/**
 * JSX/TSX usage extractor — turns one source file into `usage_*` facts.
 *
 * The extractor is a pure function over (source, filePath). It produces:
 *
 *   - `usage_node`            for each JSX element
 *   - `usage_component`       for elements imported from a non-relative source
 *   - `usage_prop_resolved`   for each JSX attribute or structural child prop
 *   - `usage_inline_style`    for each property in `style={{...}}`
 *   - `usage_text_child`      for each JSXText/string child
 *
 * Locations point at the offending value (e.g., the `<Button` opening, the
 * `padding` property key, the literal `"secondary"`), not just the file. The
 * extractor never resolves modules from disk — it only walks imports declared
 * at the top of the file.
 */

import { parse } from "@babel/parser";
import _traverse, { type NodePath } from "@babel/traverse";
import * as t from "@babel/types";

import {
  asComponentId,
  type Fact,
  makeUsageComponentFact,
  makeUsageImportFact,
  makeUsageInlineStyleFact,
  makeUsageNodeFact,
  makeUsagePropResolvedFact,
  makeUsageTextChildFact,
  type ComponentId,
  type FactLocation,
  type UsageNodeFact,
} from "@fragments-sdk/core";

import { BABEL_PARSER_OPTIONS } from "./babel-config.js";
import { jsxAttributeName, locFromNode } from "./babel-helpers.js";
import {
  extractClassnameFactsFromAttribute,
  extractClassnameSpreadFact,
  resolveRecognizedHelpers,
  type BindingResolver,
  type ExtractClassnamesOptions,
} from "../tailwind/extract-classnames.js";

// `@babel/traverse` is published as a CJS module whose default export is the
// function. Importing under ESM yields `{ default: traverse }`; this unwrap
// keeps the call site working in both bundler resolutions.
const traverse = (_traverse as unknown as { default?: typeof _traverse }).default ?? _traverse;

interface ImportRecord {
  /** Local binding name as used inside JSX (e.g., `Button`, `UI`). */
  local: string;
  /** Originally exported name (`default` for default imports). */
  imported: string;
  /** Module specifier (`@fragments-sdk/ui`, `./components/Card`). */
  source: string;
  /** A namespace import (`import * as UI from ...`). */
  namespace?: boolean;
  location: FactLocation;
}

type ImportMap = Map<string, ImportRecord>;

export interface ComponentImportResolution {
  filePath: string;
  elementName: string;
  local: string;
  imported: string;
  source: string;
  namespace?: boolean;
}

export interface ExtractJsxFactsOptions {
  resolveComponentId?: (resolution: ComponentImportResolution) => ComponentId | null | undefined;
  /**
   * Identifier names recognized as className-helper callees (e.g. `clsx`,
   * `cn`). Supplying this option *replaces* the default list — it is not
   * merged with the defaults.
   */
  recognizedClassHelpers?: ExtractClassnamesOptions["recognizedClassHelpers"];
}

export function extractJsxFacts(
  source: string,
  filePath: string,
  options: ExtractJsxFactsOptions = {}
): Fact[] {
  let ast: t.File;
  try {
    ast = parse(source, BABEL_PARSER_OPTIONS);
  } catch {
    return [];
  }

  const imports = collectImports(ast, filePath);
  const facts: Fact[] = [];
  const nodeIndex = new Map<t.Node, number>();
  const childIndex = new Map<t.Node, number>();
  const recognizedHelpers = resolveRecognizedHelpers(options.recognizedClassHelpers);

  for (const record of imports.values()) {
    facts.push(
      makeUsageImportFact({
        file: filePath,
        local: record.local,
        imported: record.imported,
        source: record.source,
        namespace: record.namespace,
        location: record.location,
      })
    );
  }

  traverse(ast, {
    JSXElement(path) {
      const opening = path.node.openingElement;
      const elementName = jsxElementName(opening);
      if (!elementName) return;

      const nodePath = computeNodePath(path, nodeIndex, childIndex);
      const location = locFromNode(opening, filePath);
      if (!location) return;

      const semantics = extractElementSemantics(opening);
      const nodeFact = makeUsageNodeFact({
        file: filePath,
        nodePath,
        element: elementName,
        ...(semantics.role !== undefined ? { role: semantics.role } : {}),
        ...(semantics.interactive ? { interactive: true } : {}),
        location,
      });
      facts.push(nodeFact);

      const componentId = resolveComponentId(elementName, imports, filePath, options);
      if (componentId) {
        facts.push(makeUsageComponentFact({ nodeId: nodeFact.id, componentId }));
      }

      facts.push(...extractAttributeFacts(path, nodeFact, recognizedHelpers));
      facts.push(...extractChildPropFacts(path, nodeFact));
      facts.push(...extractTextChildren(path, nodeFact));
    },
  });

  return facts;
}

