// React → UCF adapter.
//
// Pure function: source → UniversalComponentFact[]. Uses the TypeScript compiler
// API for the heavy lifting; per-field mapping lives in `react-mappers.ts` so
// individual helpers can be unit-tested in isolation.
//
// Adapter never throws on a malformed file: skips the file, logs, emits zero UCFs.

import ts from "typescript";
import { existsSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve } from "node:path";

import {
  mapAria,
  mapBehaviorHints,
  mapDefinitionKind,
  mapEvents,
  mapFileCompoundChildren,
  mapFileExports,
  mapImports,
  mapJsdoc,
  mapProps,
  mapRootElements,
  mapSlots,
  mapStyleSystem,
} from "./react-mappers.js";
import type { FileExportSummary } from "./react-mappers.js";
import type { ExportRecord, UniversalComponentFact } from "./types.js";
import { ucfId } from "./ucf-id.js";

const DEFAULT_ADAPTER_VERSION = "0.1.0";
const MAX_UNWRAP_DEPTH = 6;

export interface InlineSource {
  filePath: string;
  source: string;
}

export interface ExtractUcfsForReactOptions {
  /** Absolute file paths to extract from. */
  sourceFiles?: string[];
  /** In-memory virtual files (for tests). */
  inlineSources?: InlineSource[];
  /** Path to tsconfig.json (optional). */
  tsconfig?: string;
  /** Pre-resolved compiler options for non-filesystem sources. */
  compilerOptions?: ts.CompilerOptions;
  /** 40-char SHA. Caller-provided. */
  sourceCommit: string;
  /** Defaults to '0.1.0'. */
  adapterVersion?: string;
  /** Repo root for relative filePath; defaults to process.cwd(). */
  cwd?: string;
}

interface PreparedExtraction {
  program: ts.Program;
  checker: ts.TypeChecker;
  sources: ResolvedSource[];
  ctxBase: Pick<ExtractContext, "sourceCommit" | "adapterVersion" | "capturedAt" | "cwd">;
}

// Builds the TypeScript program once over all sources (cross-file type
// resolution requires a single program — never split sources into separate
// programs, that breaks import/type resolution and silently drops props).
function prepareExtraction(opts: ExtractUcfsForReactOptions): PreparedExtraction | null {
  const sources = collectSources(opts);
  if (sources.length === 0) return null;

  const program = createProgram(sources, opts, opts.cwd ?? process.cwd());
  return {
    program,
    checker: program.getTypeChecker(),
    sources,
    ctxBase: {
      sourceCommit: opts.sourceCommit,
      adapterVersion: opts.adapterVersion ?? DEFAULT_ADAPTER_VERSION,
      capturedAt: new Date().toISOString(),
      cwd: opts.cwd ?? process.cwd(),
    },
  };
}

function extractFactsForSource(
  prepared: PreparedExtraction,
  source: ResolvedSource
): UniversalComponentFact[] {
  const sourceFile = prepared.program.getSourceFile(source.filePath);
  if (!sourceFile) {
    console.warn(`[ucf-adapter] skip: cannot load ${source.filePath}`);
    return [];
  }
  try {
    return extractFromFile({ sourceFile, checker: prepared.checker, ...prepared.ctxBase });
  } catch (err) {
    console.warn(`[ucf-adapter] error processing ${source.filePath}: ${(err as Error).message}`);
    return [];
  }
}

export function extractUcfsForReact(opts: ExtractUcfsForReactOptions): UniversalComponentFact[] {
  const prepared = prepareExtraction(opts);
  if (!prepared) return [];

  const results: UniversalComponentFact[] = [];
  for (const source of prepared.sources) {
    results.push(...extractFactsForSource(prepared, source));
  }
  return results;
}

export interface ExtractUcfsForReactStreamingOptions extends ExtractUcfsForReactOptions {
  /**
   * Yield to the event loop every N files so a host heartbeat (and any other
   * pending timers) can fire while the type-checker grinds through a large
   * repo. Defaults to 25. The per-file type-check is the bulk of the cost;
   * program construction itself stays atomic.
   */
  yieldEveryNFiles?: number;
  /** Reports extraction progress as facts accumulate. */
  onProgress?: (progress: {
    filesProcessed: number;
    totalFiles: number;
    componentsFound: number;
  }) => void;
}

function yieldToEventLoop(): Promise<void> {
  // A macrotask (setTimeout 0) lets other due timers run before we resume —
  // a microtask (Promise.resolve) would not, so the heartbeat could still
  // starve. The extract package is Node-only (it imports node:fs).
  return new Promise((resolve) => setTimeout(resolve, 0));
}

