/*
 * node --test src/preview/jsx-transform.test.js
 *
 * The transform runs on model-authored text, so these cases are the shapes a
 * model actually emits, plus the ones where a naive scanner silently produces
 * code that runs and does the wrong thing (generics read as comparisons, `<` in
 * a string read as a tag). Silent-wrong is the failure mode worth testing;
 * anything that throws is already visible to the user.
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { transform, TransformError } from "./jsx-transform.js";

// Whitespace is normalised for comparison: the transform drops the space a
// stripped annotation sat in (`const x: T = 1` -> `const x= 1`), which is
// invisible to everyone because the Code tab shows the original source, not
// this output.
const js = (src) => transform(src).code.replace(/\s+/g, " ").trim();

// ── elements ────────────────────────────────────────────────────────────────

test("element with no attributes or children", () => {
  assert.equal(js("const a = <div />;"), 'const a = h("div",null);');
});

test("lowercase tags become strings, capitalized stay identifiers", () => {
  assert.equal(js("<div />"), 'h("div",null)');
  assert.equal(js("<App />"), "h(App,null)");
  assert.equal(js("<Foo.Bar />"), "h(Foo.Bar,null)");
});

test("string, expression, boolean and hyphenated attributes", () => {
  assert.equal(js('<a href="/x" />'), 'h("a",{href:"/x"})');
  assert.equal(js("<a n={1 + 2} />"), 'h("a",{n:(1 + 2)})');
  assert.equal(js("<input disabled />"), 'h("input",{disabled:true})');
  assert.equal(js('<a data-id="7" />'), 'h("a",{"data-id":"7"})');
});

test("attribute spread", () => {
  assert.equal(js("<div {...props} id=\"x\" />"), 'h("div",{...(props),id:"x"})');
});

test("children: text, expressions and nesting", () => {
  assert.equal(js("<p>hi</p>"), 'h("p",null,"hi")');
  assert.equal(js("<p>{name}</p>"), 'h("p",null,(name))');
  assert.equal(js("<p>a {b} c</p>"), 'h("p",null,"a ",(b)," c")');
  assert.equal(js("<ul><li>x</li></ul>"), 'h("ul",null,h("li",null,"x"))');
});

test("fragments", () => {
  assert.equal(js("<><a /><b /></>"), 'h(Fragment,null,h("a",null),h("b",null))');
});

test("JSX nested inside an expression child", () => {
  assert.equal(
    js("<ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>"),
    'h("ul",null,(items.map((i) => h("li",{key:(i)},(i)))))',
  );
});

test("JSX comments render nothing", () => {
  assert.equal(js("<div>{/* note */}<a /></div>"), 'h("div",null,h("a",null))');
});

test("whitespace-only lines between elements collapse away", () => {
  assert.equal(
    js("<div>\n  <a />\n  <b />\n</div>"),
    'h("div",null,h("a",null),h("b",null))',
  );
});

test("text spanning lines keeps single spaces", () => {
  assert.equal(js("<p>\n  one\n  two\n</p>"), 'h("p",null,"one two")');
});

// ── things that must NOT be treated as JSX ──────────────────────────────────

test("comparisons and arrow bodies are left alone", () => {
  assert.equal(js("const t = a < b;"), "const t = a < b;");
  assert.equal(js("if (x < 3 && y > 1) {}"), "if (x < 3 && y > 1) {}");
  assert.equal(js("const f = (a, b) => a < b;"), "const f = (a, b) => a < b;");
});

test("angle brackets inside strings, templates and regexes survive", () => {
  assert.equal(js('const s = "<div>not jsx</div>";'), 'const s = "<div>not jsx</div>";');
  assert.equal(js("const s = `a <b> c`;"), "const s = `a <b> c`;");
  assert.equal(js("const r = /<[a-z]+>/g;"), "const r = /<[a-z]+>/g;");
});

test("template interpolation can still contain JSX", () => {
  assert.equal(js("const s = `${<a />}`;"), "const s = `${h(\"a\",null)}`;");
});

