/**
 * Walks JSX `className` (or `class`) attributes and emits `classname_literal`
 * / `classname_dynamic` facts. Pure; never throws on user-input shapes —
 * parser surprises degrade to a `classname_dynamic` fact with a snippet so
 * the scan never aborts mid-file.
 *
 * Invoked from the JSX usage extractor (see `../usage/jsx.ts`) so the AST
 * is walked exactly once per file.
 */

import * as t from "@babel/types";

import {
  makeClassNameDynamicFact,
  makeClassNameLiteralFact,
  type ClassNameDynamicReason,
  type ClassNameOrigin,
  type Fact,
  type FactId,
  type FactLocation,
} from "@fragments-sdk/core";

import { jsxAttributeName, locFromNode } from "../usage/babel-helpers.js";
import { parseClass } from "./parse-class.js";

/**
 * Single source of truth for which call expressions Stage 1 recurses into,
 * and which `ClassNameOrigin` each helper imprints on the literals it
 * extracts. `cva` is recognized but never recursed — its variants object is
 * out of scope for Stage 1.
 */
const HELPER_ORIGINS: Record<string, ClassNameOrigin> = {
  clsx: "clsx",
  cn: "cn",
  classnames: "classnames",
  twMerge: "twMerge",
  tw: "tw",
};

/** Recognized callees: HELPER_ORIGINS keys plus `cva` (always opaque). */
export const DEFAULT_RECOGNIZED_CLASS_HELPERS = Object.freeze([
  ...Object.keys(HELPER_ORIGINS),
  "cva",
] as const);

const DEFAULT_RECOGNIZED_HELPERS_SET: ReadonlySet<string> = new Set(
  DEFAULT_RECOGNIZED_CLASS_HELPERS,
);

const SNIPPET_MAX = 120;