/**
 * Async, cooperatively-scheduled twin of `extractUcfsForReact`. Produces
 * identical facts for identical input — the only difference is it periodically
 * yields the event loop (and reports progress) so a long extraction over a
 * large repo can't block a host's liveness heartbeat. Used by the cloud repo
 * scanner; the synchronous `extractUcfsForReact` is unchanged for CLI/LSP/etc.
 */
export async function extractUcfsForReactStreaming(
  opts: ExtractUcfsForReactStreamingOptions
): Promise<UniversalComponentFact[]> {
  const prepared = prepareExtraction(opts);
  if (!prepared) return [];

  const yieldEvery = Math.max(1, opts.yieldEveryNFiles ?? 25);
  const total = prepared.sources.length;
  const results: UniversalComponentFact[] = [];

  for (let i = 0; i < total; i += 1) {
    results.push(...extractFactsForSource(prepared, prepared.sources[i]!));
    if ((i + 1) % yieldEvery === 0) {
      opts.onProgress?.({
        filesProcessed: i + 1,
        totalFiles: total,
        componentsFound: results.length,
      });
      await yieldToEventLoop();
    }
  }

  opts.onProgress?.({ filesProcessed: total, totalFiles: total, componentsFound: results.length });
  return results;
}

// ---------------------------------------------------------------------------
// Source collection
// ---------------------------------------------------------------------------

interface ResolvedSource {
  filePath: string;
  /** Inline source text — only set for virtual files; real paths are read by the TS host. */
  inlineSource?: string;
}

function collectSources(opts: ExtractUcfsForReactOptions): ResolvedSource[] {
  const map = new Map<string, ResolvedSource>();

  for (const filePath of opts.sourceFiles ?? []) {
    const abs = resolve(filePath);
    if (!existsSync(abs)) continue;
    map.set(abs, { filePath: abs });
  }

  for (const inline of opts.inlineSources ?? []) {
    const abs = isAbsolute(inline.filePath) ? inline.filePath : resolve(inline.filePath);
    map.set(abs, { filePath: abs, inlineSource: inline.source });
  }

  return [...map.values()];
}

// ---------------------------------------------------------------------------
// Program creation (real FS overlaid with virtual sources)
// ---------------------------------------------------------------------------

function createProgram(
  sources: ResolvedSource[],
  opts: Pick<ExtractUcfsForReactOptions, "compilerOptions" | "tsconfig">,
  cwd: string
): ts.Program {
  const options = resolveCompilerOptions(opts, cwd);
  const overlays = new Map<string, ts.SourceFile>();
  const overlayDirs = new Set<string>();
  for (const { filePath, inlineSource } of sources) {
    if (inlineSource === undefined) continue;
    let dir = dirname(filePath);
    while (dir && dir !== dirname(dir)) {
      overlayDirs.add(dir);
      dir = dirname(dir);
    }
    overlays.set(
      filePath,
      ts.createSourceFile(filePath, inlineSource, ts.ScriptTarget.Latest, true, scriptKindFor(filePath))
    );
  }

  const baseHost = ts.createCompilerHost(options, true);
  const host: ts.CompilerHost = {
    ...baseHost,
    getSourceFile: (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
      const overlay = overlays.get(resolve(fileName));
      if (overlay) return overlay;
      return baseHost.getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
    },
    fileExists: (fileName) => overlays.has(resolve(fileName)) || baseHost.fileExists(fileName),
    directoryExists: (dirName) =>
      overlayDirs.has(resolve(dirName)) || baseHost.directoryExists?.(dirName) === true,
    readFile: (fileName) => {
      const overlay = overlays.get(resolve(fileName));
      if (overlay) return overlay.getFullText();
      return baseHost.readFile(fileName);
    },
    writeFile: () => undefined,
  };

  return ts.createProgram({
    rootNames: sources.map((s) => s.filePath),
    options,
    host,
  });
}

function resolveCompilerOptions(
  opts: Pick<ExtractUcfsForReactOptions, "compilerOptions" | "tsconfig">,
  cwd: string
): ts.CompilerOptions {
  if (opts.compilerOptions) {
    return {
      ...defaultCompilerOptions(cwd),
      ...opts.compilerOptions,
    };
  }

  if (opts.tsconfig && existsSync(opts.tsconfig)) {
    const resolved = resolve(opts.tsconfig);
    const configFile = ts.readConfigFile(resolved, ts.sys.readFile);
    if (!configFile.error) {
      const parsed = ts.parseJsonConfigFileContent(
        configFile.config,
        ts.sys,
        dirname(resolved),
        undefined,
        resolved
      );
      // Force JS/JSX parsing on so plain-JavaScript projects (CRA in .jsx) are
      // analyzed, not silently skipped, regardless of the project's tsconfig.
      return {
        ...parsed.options,
        allowJs: true,
        jsx: parsed.options.jsx ?? ts.JsxEmit.ReactJSX,
      };
    }
  }
  return defaultCompilerOptions(cwd);
}