// ---------------------------------------------------------------------------
// Imports
// ---------------------------------------------------------------------------

function collectImports(ast: t.File, filePath: string): ImportMap {
  const map: ImportMap = new Map();
  for (const node of ast.program.body) {
    if (!t.isImportDeclaration(node)) continue;
    const location = locFromNode(node, filePath);
    if (!location) continue;
    const source = node.source.value;
    for (const spec of node.specifiers) {
      if (t.isImportSpecifier(spec)) {
        const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
        map.set(spec.local.name, {
          local: spec.local.name,
          imported,
          source,
          location,
        });
      } else if (t.isImportDefaultSpecifier(spec)) {
        map.set(spec.local.name, {
          local: spec.local.name,
          imported: "default",
          source,
          location,
        });
      } else if (t.isImportNamespaceSpecifier(spec)) {
        map.set(spec.local.name, {
          local: spec.local.name,
          imported: spec.local.name,
          source,
          namespace: true,
          location,
        });
      }
    }
  }
  return map;
}

function isRelativeSource(source: string): boolean {
  return source.startsWith("./") || source.startsWith("../") || source === ".";
}

function resolveComponentId(
  elementName: string,
  imports: ImportMap,
  filePath: string,
  options: ExtractJsxFactsOptions
): ComponentId | undefined {
  // Member expressions:
  //   <UI.Card>      from `import * as UI from "@pkg/ui"` -> @pkg/ui#Card
  //   <Card.Header> from `import { Card } from "@pkg/ui"` -> @pkg/ui#Card.Header
  //   <Card.Header> from `import Card from "@pkg/card"` -> @pkg/card#default.Header
  if (elementName.includes(".")) {
    const [head, ...tail] = elementName.split(".");
    const record = imports.get(head);
    if (!record) return undefined;
    const member = tail.join(".");
    const imported = record.namespace ? member : `${record.imported}.${member}`;
    const resolved = options.resolveComponentId?.({
      filePath,
      elementName,
      local: record.local,
      imported,
      source: record.source,
      namespace: record.namespace,
    });
    if (resolved) return resolved;
    if (resolved === null) return undefined;
    if (isRelativeSource(record.source)) return undefined;
    return asComponentId(`${record.source}#${imported}`);
  }

  const record = imports.get(elementName);
  if (!record) return undefined;
  const resolved = options.resolveComponentId?.({
    filePath,
    elementName,
    local: record.local,
    imported: record.imported,
    source: record.source,
    namespace: record.namespace,
  });
  if (resolved) return resolved;
  if (resolved === null) return undefined;
  if (isRelativeSource(record.source)) return undefined;
  return asComponentId(`${record.source}#${record.imported}`);
}

// ---------------------------------------------------------------------------
// JSX traversal helpers
// ---------------------------------------------------------------------------

/**
 * Normalize the framework-neutral element semantics the rule engine reasons
 * over: the static ARIA `role` and whether the element carries an interaction
 * handler. This is the React/JSX *adapter* for the producer seam — a Vue or
 * Svelte adapter would compute the same `{ role, interactive }` shape from its
 * own markup so `usage_node.role`/`.interactive` stay framework-agnostic.
 */