test("division is not mistaken for a regex", () => {
  assert.equal(js("const r = (a + b) / 2 / c;"), "const r = (a + b) / 2 / c;");
  // A `/` right after a call's closing paren: `)` ends a value, so this is
  // division. Treating it as a regex swallowed the rest of the line.
  assert.equal(js("const y = Math.sin((i + s) / 6) * 70;"), "const y = Math.sin((i + s) / 6) * 70;");
  assert.equal(js("const m = xs[0] / total;"), "const m = xs[0] / total;");
});

test("indexing and calls before < are comparisons, not JSX", () => {
  assert.equal(js("if (xs[0] < 3) {}"), "if (xs[0] < 3) {}");
  assert.equal(js("while (f(i) < n) { i++; }"), "while (f(i) < n) { i++; }");
});

// ── TypeScript ──────────────────────────────────────────────────────────────

test("parameter and variable annotations are stripped", () => {
  assert.equal(js("function f(a: string, b: number) {}"), "function f(a, b) {}");
  assert.equal(js("const x: number = 5;"), "const x= 5;");
  assert.equal(js("const f = (n: number): string => String(n);"), "const f = (n)=> String(n);");
});

test("interface and type declarations are dropped whole", () => {
  assert.equal(js("interface Props { a: string; b?: number }\nconst x = 1;"), "const x = 1;");
  assert.equal(js("type Id = string | number;\nconst x = 1;"), "const x = 1;");
});

test("generic call arguments are stripped, not read as comparisons", () => {
  // The silent-wrong case: `useState<number>(0)` is valid JS meaning
  // `(useState < number) > (0)`, so getting this wrong yields a boolean.
  assert.equal(js("const [n, setN] = useState<number>(0);"), "const [n, setN] = useState(0);");
  assert.equal(js("useRef<HTMLCanvasElement | null>(null);"), "useRef(null);");
});

test("as-casts and non-null assertions are stripped", () => {
  assert.equal(js("const el = x as HTMLElement;"), "const el = x ;");
  assert.equal(js("const v = raw as const;"), "const v = raw ;");
  assert.equal(js("ref.current!.focus();"), "ref.current.focus();");
});

test("optional parameters keep their default values", () => {
  assert.equal(js("function f(a: number = 3) { return a; }"), "function f(a= 3) { return a; }");
});

test("object literals and ternaries are not mistaken for annotations", () => {
  assert.equal(js("const o = { a: 1, b: 'two' };"), "const o = { a: 1, b: 'two' };");
  assert.equal(js("const v = c ? 1 : 2;"), "const v = c ? 1 : 2;");
  assert.equal(js("el.style = { color: 'red' };"), "el.style = { color: 'red' };");
  // Object literals passed as arguments sit inside a parameter list, where
  // parameter annotations also live.
  assert.equal(js("f({ a: 1, b: 2 });"), "f({ a: 1, b: 2 });");
  assert.equal(js("ctx.fillRect(x, y, { w: 1 });"), "ctx.fillRect(x, y, { w: 1 });");
});

test("ternaries inside a call keep their else-branch", () => {
  // Both of these end in `}` or `)` before the `:`, exactly like a destructured
  // parameter annotation and a return type - only the pending `?` tells them apart.
  assert.equal(js("f(cond ? { a: 1 } : { b: 2 });"), "f(cond ? { a: 1 } : { b: 2 });");
  assert.equal(js("f(cond ? g() : h2());"), "f(cond ? g() : h2());");
  assert.equal(js("const s = ok ? 'y' : 'n';"), "const s = ok ? 'y' : 'n';");
});

test("destructured parameter annotations are stripped", () => {
  assert.equal(js("function C({ start }: Props) {}"), "function C({ start }) {}");
  assert.equal(js("const f = ({ a, b }: P) => a + b;"), "const f = ({ a, b }) => a + b;");
  assert.equal(js("function g([x, y]: Pair) {}"), "function g([x, y]) {}");
});