/** `.jsx`/`.js` files must be parsed as JSX scripts, not TSX, or they are dropped. */
function scriptKindFor(filePath: string): ts.ScriptKind {
  return /\.jsx?$/i.test(filePath) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX;
}

function defaultCompilerOptions(cwd: string): ts.CompilerOptions {
  return {
    target: ts.ScriptTarget.ES2022,
    module: ts.ModuleKind.ESNext,
    moduleResolution: ts.ModuleResolutionKind.Bundler,
    jsx: ts.JsxEmit.ReactJSX,
    allowJs: true,
    allowSyntheticDefaultImports: true,
    esModuleInterop: true,
    skipLibCheck: true,
    strict: false,
    noEmit: true,
    rootDir: cwd,
  };
}

// ---------------------------------------------------------------------------
// Per-file extraction
// ---------------------------------------------------------------------------

interface ExtractContext {
  sourceFile: ts.SourceFile;
  checker: ts.TypeChecker;
  sourceCommit: string;
  adapterVersion: string;
  capturedAt: string;
  cwd: string;
}

function extractFromFile(ctx: ExtractContext): UniversalComponentFact[] {
  const { sourceFile, checker } = ctx;

  const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
  const exportSymbols = moduleSymbol ? checker.getExportsOfModule(moduleSymbol) : [];
  if (exportSymbols.length === 0) return [];

  // File-level work — done once per source file, not per export.
  const filePath = toRelativePath(sourceFile.fileName, ctx.cwd);
  const imports = mapImports(sourceFile);
  const fileExports = mapFileExports(sourceFile);
  const compoundMap = mapFileCompoundChildren(sourceFile);
  const style = mapStyleSystem(sourceFile, imports);

  const facts: UniversalComponentFact[] = [];

  for (const exportSymbol of exportSymbols) {
    const exportName = exportSymbol.getName();

    const exportedDeclaration = resolveExportDeclaration(exportSymbol, checker);
    const declaration = exportedDeclaration
      ? resolveDeclarationFromNode(exportedDeclaration, checker)
      : null;
    if (!exportedDeclaration || !declaration) continue;

    const name = componentNameForExport(exportName, exportedDeclaration, declaration);
    if (!name) continue;

    const definition = mapDefinitionKind(declaration);
    if (!definition.inner) continue;

    const rootResult = mapRootElements(definition.inner);
    const aria = mapAria(rootResult.rootElements);
    const props = mapProps(definition.inner, checker, exportedDeclaration ?? declaration);
    const behavior = mapBehaviorHints(definition.inner, props);
    const docs = mapJsdoc(declaration, sourceFile);

    const id = ucfId(filePath, name, "react", ctx.sourceCommit);
    const exportRecords = buildExportRecords(fileExports, {
      componentName: name,
      exportName,
    });
    const existing = facts.find((fact) => fact.id === id);
    if (existing) {
      existing.exports = mergeExportRecords(existing.exports, exportRecords);
      continue;
    }

    facts.push({
      id,
      filePath,
      componentName: name,
      framework: "react",
      sourceCommit: ctx.sourceCommit,
      capturedAt: ctx.capturedAt,
      adapterVersion: ctx.adapterVersion,

      definitionKind: definition.kind,
      wrappedBy: definition.wrappedBy,

      imports,
      exports: exportRecords,
      exportedFromBarrel: fileExports.exportedFromBarrel,

      rootElements: rootResult.rootElements,
      rootElementsTruncated: rootResult.rootElementsTruncated,

      ariaRoles: aria.ariaRoles,
      ariaAttributes: aria.ariaAttributes,

      props,
      events: mapEvents(),
      slots: mapSlots(),

      hasInternalState: behavior.hasInternalState,
      hasEffects: behavior.hasEffects,
      isControlled: behavior.isControlled,
      compoundChildren: compoundMap.get(name) ?? [],

      styleSystem: style.styleSystem,
      classNamesUsed: style.classNamesUsed,

      jsdocDescription: docs.jsdocDescription,
      fileHeaderComment: docs.fileHeaderComment,
    });
  }

  return facts;
}