function extractElementSemantics(opening: t.JSXOpeningElement): {
  role?: string;
  interactive: boolean;
} {
  let role: string | undefined;
  let interactive = false;
  for (const attr of opening.attributes) {
    if (!t.isJSXAttribute(attr)) continue;
    const name = jsxAttributeName(attr);
    if (!name) continue;
    if (name === "role") {
      const value = staticStringAttrValue(attr.value);
      if (value) role = value;
    } else if (INTERACTION_HANDLER_PATTERN.test(name)) {
      interactive = true;
    }
  }
  return { role, interactive };
}

const INTERACTION_HANDLER_PATTERN = /^on[A-Z]/;

function staticStringAttrValue(value: t.JSXAttribute["value"]): string | undefined {
  if (t.isStringLiteral(value)) return value.value;
  if (t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression)) {
    return value.expression.value;
  }
  return undefined;
}

function jsxElementName(opening: t.JSXOpeningElement): string | undefined {
  const name = opening.name;
  if (t.isJSXIdentifier(name)) return name.name;
  if (t.isJSXMemberExpression(name)) return jsxMemberExpressionName(name);
  // JSXNamespacedName (`<svg:rect>`) — out of scope for component governance.
  return undefined;
}

function jsxMemberExpressionName(node: t.JSXMemberExpression): string {
  const parts: string[] = [];
  let current: t.JSXMemberExpression | t.JSXIdentifier = node;
  while (t.isJSXMemberExpression(current)) {
    parts.unshift(current.property.name);
    current = current.object;
  }
  if (t.isJSXIdentifier(current)) parts.unshift(current.name);
  return parts.join(".");
}

function computeNodePath(
  path: NodePath<t.JSXElement>,
  nodeIndex: Map<t.Node, number>,
  childIndex: Map<t.Node, number>
): string {
  // Build a structural path of JSX ancestors from the root down. JSX embedded
  // inside expression containers (`{ok && <div />}`, ternaries, map callbacks)
  // needs the expression branch too; otherwise a conditional child can collide
  // with a normal sibling at the same JSX depth.
  const segments: string[] = [];
  let current: NodePath<t.Node> | null = path;
  while (current && current.node) {
    if (t.isJSXElement(current.node)) {
      if (isJsxChildrenParent(current.parent)) {
        segments.unshift(String(siblingIndex(current, childIndex)));
      } else {
        segments.unshift(`jsx:${pathEdge(current)}`);
      }
    } else if (t.isJSXExpressionContainer(current.node)) {
      segments.unshift(`expr:${jsxChildIndex(current, childIndex)}`);
    } else if (t.isJSXAttribute(current.node)) {
      segments.unshift(`attr:${jsxAttributeName(current.node) ?? "unknown"}`);
    } else if (isInsideJsxExpression(current)) {
      segments.unshift(pathEdge(current));
    }
    current = current.parentPath;
  }
  const root = jsxRoot(path);
  const fileIndex = nodeIndex.get(root.node) ?? nodeIndex.size;
  nodeIndex.set(root.node, fileIndex);
  return `${fileIndex}:${segments.join("/")}`;
}

function jsxRoot(path: NodePath<t.JSXElement>): NodePath<t.Node> {
  let root: NodePath<t.Node> = path;
  let current: NodePath<t.Node> | null = path.parentPath;
  while (current) {
    if (t.isJSXElement(current.node) || t.isJSXFragment(current.node)) {
      root = current;
    }
    current = current.parentPath;
  }
  return root;
}

function isJsxChildrenParent(
  node: t.Node | null | undefined
): node is t.JSXElement | t.JSXFragment {
  return !!node && (t.isJSXElement(node) || t.isJSXFragment(node));
}

function pathEdge(path: NodePath<t.Node>): string {
  const key = String(path.key);
  return path.listKey ? `${path.listKey}[${key}]` : key;
}

function isInsideJsxExpression(path: NodePath<t.Node>): boolean {
  let current = path.parentPath;
  while (current) {
    if (t.isJSXExpressionContainer(current.node)) return true;
    if (t.isJSXElement(current.node) || t.isJSXFragment(current.node)) {
      return false;
    }
    current = current.parentPath;
  }
  return false;
}

