import { describe, expect, it } from "vitest";
import { asComponentId } from "@fragments-sdk/core";

import { extractJsxFacts } from "../jsx.js";

const FILE = "apps/checkout/page.tsx";

const SOURCE = `import { Button } from "@fragments-sdk/ui";

export default function CheckoutPage() {
  return (
    <Button variant="secondary" style={{ padding: 13, color: '#2563eb' }}>
      Continue
    </Button>
  );
}
`;

describe("extractJsxFacts — JSX usage extractor", () => {
  it("emits a usage_node anchored to the JSX opening element", () => {
    const facts = extractJsxFacts(SOURCE, FILE);
    const nodes = facts.filter((f) => f.kind === "usage_node");

    expect(nodes).toHaveLength(1);
    expect(nodes[0].kind).toBe("usage_node");
    expect(nodes[0]).toMatchObject({
      kind: "usage_node",
      file: FILE,
      element: "Button",
    });
    // Location points at the JSX element opening, not just the file.
    if (nodes[0].kind !== "usage_node") throw new Error("unreachable");
    expect(nodes[0].location.file).toBe(FILE);
    expect(nodes[0].location.line).toBeGreaterThan(0);
    expect(nodes[0].location.column).toBeGreaterThanOrEqual(0);
  });

  it("normalizes element semantics: static role and interactivity onto usage_node", () => {
    const facts = extractJsxFacts(
      `export function Banner() {
        return (
          <div className="banner" role="status" aria-live="polite">
            <button type="button" className="rescan" onClick={() => {}}>Rescan</button>
          </div>
        );
      }`,
      "src/banner.tsx"
    );
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const div = nodes.find((n) => n.kind === "usage_node" && n.element === "div");
    const button = nodes.find((n) => n.kind === "usage_node" && n.element === "button");
    if (div?.kind !== "usage_node" || button?.kind !== "usage_node") {
      throw new Error("missing nodes");
    }

    // <div role="status"> → role normalized, not interactive.
    expect(div.role).toBe("status");
    expect(div.interactive).toBeUndefined();

    // <button onClick> → interactive, no role declared.
    expect(button.interactive).toBe(true);
    expect(button.role).toBeUndefined();
  });

  it("leaves role/interactive absent on plain elements (no identity perturbation)", () => {
    const facts = extractJsxFacts(`export const X = () => <span>hi</span>;`, "src/x.tsx");
    const node = facts.find((f) => f.kind === "usage_node");
    if (node?.kind !== "usage_node") throw new Error("missing node");
    expect(node.role).toBeUndefined();
    expect(node.interactive).toBeUndefined();
  });

  it("emits usage_component linking the node to the imported component id", () => {
    const facts = extractJsxFacts(SOURCE, FILE);
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const components = facts.filter((f) => f.kind === "usage_component");

    expect(components).toHaveLength(1);
    if (components[0].kind !== "usage_component") throw new Error("unreachable");
    if (nodes[0].kind !== "usage_node") throw new Error("unreachable");
    expect(components[0].nodeId).toBe(nodes[0].id);
    expect(components[0].componentId).toBe("@fragments-sdk/ui#Button");
  });

  it("resolves aliased named imports to the exported component identity", () => {
    const facts = extractJsxFacts(
      `import { Button as FuiButton } from "@fragments-sdk/ui";

export function Page() {
  return <FuiButton variant="primary" />;
}
`,
      FILE,
    );
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const components = facts.filter((f) => f.kind === "usage_component");
    const imports = facts.filter((f) => f.kind === "usage_import");

    expect(nodes).toHaveLength(1);
    expect(components).toHaveLength(1);
    if (nodes[0].kind !== "usage_node") throw new Error("missing node");
    if (components[0].kind !== "usage_component") {
      throw new Error("missing component");
    }
    expect(nodes[0].element).toBe("FuiButton");
    expect(components[0].componentId).toBe("@fragments-sdk/ui#Button");
    expect(imports).toEqual([
      expect.objectContaining({
        local: "FuiButton",
        imported: "Button",
        source: "@fragments-sdk/ui",
      }),
    ]);
  });

  it("lets callers resolve direct compound aliases to component identity", () => {
    const facts = extractJsxFacts(
      `import { AccordionItem, CardBody } from "@fragments-sdk/ui";

export function Page() {
  return (
    <>
      <CardBody tone="muted" />
      <AccordionItem value="billing" />
    </>
  );
}
`,
      FILE,
      {
        resolveComponentId(resolution) {
          if (resolution.imported === "CardBody") {
            return asComponentId("@fragments-sdk/ui#Card.Body");
          }
          if (resolution.imported === "AccordionItem") {
            return asComponentId("@fragments-sdk/ui#Accordion.Item");
          }
          return undefined;
        },
      },
    );
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const components = facts.filter((f) => f.kind === "usage_component");

    expect(nodes.map((node) => node.element)).toEqual([
      "CardBody",
      "AccordionItem",
    ]);
    expect(components.map((component) => component.componentId)).toEqual([
      "@fragments-sdk/ui#Card.Body",
      "@fragments-sdk/ui#Accordion.Item",
    ]);
  });

  it("resolves named-import compound member elements", () => {
    const facts = extractJsxFacts(
      `import { Card } from "@fragments-sdk/ui";

export function Page() {
  return <Card.Header tone="muted" />;
}
`,
      FILE,
    );
    const components = facts.filter((f) => f.kind === "usage_component");
    const props = facts.filter(
      (f) => f.kind === "usage_prop_resolved" && f.prop === "tone",
    );

    expect(components).toHaveLength(1);
    if (components[0].kind !== "usage_component") {
      throw new Error("missing component");
    }
    expect(components[0].componentId).toBe("@fragments-sdk/ui#Card.Header");
    expect(props).toHaveLength(1);
  });

  it("resolves namespace-import member elements", () => {
    const facts = extractJsxFacts(
      `import * as UI from "@fragments-sdk/ui";

export function Page() {
  return (
    <UI.Card>
      <UI.Card.Header tone="muted" />
      <UI.Avatar.Group size="sm" />
    </UI.Card>
  );
}
`,
      FILE,
    );
    const components = facts
      .filter((f) => f.kind === "usage_component")
      .map((fact) => fact.componentId);

    expect(components).toEqual([
      "@fragments-sdk/ui#Card",
      "@fragments-sdk/ui#Card.Header",
      "@fragments-sdk/ui#Avatar.Group",
    ]);
  });

  it("resolves package default imports to the default export identity", () => {
    const facts = extractJsxFacts(
      `import Button from "@fragments-sdk/button";

export function Page() {
  return <Button variant="primary" />;
}
`,
      FILE,
    );
    const component = facts.find((f) => f.kind === "usage_component");

    expect(component).toBeDefined();
    if (component?.kind !== "usage_component") {
      throw new Error("missing component");
    }
    expect(component.componentId).toBe("@fragments-sdk/button#default");
  });

  it("resolves package default-import compound members", () => {
    const facts = extractJsxFacts(
      `import Card from "@fragments-sdk/card";

export function Page() {
  return <Card.Header tone="muted" />;
}
`,
      FILE,
    );
    const component = facts.find((f) => f.kind === "usage_component");

    expect(component).toBeDefined();
    if (component?.kind !== "usage_component") {
      throw new Error("missing component");
    }
    expect(component.componentId).toBe("@fragments-sdk/card#default.Header");
  });

  it("can resolve relative compound members through the scanner resolver", () => {
    const facts = extractJsxFacts(
      `import { Card } from "./Card";

export function Page() {
  return <Card.Header />;
}
`,
      "src/page.tsx",
      {
        resolveComponentId(resolution) {
          if (
            resolution.source === "./Card" &&
            resolution.imported === "Card.Header"
          ) {
            return asComponentId("@local/ui#Card.Header");
          }
          return undefined;
        },
      },
    );

    const component = facts.find((f) => f.kind === "usage_component");
    expect(component).toBeDefined();
    if (component?.kind !== "usage_component") {
      throw new Error("missing component");
    }
    expect(component.componentId).toBe("@local/ui#Card.Header");
  });

  it("emits usage_import facts for import declarations", () => {
    const facts = extractJsxFacts(
      `import DefaultButton, { Button } from "@fragments-sdk/ui";
import * as Legacy from "@legacy/ui";

export function Page() {
  return <><Button /><DefaultButton /><Legacy.Card /></>;
}
`,
      FILE,
    );
    const imports = facts.filter((f) => f.kind === "usage_import");

    expect(imports).toHaveLength(3);
    expect(imports).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          kind: "usage_import",
          local: "DefaultButton",
          imported: "default",
          source: "@fragments-sdk/ui",
        }),
        expect.objectContaining({
          kind: "usage_import",
          local: "Button",
          imported: "Button",
          source: "@fragments-sdk/ui",
        }),
        expect.objectContaining({
          kind: "usage_import",
          local: "Legacy",
          imported: "Legacy",
          source: "@legacy/ui",
          namespace: true,
        }),
      ]),
    );
    const first = imports[0];
    if (first.kind !== "usage_import") throw new Error("missing import");
    expect(first.location.line).toBe(1);
    expect(first.location.column).toBe(0);
  });

  it("emits usage_prop_resolved for static string props with a value-precise location", () => {
    const facts = extractJsxFacts(SOURCE, FILE);
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const props = facts.filter((f) => f.kind === "usage_prop_resolved");

    expect(props).toHaveLength(1);
    if (props[0].kind !== "usage_prop_resolved") throw new Error("unreachable");
    if (nodes[0].kind !== "usage_node") throw new Error("unreachable");

    expect(props[0].nodeId).toBe(nodes[0].id);
    expect(props[0].prop).toBe("variant");
    expect(props[0].resolution).toBe("static");
    expect(props[0].value).toBe("secondary");
    expect(props[0].location?.file).toBe(FILE);
    expect(props[0].location?.line).toBe(5);
  });

  it("emits usage_inline_style facts for each property in style={{...}}", () => {
    const facts = extractJsxFacts(SOURCE, FILE);
    const inlineStyles = facts.filter((f) => f.kind === "usage_inline_style");

    expect(inlineStyles).toHaveLength(2);
    const padding = inlineStyles.find(
      (f) => f.kind === "usage_inline_style" && f.property === "padding"
    );
    const color = inlineStyles.find(
      (f) => f.kind === "usage_inline_style" && f.property === "color"
    );

    if (padding?.kind !== "usage_inline_style") throw new Error("padding missing");
    if (color?.kind !== "usage_inline_style") throw new Error("color missing");

    expect(padding.valueKind).toBe("number");
    expect(padding.value).toBe("13");
    expect(color.valueKind).toBe("static");
    expect(color.value).toBe("#2563eb");
  });

  it("surfaces a raw color hidden in an inline-style ternary branch (BUG 14)", () => {
    const facts = extractJsxFacts(
      `export default function P({ on }: any) {
        return <div style={{ color: on ? '#ff0000' : '#00ff00' }}>x</div>;
      }`,
      "src/hero.tsx"
    );
    const color = facts.find(
      (f) => f.kind === "usage_inline_style" && f.property === "color"
    );
    if (color?.kind !== "usage_inline_style") throw new Error("color missing");
    // The conditional no longer fully bypasses the contract — a raw color
    // branch is surfaced for the color rule to flag.
    expect(color.value).toBe("#ff0000");
  });

  it("emits usage_text_child facts for raw text children", () => {
    const facts = extractJsxFacts(SOURCE, FILE);
    const text = facts.filter((f) => f.kind === "usage_text_child");

    expect(text).toHaveLength(1);
    if (text[0].kind !== "usage_text_child") throw new Error("unreachable");
    expect(text[0].text).toBe("Continue");
    expect(text[0].index).toBe(0);
  });

  it("emits a jsx children prop for structural child elements", () => {
    const facts = extractJsxFacts(
      `import { Card } from "@fragments-sdk/ui";

export function Page() {
  return (
    <Card.Body>
      <div>Checkout</div>
    </Card.Body>
  );
}
`,
      FILE,
    );
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const props = facts.filter(
      (f) => f.kind === "usage_prop_resolved" && f.prop === "children",
    );

    expect(props).toHaveLength(1);
    if (nodes[0].kind !== "usage_node") throw new Error("missing node");
    if (props[0].kind !== "usage_prop_resolved") {
      throw new Error("missing prop");
    }
    expect(props[0].nodeId).toBe(nodes[0].id);
    expect(props[0].resolution).toBe("jsx");
    expect(props[0].value).toBeUndefined();
    expect(props[0].location?.line).toBe(6);
  });

  it("emits a jsx children prop for expression children", () => {
    const facts = extractJsxFacts(
      `import { Button } from "@fragments-sdk/ui";

export function Page({ loading }: { loading: boolean }) {
  return <Button>{loading ? "Signing in..." : "Sign in"}</Button>;
}
`,
      FILE,
    );
    const props = facts.filter(
      (f) => f.kind === "usage_prop_resolved" && f.prop === "children",
    );

    expect(props).toHaveLength(1);
    if (props[0].kind !== "usage_prop_resolved") {
      throw new Error("missing prop");
    }
    expect(props[0].resolution).toBe("jsx");
    expect(props[0].value).toBeUndefined();
    expect(props[0].location?.line).toBe(4);
  });

  it("locations point at the offending value, not just the file", () => {
    const facts = extractJsxFacts(SOURCE, FILE);
    const node = facts.find((f) => f.kind === "usage_node");
    if (node?.kind !== "usage_node") throw new Error("missing node");

    // Source line containing `<Button` is line 5 (1-based: blank, import, blank,
    // function body opener, return, then JSX). Column points at the `<`.
    expect(node.location.line).toBe(5);
    expect(node.location.endLine).toBeGreaterThanOrEqual(5);
  });

  it("does not emit usage_component when the import source is relative", () => {
    const local = `import { Button } from "./Button";\n\nexport default function Page() {\n  return <Button />;\n}\n`;
    const facts = extractJsxFacts(local, "src/page.tsx");

    expect(facts.find((f) => f.kind === "usage_node")).toBeDefined();
    expect(facts.find((f) => f.kind === "usage_component")).toBeUndefined();
  });

  it("can resolve relative imports when the scanner provides a component resolver", () => {
    const local = `import { Button } from "./Button";\n\nexport default function Page() {\n  return <Button />;\n}\n`;
    const facts = extractJsxFacts(local, "src/page.tsx", {
      resolveComponentId(resolution) {
        if (
          resolution.source === "./Button" &&
          resolution.imported === "Button"
        ) {
          return asComponentId("@local/ui#Button");
        }
        return undefined;
      },
    });

    const component = facts.find((f) => f.kind === "usage_component");
    expect(component).toBeDefined();
    if (component?.kind !== "usage_component") throw new Error("missing component");
    expect(component.componentId).toBe("@local/ui#Button");
  });

  it("emits usage_prop_resolved with resolution: 'spread' for {...props}", () => {
    const src = `import { Button } from "@fragments-sdk/ui";\n\nexport default function P({ extras }: any) {\n  return <Button {...extras} />;\n}\n`;
    const facts = extractJsxFacts(src, "src/p.tsx");

    const props = facts.filter((f) => f.kind === "usage_prop_resolved");
    expect(props).toHaveLength(1);
    if (props[0].kind !== "usage_prop_resolved") throw new Error("unreachable");
    expect(props[0].resolution).toBe("spread");
  });

  it("emits usage_prop_resolved with resolution: 'dynamic' for {expr} props", () => {
    const src = `import { Button } from "@fragments-sdk/ui";\n\nexport default function P({ kind }: any) {\n  return <Button variant={kind} />;\n}\n`;
    const facts = extractJsxFacts(src, "src/p.tsx");

    const props = facts.filter(
      (f) => f.kind === "usage_prop_resolved" && f.prop === "variant"
    );
    expect(props).toHaveLength(1);
    if (props[0].kind !== "usage_prop_resolved") throw new Error("unreachable");
    expect(props[0].resolution).toBe("dynamic");
    expect(props[0].value).toBeUndefined();
  });

  it("keeps React key attributes as raw JSX evidence", () => {
    const src = `import { Button } from "@fragments-sdk/ui";\n\nexport default function P({ id }: any) {\n  return <Button key={id} variant="primary" />;\n}\n`;
    const facts = extractJsxFacts(src, "src/p.tsx");

    const key = facts.find(
      (f) => f.kind === "usage_prop_resolved" && f.prop === "key",
    );
    expect(key).toBeDefined();
    if (key?.kind !== "usage_prop_resolved") throw new Error("missing key");
    expect(key.resolution).toBe("dynamic");
    expect(key.value).toBeUndefined();
  });

  it("keeps non-literal expression props dynamic without guessed values", () => {
    const src = `import { Button } from "@fragments-sdk/ui";

export default function P({ kind, config, label }: any) {
  return (
    <Button
      variant={kind}
      size={config.button.size}
      label={\`Pay \${label}\`}
    />
  );
}
`;
    const facts = extractJsxFacts(src, "src/p.tsx");

    const props = facts
      .filter((f) => f.kind === "usage_prop_resolved")
      .sort((a, b) => a.prop.localeCompare(b.prop));

    expect(props).toHaveLength(3);
    for (const prop of props) {
      if (prop.kind !== "usage_prop_resolved") {
        throw new Error("missing prop");
      }
      expect(prop.resolution).toBe("dynamic");
      expect(prop.value).toBeUndefined();
      expect(prop.location?.line).toBeGreaterThanOrEqual(6);
    }
    expect(props.map((prop) => prop.prop)).toEqual(["label", "size", "variant"]);
  });

  it("returns an empty array when the source has no JSX", () => {
    const facts = extractJsxFacts(`export const x = 1;\n`, "src/no-jsx.ts");
    expect(facts).toEqual([]);
  });

  it("keeps conditional JSX children distinct from normal siblings", () => {
    const src = `
export function Panel({ status }: { status?: string }) {
  return (
    <div>
      <div>Header</div>
      {status && <div>{status}</div>}
    </div>
  );
}
`;
    const facts = extractJsxFacts(src, "src/panel.tsx");
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const ids = new Set(nodes.map((node) => node.id));

    expect(nodes).toHaveLength(3);
    expect(ids.size).toBe(nodes.length);
  });

  it("keeps render-prop JSX roots in sibling JSX attributes distinct", () => {
    const src = `
import { Trans } from "react-i18next";

export function Help() {
  return (
    <Trans
      flowchartLink={(el) => <a href="/flowchart">{el}</a>}
      sequenceLink={(el) => <a href="/sequence">{el}</a>}
      classLink={(el) => <a href="/class">{el}</a>}
    />
  );
}
`;
    const facts = extractJsxFacts(src, "src/help.tsx");
    const anchorNodes = facts
      .filter((fact) => fact.kind === "usage_node")
      .filter((fact) => fact.element === "a");
    const ids = new Set(anchorNodes.map((node) => node.id));
    const paths = new Set(anchorNodes.map((node) => node.nodePath));

    expect(anchorNodes).toHaveLength(3);
    expect(ids.size).toBe(anchorNodes.length);
    expect(paths.size).toBe(anchorNodes.length);
    expect([...paths]).toEqual(
      expect.arrayContaining([
        expect.stringContaining("attr:flowchartLink"),
        expect.stringContaining("attr:sequenceLink"),
        expect.stringContaining("attr:classLink"),
      ]),
    );
  });

  it("keeps separate JSX return trees distinct within one file", () => {
    const src = `
export function Panel({ empty }: { empty?: boolean }) {
  if (empty) {
    return (
      <div>
        <span>Empty</span>
      </div>
    );
  }

  return (
    <div>
      <span>Ready</span>
    </div>
  );
}
`;
    const facts = extractJsxFacts(src, "src/panel.tsx");
    const nodes = facts.filter((f) => f.kind === "usage_node");
    const ids = new Set(nodes.map((node) => node.id));

    expect(nodes).toHaveLength(4);
    expect(ids.size).toBe(nodes.length);
  });
});