function buildExportRecords(
  fileExports: FileExportSummary,
  identity: { componentName: string; exportName: string }
): ExportRecord[] {
  const records = fileExports.exportRecords.filter(
    (record) =>
      record.exportedAs === identity.exportName ||
      (identity.exportName === "default" && record.isDefault)
  );
  const fallback =
    records.length > 0
      ? records
      : [
          {
            exportedAs: identity.exportName,
            localName:
              identity.exportName === "default" ? identity.componentName : identity.exportName,
            isDefault: identity.exportName === "default",
            fromBarrel: fileExports.exportedFromBarrel,
          },
        ];

  return fallback.map((record) => ({
    exportedAs: record.exportedAs,
    localName: record.localName,
    isDefault: record.isDefault,
    fromBarrel: record.fromBarrel,
    isPrimary:
      record.exportedAs === identity.exportName ||
      (identity.exportName === "default" && record.isDefault),
  }));
}

function mergeExportRecords(
  existing: ReadonlyArray<ExportRecord>,
  next: ReadonlyArray<ExportRecord>
): ExportRecord[] {
  const byKey = new Map<string, ExportRecord>();
  for (const record of [...existing, ...next]) {
    const key = `${record.exportedAs}:${record.localName ?? ""}`;
    const current = byKey.get(key);
    byKey.set(key, {
      ...current,
      ...record,
      isPrimary: Boolean(current?.isPrimary || record.isPrimary),
    });
  }
  return [...byKey.values()];
}

function isLikelyComponent(name: string): boolean {
  return /^[A-Z][A-Za-z0-9_]*$/.test(name);
}

function componentNameForExport(
  exportName: string,
  exportedDeclaration: ts.Node,
  declaration: ts.Node
): string | null {
  if (isLikelyComponent(exportName)) return exportName;

  const localName = declarationName(exportedDeclaration) ?? declarationName(declaration);
  return localName && isLikelyComponent(localName) ? localName : null;
}

function declarationName(node: ts.Node | null): string | null {
  if (!node) return null;
  if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
    return node.name.text;
  }
  if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
    return node.name.text;
  }
  if (ts.isExportAssignment(node) && ts.isIdentifier(node.expression)) {
    return node.expression.text;
  }
  return null;
}

function resolveExportDeclaration(symbol: ts.Symbol, checker: ts.TypeChecker): ts.Node | null {
  let target = symbol;
  if (target.flags & ts.SymbolFlags.Alias) {
    target = checker.getAliasedSymbol(target);
  }

  const decls = target.getDeclarations();
  if (!decls || decls.length === 0) return null;
  return decls[0];
}

function resolveDeclarationFromNode(decl: ts.Node, checker: ts.TypeChecker): ts.Node | null {
  if (ts.isVariableDeclaration(decl) && decl.initializer) {
    return unwrapToComponentNode(decl.initializer, checker);
  }
  if (ts.isFunctionDeclaration(decl)) return decl;
  if (ts.isClassDeclaration(decl)) return decl;
  if (ts.isExportAssignment(decl)) return unwrapToComponentNode(decl.expression, checker);
  return null;
}

/**
 * Walk through `Object.assign(Root, {...})` calls and identifier references to
 * find the underlying component definition. forwardRef / memo / styled / observer
 * calls are returned as-is — `mapDefinitionKind` records them as `wrapper`.
 */
function unwrapToComponentNode(expression: ts.Expression, checker: ts.TypeChecker): ts.Node {
  let cursor: ts.Expression = expression;
  for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
    if (
      ts.isCallExpression(cursor) &&
      ts.isPropertyAccessExpression(cursor.expression) &&
      ts.isIdentifier(cursor.expression.expression) &&
      cursor.expression.expression.text === "Object" &&
      cursor.expression.name.text === "assign" &&
      cursor.arguments[0]
    ) {
      cursor = cursor.arguments[0];
      continue;
    }

    if (ts.isIdentifier(cursor)) {
      const sym = checker.getSymbolAtLocation(cursor);
      if (!sym) break;
      const followed = sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
      const decl = followed.getDeclarations()?.[0];
      if (decl && ts.isFunctionDeclaration(decl)) return decl;
      if (decl && ts.isClassDeclaration(decl)) return decl;
      if (decl && ts.isVariableDeclaration(decl) && decl.initializer) {
        cursor = decl.initializer;
        continue;
      }
      break;
    }

    break;
  }
  return cursor;
}

function toRelativePath(filePath: string, cwd: string): string {
  const rel = relative(cwd, filePath);
  return rel.startsWith("..") ? filePath : rel;
}