function siblingIndex(path: NodePath<t.Node>, cache: Map<t.Node, number>): number {
  if (cache.has(path.node)) return cache.get(path.node)!;
  const parent = path.parent;
  if (!parent || !("children" in parent) || !Array.isArray(parent.children)) {
    cache.set(path.node, 0);
    return 0;
  }
  let i = 0;
  for (const child of parent.children) {
    if (!t.isJSXElement(child)) continue;
    if (child === path.node) {
      cache.set(child, i);
      return i;
    }
    i++;
  }
  cache.set(path.node, 0);
  return 0;
}

function jsxChildIndex(path: NodePath<t.Node>, cache: Map<t.Node, number>): number {
  if (cache.has(path.node)) return cache.get(path.node)!;
  const parent = path.parent;
  if (!isJsxChildrenParent(parent)) {
    cache.set(path.node, 0);
    return 0;
  }
  let i = 0;
  for (const child of parent.children) {
    const significant =
      t.isJSXElement(child) || t.isJSXFragment(child) || t.isJSXExpressionContainer(child);
    if (!significant) continue;
    if (child === path.node) {
      cache.set(child, i);
      return i;
    }
    i++;
  }
  cache.set(path.node, 0);
  return 0;
}

// ---------------------------------------------------------------------------
// Attribute / inline-style extraction
// ---------------------------------------------------------------------------

/**
 * Build a same-file `const`-binding resolver from the element's scope so the
 * className extractor can fold a hoisted `const classes = …` into its literals.
 * Only constant, statically-initialized bindings resolve; everything else (a
 * reassigned let, an imported/param binding, a runtime value) returns
 * undefined and stays an opaque dynamic span.
 */
function makeBindingResolver(path: NodePath<t.JSXElement>): BindingResolver {
  return (name: string) => {
    const binding = path.scope.getBinding(name);
    if (!binding || !binding.constant) return undefined;
    if (binding.kind !== "const" && binding.kind !== "module") return undefined;
    const node = binding.path.node;
    if (t.isVariableDeclarator(node) && node.init && t.isExpression(node.init)) {
      return node.init;
    }
    return undefined;
  };
}

function extractAttributeFacts(
  path: NodePath<t.JSXElement>,
  nodeFact: UsageNodeFact,
  recognizedHelpers: ReadonlySet<string>
): Fact[] {
  const facts: Fact[] = [];
  const attributes = path.node.openingElement.attributes;
  let spreadIndex = 0;
  let firstSpread: t.JSXSpreadAttribute | undefined;
  let sawClassName = false;

  for (const attr of attributes) {
    if (t.isJSXSpreadAttribute(attr)) {
      if (!firstSpread) firstSpread = attr;
      const spreadKey = `...${spreadSourceName(attr.argument) ?? `arg${spreadIndex}`}`;
      facts.push(
        makeUsagePropResolvedFact({
          nodeId: nodeFact.id,
          prop: spreadKey,
          resolution: "spread",
          location: locFromNode(attr, nodeFact.file),
        })
      );
      spreadIndex++;
      continue;
    }

    if (!t.isJSXAttribute(attr)) continue;
    const name = jsxAttributeName(attr);
    if (!name) continue;

    if (name === "style") {
      facts.push(...extractInlineStyleFacts(attr, nodeFact));
      continue;
    }

    if (name === "className" || name === "class") {
      sawClassName = true;
      facts.push(
        ...extractClassnameFactsFromAttribute({
          attr,
          file: nodeFact.file,
          nodeId: nodeFact.id,
          recognizedHelpers,
          resolveBinding: makeBindingResolver(path),
        })
      );
    }

    facts.push(...extractPropFact(attr, name, nodeFact));
  }

  // Spread without an explicit className → emit a single dynamic spread fact
  // so the user sees the class slot was considered.
  if (firstSpread && !sawClassName) {
    facts.push(
      ...extractClassnameSpreadFact({
        spread: firstSpread,
        file: nodeFact.file,
        nodeId: nodeFact.id,
      })
    );
  }

  return facts;
}