// ── modules ─────────────────────────────────────────────────────────────────

test("imports are dropped - the sandbox has no module loader", () => {
  assert.equal(js('import React, { useState } from "react";\nconst x = 1;'), "const x = 1;");
  assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
});

test("multiline named imports are dropped as one statement", () => {
  const source = `import {
  useState,
  useEffect,
} from "react";
const x = 1;`;
  const out = transform(source);
  assert.equal(out.code, "\n\n\n\nconst x = 1;");
  assert.deepEqual(out.imports, [
    { names: ["useState", "useEffect"], module: "react" },
  ]);
  assert.doesNotThrow(() => new Function(out.code));
});

test("dropped imports report the bindings they would have provided", () => {
  // So a missing name can say where it was supposed to come from, instead of
  // surfacing as "useInView is not defined" at its first use.
  const { imports } = transform(
    'import React, { useState, useEffect } from "react";\n' +
    'import { useInView } from "react-infinite-scroll";\n' +
    'import * as d3 from "d3";\nconst x = 1;',
  );
  assert.deepEqual(imports, [
    { names: ["React", "useState", "useEffect"], module: "react" },
    { names: ["useInView"], module: "react-infinite-scroll" },
    { names: ["d3"], module: "d3" },
  ]);
});

test("a malformed import still yields its name and module", () => {
  // Straight from a transcript: a missing closing brace. The import is dropped
  // either way, so the binding it meant to create is what matters.
  const { imports } = transform("import { useInView from 'react-infinite-scroll';\nconst x = 1;");
  assert.deepEqual(imports, [{ names: ["useInView"], module: "react-infinite-scroll" }]);
});

test("export default names the mount target", () => {
  assert.equal(transform("export default function App() {}").defaultExport, "App");
  assert.equal(transform("function A() {}\nexport default A;").defaultExport, "A");
  assert.equal(js("export default function App() {}"), "function App() {}");
});

test("named exports are unwrapped", () => {
  assert.equal(js("export const x = 1;"), "const x = 1;");
  assert.equal(js("export function Chart() {}"), "function Chart() {}");
});

// ── failure is loud ─────────────────────────────────────────────────────────

test("mismatched closing tag throws with a line number", () => {
  assert.throws(() => transform("<div>\n<span>x</div>"), (e) =>
    e instanceof TransformError && /does not match/.test(e.message) && /line 2/.test(e.message));
});

test("unterminated element throws", () => {
  assert.throws(() => transform("const a = <div>"), TransformError);
});

// ── a whole component, end to end ───────────────────────────────────────────

test("a realistic component transforms to runnable JS", () => {
  const src = `
import { useState } from "react";

interface Props { start: number }

export default function Counter({ start }: Props) {
  const [n, setN] = useState<number>(start);
  return (
    <div className="box">
      <button onClick={() => setN(n + 1)}>+1</button>
      <span>{n} clicks</span>
      {n > 3 && <em>many!</em>}
    </div>
  );
}`;
  const out = transform(src);
  assert.equal(out.defaultExport, "Counter");
  assert.match(out.code, /function Counter\(\{ start \}\)/);
  assert.match(out.code, /useState\(start\)/);
  assert.match(out.code, /h\("button",\{onClick:\(\(\) => setN\(n \+ 1\)\)\},"\+1"\)/);
  assert.doesNotMatch(out.code, /interface|import|: Props|<number>/);
  // The real proof: it parses as JS.
  assert.doesNotThrow(() => new Function(out.code));
});

test("output of every element case parses as JS", () => {
  for (const src of [
    "<div />",
    "<a href=\"/x\">link</a>",
    "<><p>a</p><p>b</p></>",
    "const v = <ul>{xs.map((x) => <li key={x}>{x}</li>)}</ul>;",
    "const v = <Foo {...p} n={1} on={() => f(`${x}`)} />;",
  ]) {
    const { code } = transform(src);
    assert.doesNotThrow(() => new Function("h", "Fragment", "xs", "p", "x", "f", code), src);
  }
});