export interface ExtractClassnamesOptions {
  /**
   * 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?: readonly string[];
}

/**
 * Resolve a same-file identifier to the expression it is `const`-bound to, when
 * that binding is constant and statically initialized. Supplied by the JSX
 * walker (which has the Babel scope) so a className built from a hoisted const —
 * `const tone = cond ? 'bg-[#abcdef]' : 'bg-brand'; <div className={\`p ${tone}\`}/>`
 * — is folded and graded instead of being written off as fully dynamic (BUG 5).
 */
export type BindingResolver = (name: string) => t.Expression | undefined;

const MAX_BINDING_DEPTH = 8;

/**
 * Resolve the recognized-helper set once per file so the per-attribute hot
 * path doesn't reallocate. Returns the cached default set when the override
 * is absent.
 */
export function resolveRecognizedHelpers(
  override: readonly string[] | undefined,
): ReadonlySet<string> {
  if (!override) return DEFAULT_RECOGNIZED_HELPERS_SET;
  return new Set(override);
}

interface Context {
  file: string;
  nodeId: FactId;
  attr: string;
  recognizedHelpers: ReadonlySet<string>;
  resolveBinding?: BindingResolver;
  /** Identifier names currently being resolved — guards against cycles. */
  resolving: Set<string>;
  literalSeq: number;
  dynamicSeq: number;
  out: Fact[];
}

/**
 * Extract className facts from a JSX attribute. The caller supplies the
 * parent element's `usage_node` identity so the facts join back to the
 * existing usage graph without re-walking.
 */
export function extractClassnameFactsFromAttribute(input: {
  attr: t.JSXAttribute;
  file: string;
  nodeId: FactId;
  recognizedHelpers?: ReadonlySet<string>;
  resolveBinding?: BindingResolver;
}): Fact[] {
  const ctx: Context = {
    file: input.file,
    nodeId: input.nodeId,
    attr: jsxAttributeName(input.attr) ?? "className",
    recognizedHelpers:
      input.recognizedHelpers ?? DEFAULT_RECOGNIZED_HELPERS_SET,
    resolveBinding: input.resolveBinding,
    resolving: new Set(),
    literalSeq: 0,
    dynamicSeq: 0,
    out: [],
  };

  try {
    handleAttributeValue(input.attr, ctx);
  } catch {
    emitDynamic(
      ctx,
      "computed",
      sourceSnippet(input.attr),
      locFromNode(input.attr, ctx.file),
    );
  }

  return ctx.out;
}

/**
 * Emit a single `classname_dynamic { reason: 'spread' }` fact for an element
 * whose only opportunity to set className is via a spread attribute.
 */
export function extractClassnameSpreadFact(input: {
  spread: t.JSXSpreadAttribute;
  file: string;
  nodeId: FactId;
}): Fact[] {
  return [
    makeClassNameDynamicFact({
      file: input.file,
      nodeId: input.nodeId,
      attr: "className",
      reason: "spread",
      snippet: clip(sourceSnippet(input.spread), SNIPPET_MAX),
      originPath: "spread#0",
      location: locFromNode(input.spread, input.file) ?? syntheticLocation(input.file),
    }),
  ];
}

// ---------------------------------------------------------------------------
// Attribute-level dispatch
// ---------------------------------------------------------------------------

function handleAttributeValue(attr: t.JSXAttribute, ctx: Context): void {
  const value = attr.value;
  const fallbackLocation = locFromNode(attr, ctx.file);

  if (value === null || value === undefined) {
    emitLiteral(ctx, "attribute", [], fallbackLocation);
    return;
  }

  if (t.isStringLiteral(value)) {
    emitLiteral(
      ctx,
      "attribute",
      splitClassString(value.value),
      locFromNode(value, ctx.file) ?? fallbackLocation,
    );
    return;
  }

  if (t.isJSXExpressionContainer(value)) {
    const expr = value.expression;
    if (t.isJSXEmptyExpression(expr)) {
      emitLiteral(ctx, "attribute", [], fallbackLocation);
      return;
    }
    const startOrigin: ClassNameOrigin = t.isTemplateLiteral(expr)
      ? "template"
      : "attribute";
    handleExpression(expr, ctx, fallbackLocation, "attribute", startOrigin);
    return;
  }

  emitOpaque(ctx, value, fallbackLocation);
}

// ---------------------------------------------------------------------------
// Expression dispatch
// ---------------------------------------------------------------------------

type ExprContext = "attribute" | "argument";

function handleExpression(
  expr: t.Expression,
  ctx: Context,
  fallbackLocation: FactLocation | undefined,
  exprContext: ExprContext,
  origin: ClassNameOrigin = "attribute",
): { literals: string[]; emittedDynamic: boolean } {
  const acc: string[] = [];
  let emittedDynamic = false;

  const visit = (
    node: t.Expression | t.SpreadElement | t.JSXNamespacedName | t.ArgumentPlaceholder,
  ): void => {
    if (t.isStringLiteral(node)) {
      acc.push(...splitClassString(node.value));
      return;
    }
    if (t.isTemplateLiteral(node)) {
      visitTemplate(node, ctx, fallbackLocation, acc, () => {
        emittedDynamic = true;
      });
      return;
    }
    if (t.isConditionalExpression(node)) {
      const c = handleExpression(node.consequent, ctx, fallbackLocation, exprContext, origin);
      const a = handleExpression(node.alternate, ctx, fallbackLocation, exprContext, origin);
      acc.push(...c.literals, ...a.literals);
      if (c.emittedDynamic || a.emittedDynamic) emittedDynamic = true;
      return;
    }
    if (t.isLogicalExpression(node)) {
      // For `x && classes`, `x || classes`, `x ?? classes` — the value is on
      // the right; the left is a guard and contributes nothing to className.
      const r = handleExpression(node.right, ctx, fallbackLocation, exprContext, origin);
      acc.push(...r.literals);
      if (r.emittedDynamic) emittedDynamic = true;
      return;
    }
    if (t.isObjectExpression(node)) {
      for (const prop of node.properties) {
        if (t.isObjectProperty(prop)) {
          const key = objectPropertyKey(prop);
          if (key) acc.push(...splitClassString(key));
          continue;
        }
        emittedDynamic = true;
        emitOpaque(ctx, prop, fallbackLocation);
      }
      return;
    }
    if (t.isArrayExpression(node)) {
      for (const el of node.elements) {
        if (el == null) continue;
        if (t.isSpreadElement(el)) {
          emittedDynamic = true;
          emitOpaque(ctx, el, fallbackLocation);
          continue;
        }
        const sub = handleExpression(el, ctx, fallbackLocation, exprContext, origin);
        acc.push(...sub.literals);
        if (sub.emittedDynamic) emittedDynamic = true;
      }
      return;
    }
    if (t.isCallExpression(node)) {
      handleCall(node, ctx, fallbackLocation);
      return;
    }
    if (
      t.isParenthesizedExpression(node) ||
      t.isTSAsExpression(node) ||
      t.isTSSatisfiesExpression(node) ||
      t.isTSNonNullExpression(node) ||
      t.isTSTypeAssertion(node)
    ) {
      const sub = handleExpression(node.expression, ctx, fallbackLocation, exprContext, origin);
      acc.push(...sub.literals);
      if (sub.emittedDynamic) emittedDynamic = true;
      return;
    }
    if (t.isNullLiteral(node)) {
      emittedDynamic = true;
      emitDynamic(ctx, "computed", "null", locFromNode(node, ctx.file) ?? fallbackLocation);
      return;
    }
    if (t.isIdentifier(node) && node.name === "undefined") {
      emittedDynamic = true;
      emitDynamic(ctx, "computed", "undefined", locFromNode(node, ctx.file) ?? fallbackLocation);
      return;
    }
    if (t.isIdentifier(node) || t.isMemberExpression(node)) {
      if (t.isIdentifier(node)) {
        const resolved = resolveIdentifier(node.name, ctx, fallbackLocation, exprContext, origin);
        if (resolved) {
          acc.push(...resolved.literals);
          if (resolved.emittedDynamic) emittedDynamic = true;
          return;
        }
      }
      emittedDynamic = true;
      emitDynamic(
        ctx,
        "identifier",
        sourceSnippet(node),
        locFromNode(node, ctx.file) ?? fallbackLocation,
      );
      return;
    }
    emittedDynamic = true;
    emitOpaque(ctx, node, fallbackLocation);
  };

  visit(expr);

  if (exprContext === "attribute" && acc.length > 0) {
    emitLiteral(ctx, origin, acc, locFromNode(expr, ctx.file) ?? fallbackLocation);
  }

  return { literals: acc, emittedDynamic };
}

function visitTemplate(
  tmpl: t.TemplateLiteral,
  ctx: Context,
  fallbackLocation: FactLocation | undefined,
  acc: string[],
  setDynamic: () => void,
): void {
  // Static quasis between interpolations only contribute fully-bounded class
  // tokens: tokens that abut an interpolation boundary are partial and must
  // be dropped, otherwise we'd grade `p-` as a class and emit a false
  // arbitrary-spacing warning when authors meant `p-${size}`.
  const { quasis, expressions } = tmpl;
  for (let i = 0; i < quasis.length; i++) {
    const text = quasis[i].value.cooked ?? quasis[i].value.raw ?? "";
    const tokens = splitClassString(text);
    if (tokens.length === 0) continue;
    if (i > 0 && !/^\s/.test(text)) tokens.shift();
    if (i < quasis.length - 1 && !/\s$/.test(text)) tokens.pop();
    acc.push(...tokens);
  }
  // An interpolation `${tone}` that resolves to a hoisted const is folded so
  // its classes are graded; anything else stays an opaque dynamic span.
  let unresolved = 0;
  for (const expr of expressions) {
    if (t.isIdentifier(expr)) {
      const resolved = resolveIdentifier(expr.name, ctx, fallbackLocation, "argument", "attribute");
      if (resolved) {
        acc.push(...resolved.literals);
        if (resolved.emittedDynamic) unresolved++;
        continue;
      }
    }
    unresolved++;
  }
  if (unresolved > 0) {
    setDynamic();
    emitDynamic(
      ctx,
      "template_interpolation",
      sourceSnippet(tmpl),
      locFromNode(tmpl, ctx.file) ?? fallbackLocation,
    );
  }
}

/**
 * Fold a `const`-bound identifier into its initializer's class literals. Returns
 * `undefined` when there is no resolver, no static const binding, or the cycle
 * guard / depth limit trips — the caller then falls back to a dynamic fact.
 */
function resolveIdentifier(
  name: string,
  ctx: Context,
  fallbackLocation: FactLocation | undefined,
  exprContext: ExprContext,
  origin: ClassNameOrigin,
): { literals: string[]; emittedDynamic: boolean } | undefined {
  if (!ctx.resolveBinding) return undefined;
  if (ctx.resolving.has(name) || ctx.resolving.size >= MAX_BINDING_DEPTH) return undefined;
  const init = ctx.resolveBinding(name);
  if (!init) return undefined;
  ctx.resolving.add(name);
  try {
    // "argument" context returns literals without emitting a literal fact here;
    // the caller decides how to surface them.
    return handleExpression(init, ctx, fallbackLocation, exprContext === "attribute" ? "argument" : exprContext, origin);
  } finally {
    ctx.resolving.delete(name);
  }
}

// ---------------------------------------------------------------------------
// Call expressions
// ---------------------------------------------------------------------------

function handleCall(
  call: t.CallExpression,
  ctx: Context,
  fallbackLocation: FactLocation | undefined,
): void {
  const calleeName = callExpressionCalleeName(call);
  const location = locFromNode(call, ctx.file) ?? fallbackLocation;

  if (!calleeName || !ctx.recognizedHelpers.has(calleeName)) {
    emitDynamic(ctx, "unknown_helper", sourceSnippet(call), location);
    return;
  }

  if (calleeName === "cva") {
    emitDynamic(ctx, "cva-arg", sourceSnippet(call), location);
    return;
  }

  const origin: ClassNameOrigin = HELPER_ORIGINS[calleeName] ?? "attribute";
  const acc: string[] = [];
  for (const arg of call.arguments) {
    if (t.isSpreadElement(arg) || !t.isExpression(arg)) {
      emitOpaque(ctx, arg, location);
      continue;
    }
    const sub = handleExpression(arg, ctx, location, "argument", origin);
    acc.push(...sub.literals);
  }
  if (acc.length > 0) {
    emitLiteral(ctx, origin, acc, location);
  }
}

function callExpressionCalleeName(call: t.CallExpression): string | undefined {
  const callee = call.callee;
  if (t.isIdentifier(callee)) return callee.name;
  if (t.isMemberExpression(callee) && !callee.computed && t.isIdentifier(callee.property)) {
    return callee.property.name;
  }
  return undefined;
}

// ---------------------------------------------------------------------------
// Emitters
// ---------------------------------------------------------------------------

function emitLiteral(
  ctx: Context,
  origin: ClassNameOrigin,
  classes: string[],
  location: FactLocation | undefined,
): void {
  const idx = ctx.literalSeq++;
  const originPath = `${origin}#${idx}`;
  const resolvedLocation = location ?? syntheticLocation(ctx.file);
  ctx.out.push(
    makeClassNameLiteralFact({
      file: ctx.file,
      nodeId: ctx.nodeId,
      attr: ctx.attr,
      classes,
      origin,
      originPath,
      location: resolvedLocation,
    }),
  );
  for (let classIndex = 0; classIndex < classes.length; classIndex++) {
    ctx.out.push(
      parseClass({
        raw: classes[classIndex],
        file: ctx.file,
        nodeId: ctx.nodeId,
        originPath: `${originPath}/class#${classIndex}`,
        location: resolvedLocation,
      }),
    );
  }
}

function emitDynamic(
  ctx: Context,
  reason: ClassNameDynamicReason,
  snippet: string,
  location: FactLocation | undefined,
): void {
  const idx = ctx.dynamicSeq++;
  ctx.out.push(
    makeClassNameDynamicFact({
      file: ctx.file,
      nodeId: ctx.nodeId,
      attr: ctx.attr,
      reason,
      snippet: clip(snippet, SNIPPET_MAX),
      originPath: `${reason}#${idx}`,
      location: location ?? syntheticLocation(ctx.file),
    }),
  );
}

function emitOpaque(
  ctx: Context,
  node: t.Node,
  fallbackLocation: FactLocation | undefined,
): void {
  emitDynamic(
    ctx,
    "computed",
    sourceSnippet(node),
    locFromNode(node, ctx.file) ?? fallbackLocation,
  );
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function splitClassString(value: string): string[] {
  return value.split(/\s+/).filter(Boolean);
}

function objectPropertyKey(prop: t.ObjectProperty): string | undefined {
  const key = prop.key;
  if (!prop.computed) {
    if (t.isIdentifier(key)) return key.name;
    if (t.isStringLiteral(key)) return key.value;
  } else if (t.isStringLiteral(key)) {
    return key.value;
  }
  return undefined;
}

function syntheticLocation(file: string): FactLocation {
  return { file, line: 1, column: 0 };
}

function sourceSnippet(node: t.Node): string {
  return clip(directSnippet(node) ?? "<expr>", SNIPPET_MAX);
}

/**
 * Reconstruct a stable string representation of an AST node for diagnostic
 * snippets. Recursive but bounded — once the accumulator passes
 * `SNIPPET_MAX`, callers clip and stop traversing further. We don't pull in
 * `@babel/generator` to keep the package free of that 200KB dep.
 */
function directSnippet(node: t.Node): string | undefined {
  if (t.isIdentifier(node)) return node.name;
  if (t.isStringLiteral(node)) return JSON.stringify(node.value);
  if (t.isNumericLiteral(node)) return String(node.value);
  if (t.isBooleanLiteral(node)) return String(node.value);
  if (t.isNullLiteral(node)) return "null";
  if (t.isMemberExpression(node)) {
    const obj = directSnippet(node.object) ?? "?";
    if (obj.length >= SNIPPET_MAX) return obj;
    if (!node.computed && t.isIdentifier(node.property)) {
      return `${obj}.${node.property.name}`;
    }
    return `${obj}[${directSnippet(node.property as t.Node) ?? "?"}]`;
  }
  if (t.isCallExpression(node)) {
    const callee = directSnippet(node.callee) ?? "?";
    const out: string[] = [];
    let used = callee.length + 2;
    for (const arg of node.arguments) {
      if (used >= SNIPPET_MAX) {
        out.push("…");
        break;
      }
      const piece = t.isSpreadElement(arg)
        ? `...${directSnippet(arg.argument) ?? "?"}`
        : directSnippet(arg as t.Node) ?? "?";
      out.push(piece);
      used += piece.length + 2;
    }
    return `${callee}(${out.join(", ")})`;
  }
  if (t.isObjectExpression(node)) {
    const parts: string[] = [];
    let used = 4;
    for (const prop of node.properties) {
      if (used >= SNIPPET_MAX) {
        parts.push("…");
        break;
      }
      if (!t.isObjectProperty(prop)) {
        parts.push("…");
        used += 1;
        continue;
      }
      const key = objectPropertyKey(prop);
      const valueSnippet = directSnippet(prop.value as t.Node) ?? "…";
      const piece = key ? `${JSON.stringify(key)}: ${valueSnippet}` : "…";
      parts.push(piece);
      used += piece.length + 2;
    }
    return `{ ${parts.join(", ")} }`;
  }
  if (t.isArrayExpression(node)) {
    const parts = node.elements.map((el) => (el ? directSnippet(el as t.Node) ?? "…" : ""));
    return `[${parts.join(", ")}]`;
  }
  if (t.isTemplateLiteral(node)) {
    let out = "`";
    for (let i = 0; i < node.quasis.length; i++) {
      out += node.quasis[i].value.cooked ?? node.quasis[i].value.raw;
      if (out.length >= SNIPPET_MAX) return out + "…`";
      if (i < node.expressions.length) {
        out += "${" + (directSnippet(node.expressions[i] as t.Node) ?? "…") + "}";
      }
    }
    return out + "`";
  }
  if (t.isConditionalExpression(node)) {
    return `${directSnippet(node.test) ?? "?"} ? ${directSnippet(node.consequent) ?? "?"} : ${directSnippet(node.alternate) ?? "?"}`;
  }
  if (t.isLogicalExpression(node)) {
    return `${directSnippet(node.left) ?? "?"} ${node.operator} ${directSnippet(node.right) ?? "?"}`;
  }
  if (t.isParenthesizedExpression(node)) {
    return `(${directSnippet(node.expression) ?? "?"})`;
  }
  if (t.isJSXSpreadAttribute(node)) {
    return `...${directSnippet(node.argument) ?? "?"}`;
  }
  if (t.isJSXAttribute(node)) {
    return jsxAttributeName(node) ?? "attr";
  }
  return undefined;
}

function clip(text: string, max: number): string {
  if (text.length <= max) return text;
  return text.slice(0, max - 1) + "…";
}