function spreadSourceName(node: t.Expression): string | undefined {
  if (t.isIdentifier(node)) return node.name;
  if (t.isMemberExpression(node)) {
    const obj = t.isIdentifier(node.object) ? node.object.name : "";
    const prop = t.isIdentifier(node.property) && !node.computed ? node.property.name : "";
    if (obj && prop) return `${obj}.${prop}`;
  }
  return undefined;
}

function extractPropFact(attr: t.JSXAttribute, name: string, nodeFact: UsageNodeFact): Fact[] {
  const value = attr.value;
  const location = propValueLocation(attr, nodeFact.file);

  // <Foo bar />  → boolean true (static)
  if (value === null || value === undefined) {
    return [
      makeUsagePropResolvedFact({
        nodeId: nodeFact.id,
        prop: name,
        resolution: "static",
        value: true,
        location,
      }),
    ];
  }

  if (t.isStringLiteral(value)) {
    return [
      makeUsagePropResolvedFact({
        nodeId: nodeFact.id,
        prop: name,
        resolution: "static",
        value: value.value,
        location,
      }),
    ];
  }

  if (t.isJSXExpressionContainer(value)) {
    const expr = value.expression;
    if (t.isStringLiteral(expr)) {
      return [
        makeUsagePropResolvedFact({
          nodeId: nodeFact.id,
          prop: name,
          resolution: "static",
          value: expr.value,
          location,
        }),
      ];
    }
    if (t.isNumericLiteral(expr)) {
      return [
        makeUsagePropResolvedFact({
          nodeId: nodeFact.id,
          prop: name,
          resolution: "static",
          value: expr.value,
          location,
        }),
      ];
    }
    if (t.isBooleanLiteral(expr)) {
      return [
        makeUsagePropResolvedFact({
          nodeId: nodeFact.id,
          prop: name,
          resolution: "static",
          value: expr.value,
          location,
        }),
      ];
    }
    if (t.isNullLiteral(expr)) {
      return [
        makeUsagePropResolvedFact({
          nodeId: nodeFact.id,
          prop: name,
          resolution: "static",
          value: null,
          location,
        }),
      ];
    }
    if (t.isJSXEmptyExpression(expr)) return [];
    return [
      makeUsagePropResolvedFact({
        nodeId: nodeFact.id,
        prop: name,
        resolution: "dynamic",
        location,
      }),
    ];
  }

  return [];
}

function propValueLocation(attr: t.JSXAttribute, file: string): FactLocation | undefined {
  if (!attr.value) return locFromNode(attr.name, file);
  if (t.isStringLiteral(attr.value)) return locFromNode(attr.value, file);
  if (t.isJSXExpressionContainer(attr.value)) {
    if (t.isJSXEmptyExpression(attr.value.expression)) {
      return locFromNode(attr.value, file);
    }
    return locFromNode(attr.value.expression, file) ?? locFromNode(attr.value, file);
  }
  return locFromNode(attr, file);
}

function extractInlineStyleFacts(attr: t.JSXAttribute, nodeFact: UsageNodeFact): Fact[] {
  const value = attr.value;
  if (!value || !t.isJSXExpressionContainer(value)) return [];
  const expr = value.expression;
  if (!t.isObjectExpression(expr)) return [];

  const facts: Fact[] = [];
  for (const prop of expr.properties) {
    if (!t.isObjectProperty(prop)) continue;
    const property = objectKey(prop.key);
    if (!property) continue;

    const resolved = resolveStaticStyleValue(prop.value);
    if (!resolved) continue;
    facts.push(
      makeUsageInlineStyleFact({
        nodeId: nodeFact.id,
        property,
        valueKind: resolved.valueKind,
        value: resolved.value,
      })
    );
  }
  return facts;
}

interface ResolvedStyleValue {
  valueKind: "static" | "number" | "css-variable";
  value: string;
}

/**
 * Resolve a JSX inline-style value expression to a static value when possible.
 * Handles string/number literals, `var(...)` calls, single-quasi templates, and
 * — critically — conditional expressions (`cond ? '#f00' : '#0f0'`): a raw value
 * hidden in a ternary branch must not silently bypass the contract. Since the
 * fact identity is `{node, property}` we can surface only one value per
 * property, so we prefer the branch that actually carries a raw color.
 */
function resolveStaticStyleValue(node: t.Node): ResolvedStyleValue | null {
  if (t.isStringLiteral(node)) return { valueKind: "static", value: node.value };
  if (t.isNumericLiteral(node)) return { valueKind: "number", value: String(node.value) };
  if (t.isCallExpression(node) && isVarCall(node)) {
    return { valueKind: "css-variable", value: callSource(node) };
  }
  if (t.isTemplateLiteral(node) && node.quasis.length === 1) {
    return { valueKind: "static", value: node.quasis[0].value.cooked ?? "" };
  }
  if (t.isConditionalExpression(node)) {
    const consequent = resolveStaticStyleValue(node.consequent);
    const alternate = resolveStaticStyleValue(node.alternate);
    // Surface whichever branch carries a raw color; otherwise prefer the
    // consequent (then the alternate) so off-scale spacing still gets seen.
    if (consequent && looksRawColor(consequent.value)) return consequent;
    if (alternate && looksRawColor(alternate.value)) return alternate;
    return consequent ?? alternate;
  }
  return null;
}

function looksRawColor(value: string): boolean {
  return /#[0-9a-f]{3,8}\b|\b(rgb|rgba|hsl|hsla)\s*\(/i.test(value);
}

function objectKey(key: t.Node): string | undefined {
  if (t.isIdentifier(key)) return key.name;
  if (t.isStringLiteral(key)) return key.value;
  if (t.isNumericLiteral(key)) return String(key.value);
  return undefined;
}

function isVarCall(call: t.CallExpression): boolean {
  return (
    t.isIdentifier(call.callee, { name: "var" }) &&
    call.arguments.length >= 1 &&
    t.isStringLiteral(call.arguments[0])
  );
}

function callSource(call: t.CallExpression): string {
  if (t.isIdentifier(call.callee, { name: "var" }) && t.isStringLiteral(call.arguments[0])) {
    return `var(${call.arguments[0].value})`;
  }
  return "";
}

// ---------------------------------------------------------------------------
// Text children
// ---------------------------------------------------------------------------

function extractChildPropFacts(path: NodePath<t.JSXElement>, nodeFact: UsageNodeFact): Fact[] {
  for (const child of path.node.children) {
    const location = structuralChildLocation(child, nodeFact.file);
    if (!location) continue;
    return [
      makeUsagePropResolvedFact({
        nodeId: nodeFact.id,
        prop: "children",
        resolution: "jsx",
        location,
      }),
    ];
  }
  return [];
}

function structuralChildLocation(
  child: t.JSXElement["children"][number],
  file: string
): FactLocation | undefined {
  if (t.isJSXElement(child) || t.isJSXFragment(child)) {
    return locFromNode(child, file);
  }

  if (!t.isJSXExpressionContainer(child)) return undefined;
  const expression = child.expression;
  if (t.isJSXEmptyExpression(expression) || t.isStringLiteral(expression)) {
    return undefined;
  }
  return locFromNode(expression, file) ?? locFromNode(child, file);
}

function extractTextChildren(path: NodePath<t.JSXElement>, nodeFact: UsageNodeFact): Fact[] {
  const facts: Fact[] = [];
  let index = 0;
  for (const child of path.node.children) {
    if (t.isJSXText(child)) {
      const text = child.value.trim();
      if (!text) continue;
      facts.push(
        makeUsageTextChildFact({
          nodeId: nodeFact.id,
          text,
          index: index++,
        })
      );
      continue;
    }
    if (t.isJSXExpressionContainer(child) && t.isStringLiteral(child.expression)) {
      facts.push(
        makeUsageTextChildFact({
          nodeId: nodeFact.id,
          text: child.expression.value,
          index: index++,
        })
      );
    }
  }
  return facts;
}
