import { test, expect, beforeEach } from "bun:test";
import { Window } from "happy-dom";
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
import { getComponentControllerRuntime, getReactiveRuntime } from "../src/index.ts";
import { mountHtml } from "@wrnexus/test";

// Fresh DOM per test, with the runtime's globals bound.
function mount(html: string, runtime = REACTIVE_RUNTIME, controllers = ""): Window {
  const win = new Window() as unknown as Window & Record<string, unknown>;
  win.document.body.innerHTML = `<div id="app">${html}</div>`;
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  /*
   * Bind the window CustomEvent too. Without it the runtime constructs events
   * from the host global, and a listener registered through happy-dom never
   * matches them, so anything dispatched looks silently lost.
   */
  (globalThis as Record<string, unknown>).CustomEvent = (
    win as unknown as { CustomEvent: unknown }
  ).CustomEvent;
  (0, eval)(runtime);
  if (controllers) (0, eval)(controllers);
  // Hydrate deterministically (auto-init waits on DOMContentLoaded, which the
  // test window may not fire). setupScope is idempotent, so this is safe.
  const w = win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void };
  w.__wrnexusHydrateScopes?.(win.document);
  return win as unknown as Window;
}

beforeEach(() => {
  delete (globalThis as Record<string, unknown>).window;
  delete (globalThis as Record<string, unknown>).document;
  delete (globalThis as Record<string, unknown>).location;
  delete (globalThis as Record<string, unknown>).fetch;
  delete (globalThis as Record<string, unknown>).MutationObserver;
});

test("split runtime hydrates a controller only from the controller asset", () => {
  const core = getReactiveRuntime(true);
  const controllers = getComponentControllerRuntime(true);
  expect(core).not.toContain("function setupRovingFocus");
  expect(controllers).toContain("function setupRovingFocus");

  const win = mount(
    `<div data-wrn-roving="horizontal"><button data-wrn-roving-item>One</button><button data-wrn-roving-item>Two</button></div>`,
    core,
    controllers,
  );
  const buttons = win.document.querySelectorAll("button");
  (buttons[0] as unknown as HTMLButtonElement).focus();
  buttons[0]!.dispatchEvent(new win.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
  expect(win.document.activeElement).toBe(buttons[1]);
});

test("a page without controller markers does not request the controller asset", () => {
  const win = mount(
    `<main data-scope="count: 1"><span>{count}</span></main>`,
    getReactiveRuntime(true),
  );
  expect(win.document.querySelector('script[src$="controllers.js"]')).toBeNull();
});

test("hydrates {expr} mustaches from data-scope", () => {
  const win = mount(`<div data-scope="count: 0"><span>{count}, {count * 2}</span></div>`);
  expect(win.document.querySelector("span")!.textContent).toBe("0, 0");
});

test("mounts client-only templates before hydrating their scopes", () => {
  const win = mount(
    `<div data-wrn-client-root="client-page" aria-busy="true"></div>` +
      `<template data-wrn-client-template="client-page"><main data-scope="count: 2"><b>{count}</b></main></template>`,
  );
  const runtime = win as unknown as {
    __wrnexusMountClientRoots?: (root: unknown) => void;
    __wrnexusHydrateScopes?: (root: unknown) => void;
  };
  runtime.__wrnexusMountClientRoots?.(win.document);
  runtime.__wrnexusHydrateScopes?.(win.document);
  expect(win.document.querySelector("template")).toBeNull();
  expect(win.document.querySelector("main b")?.textContent).toBe("2");
});

test("orchestrates named client loads and renders the success template", async () => {
  (globalThis as Record<string, unknown>).fetch = () =>
    Promise.resolve(Response.json({ data: { name: "Ada" } }));
  const win = mount(
    `<section data-wrn-async="users" data-wrn-async-retries="0" aria-busy="true">` +
      `<div data-wrn-async-content>Loading</div>` +
      `<template data-wrn-async-loading>Loading</template>` +
      `<template data-wrn-async-success><strong>{users.name}</strong></template>` +
      `<template data-wrn-async-error><b>{error.message}</b></template></section>`,
  );
  const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
  runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(win.document.querySelector("section")?.getAttribute("data-wrn-async-state")).toBe(
    "success",
  );
  expect(win.document.querySelector("strong")?.textContent).toBe("Ada");
});

test("@event (data-on-click) mutates a signal and re-renders", () => {
  const win = mount(
    `<div data-scope="count: 0"><button data-on-click="count++">{count}</button></div>`,
  );
  const btn = win.document.querySelector("button")!;
  expect(btn.textContent).toBe("0");
  btn.click();
  btn.click();
  expect(btn.textContent).toBe("2");
});

test("component functions support formatted multiline assignments and ternaries", () => {
  const behavior = Buffer.from(
    JSON.stringify({
      functions: `
        function increment(event, nextValue) {
          nextValue =
            Number(count) +
            (event.shiftKey ? 10 : 1)
          count = nextValue
        }

        function normalize(useMinimum) {
          count = useMinimum
            ? 0
            : Number(count)
        }
      `,
      watches: [],
      lifecycle: {},
    }),
  ).toString("base64");
  const win = mount(
    `<div data-scope="count: 0" data-wrn-behavior="${behavior}">
       <button id="increment" data-on-click="increment(event)">{count}</button>
       <button id="normalize" data-on-click="normalize(true)">normalize</button>
     </div>`,
  );

  const increment = win.document.getElementById("increment") as unknown as HTMLElement;
  increment.click();
  expect(increment.textContent).toBe("1");

  const MouseEventConstructor = (win as unknown as { MouseEvent: typeof MouseEvent }).MouseEvent;
  increment.dispatchEvent(
    new MouseEventConstructor("click", { shiftKey: true }) as unknown as Event,
  );
  expect(increment.textContent).toBe("11");

  (win.document.getElementById("normalize") as unknown as HTMLElement).click();
  expect(increment.textContent).toBe("0");
});

test("component functions evaluate template literals without unsafe eval", () => {
  const behavior = Buffer.from(
    JSON.stringify({
      functions: `function increment() {
        count++
        message = \`Count is now \${count}.\`
      }`,
      watches: [],
      lifecycle: {},
    }),
  ).toString("base64");
  const win = mount(
    `<div data-scope="count: 0, message: 'ready'" data-wrn-behavior="${behavior}">` +
      `<button data-on-click="increment()">{message}</button></div>`,
  );
  const button = win.document.querySelector("button")!;
  button.click();
  expect(button.textContent).toBe("Count is now 1.");
});

test("declared component events emit through the generic $emit function", () => {
  const win = mount(
    `<div data-scope="" data-wrn-events="complete">
      <button data-on-click="$emit('complete', { value: '4829' })">Complete</button>
    </div>`,
  );
  const root = win.document.querySelector("[data-wrn-events]")!;
  let detail: Record<string, unknown> | undefined;
  root.addEventListener("complete", (event) => {
    detail = (event as unknown as CustomEvent).detail;
  });

  win.document.querySelector("button")!.click();

  expect(detail).toEqual({ value: "4829" });
});

test("nested scopes don't clobber each other (regression)", () => {
  // An empty outer scope must not touch inner scopes' values.
  const win = mount(
    `<div data-scope="">
       <div data-scope="count: 0"><button data-on-click="count++">A{count}</button></div>
       <div data-scope="count: 10"><button data-on-click="count++">B{count}</button></div>
     </div>`,
  );
  const [a, b] = Array.from(win.document.querySelectorAll("button"));
  expect(a!.textContent).toBe("A0");
  expect(b!.textContent).toBe("B10");
  a!.click();
  expect(a!.textContent).toBe("A1");
  expect(b!.textContent).toBe("B10"); // unchanged
});

test("data-text binds an element's textContent to an expression", () => {
  const win = mount(
    `<div data-scope="n: 3"><strong data-text="n * 3">?</strong><button data-on-click="n = 5">x</button></div>`,
  );
  expect(win.document.querySelector("strong")!.textContent).toBe("9");
  win.document.querySelector("button")!.click();
  expect(win.document.querySelector("strong")!.textContent).toBe("15");
});

test("string scope values bind via data-text", () => {
  const win = mount(`<div data-scope="msg: 'hi'"><strong data-text="msg">?</strong></div>`);
  expect(win.document.querySelector("strong")!.textContent).toBe("hi");
});

test("data-for renders a list of objects and reacts to array changes", () => {
  const win = mount(
    `<div data-scope="todos: [{text: 'a'}, {text: 'b'}]">
       <ul><li data-for="t in todos" data-text="t.text"></li></ul>
       <button id="add" data-on-click="todos = todos.concat([{text: 'c'}])">add</button>
       <button id="clear" data-on-click="todos = []">clear</button>
     </div>`,
  );
  const items = () => Array.from(win.document.querySelectorAll("li"), (li) => li.textContent);
  expect(items()).toEqual(["a", "b"]);
  (win.document.getElementById("add") as unknown as HTMLElement).click();
  expect(items()).toEqual(["a", "b", "c"]);
  (win.document.getElementById("clear") as unknown as HTMLElement).click();
  expect(items()).toEqual([]);
});

test("data-for exposes item + index, mustaches and member access", () => {
  const win = mount(
    `<div data-scope="rows: [{name: 'x'}, {name: 'y'}]">
       <ul><li data-for="r, i in rows">{i}:{r.name}</li></ul>
     </div>`,
  );
  expect(Array.from(win.document.querySelectorAll("li"), (li) => li.textContent)).toEqual([
    "0:x",
    "1:y",
  ]);
});

test("keyed data-for preserves DOM identity when items reorder", () => {
  const win = mount(
    `<div data-scope="rows: [{id: 1, name: 'a'}, {id: 2, name: 'b'}]">
       <ul><li data-for="row in rows key row.id">{row.name}</li></ul>
       <button data-on-click="rows = rows.slice().reverse()">reverse</button>
     </div>`,
  );

  const before = Array.from(win.document.querySelectorAll("li"));
  expect(before.map((node) => node.textContent)).toEqual(["a", "b"]);

  (win.document.querySelector("button") as unknown as HTMLElement).click();

  const after = Array.from(win.document.querySelectorAll("li"));
  expect(after.map((node) => node.textContent)).toEqual(["b", "a"]);
  expect(after[0]).toBe(before[1]);
  expect(after[1]).toBe(before[0]);
});

test("expression evaluator: member access, ternary, comparison, calls", () => {
  const win = mount(
    `<div data-scope="user: {name: 'Ada', age: 36}, items: [1, 2, 3]">
       <span id="a" data-text="user.name"></span>
       <span id="b" data-text="user.age > 30 ? 'senior' : 'junior'"></span>
       <span id="c" data-text="items.length"></span>
     </div>`,
  );
  expect(win.document.getElementById("a")!.textContent).toBe("Ada");
  expect(win.document.getElementById("b")!.textContent).toBe("senior");
  expect(win.document.getElementById("c")!.textContent).toBe("3");
});

test("expression evaluator exposes safe primitive conversion globals", () => {
  const win = mount(
    `<div data-scope="count: 12">
       <span data-text="String(count)"></span>
     </div>`,
  );

  expect(win.document.querySelector("span")!.textContent).toBe("12");
});

test("logical expressions consume their right-hand side when the result short-circuits", () => {
  const win = mount(
    `<div data-scope="leftFalse: false, leftTrue: true">
       <span id="and" data-text="leftFalse && 123"></span>
       <span id="or" data-text="leftTrue || 456"></span>
     </div>`,
  );
  expect(win.document.getElementById("and")!.textContent).toBe("false");
  expect(win.document.getElementById("or")!.textContent).toBe("true");
});

test("expression evaluator supports flatMap callbacks", () => {
  const win = mount(
    `<div data-scope="groups: [{items: [1, 2]}, {items: [3]}]">
       <span data-text="groups.flatMap((group) => group.items)"></span>
     </div>`,
  );
  expect(win.document.querySelector("span")!.textContent).toBe("1,2,3");
});

test("data-show toggles visibility without destroying interactive state", () => {
  const win = mount(
    `<div data-scope="tab: 0">
       <button data-on-click="tab = 1">go</button>
       <section id="a" data-show="tab === 0">A</section>
       <section id="b" data-show="tab === 1">B</section>
     </div>`,
  );
  const a = win.document.getElementById("a") as unknown as HTMLElement;
  const b = win.document.getElementById("b") as unknown as HTMLElement;
  expect(a.style.display).toBe("");
  expect(b.style.display).toBe("none");
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(a.style.display).toBe("none");
  expect(b.style.display).toBe("");
});

test("dynamic components remove inactive cases from the live DOM", async () => {
  const win = mount(
    `<div data-scope="active: 'Admin'">
       <button data-on-click="active = active === 'Admin' ? 'Guest' : 'Admin'">switch</button>
       <div data-wrn-dynamic-component="Admin" data-wrn-bind-0='["data-wrn-dynamic-component","{active}"]'>
         <section data-component-case="Admin">Administrator secret</section>
         <section data-component-case="Guest">Guest dashboard</section>
       </div>
     </div>`,
  );
  win.document.dispatchEvent(new win.Event("DOMContentLoaded"));
  expect(win.document.querySelector('[data-component-case="Admin"]')?.textContent).toContain(
    "Administrator",
  );
  expect(win.document.querySelector('[data-component-case="Guest"]')).toBeNull();

  (win.document.querySelector("button") as unknown as HTMLElement).click();
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(win.document.querySelector('[data-component-case="Admin"]')).toBeNull();
  expect(win.document.querySelector('[data-component-case="Guest"]')?.textContent).toContain(
    "Guest",
  );
});

test("reactive data attributes preserve explicit boolean strings", () => {
  const win = mount(
    `<div data-scope="ready: true">
      <section
        id="carousel-layout"
        data-show="true"
        data-wrn-bind-0='["data-show","{ready}"]'
      >Slides</section>
    </div>`,
  );

  expect(win.document.getElementById("carousel-layout")!.getAttribute("data-show")).toBe("true");
});

test("reactive attribute bindings update input and accessibility attributes", () => {
  const win = mount(
    `<div data-scope="show: false">
       <input id="password" type="password" data-wrn-bind-0='["type","{show ? &#39;text&#39; : &#39;password&#39;}"]'>
       <button data-on-click="show = !show" aria-label="Show password"
         data-wrn-bind-0='["aria-label","{show ? &#39;Hide password&#39; : &#39;Show password&#39;}"]'>Toggle</button>
     </div>`,
  );
  const input = win.document.getElementById("password") as unknown as HTMLInputElement;
  const button = win.document.querySelector("button") as unknown as HTMLElement;
  expect(input.type).toBe("password");
  expect(button.getAttribute("aria-label")).toBe("Show password");
  button.click();
  expect(input.type).toBe("text");
  expect(button.getAttribute("aria-label")).toBe("Hide password");
});

test("parent state updates a mounted child's reactive prop", async () => {
  const win = mount(
    `<div data-scope="n: 1">` +
      `<button data-on-click="n++">increment</button>` +
      `<div data-scope="value: 1" data-wrn-prop-bind-0='["value","{n}"]'>` +
      `<span id="child-value" data-text="value">1</span>` +
      `</div></div>`,
  );
  await Promise.resolve();
  expect(win.document.querySelector("#child-value")?.textContent).toBe("1");
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#child-value")?.textContent).toBe("2");
});

test("independent signals in one scope update correctly (dependency tracking)", () => {
  const win = mount(
    `<div data-scope="a: 0, b: 100">
       <span id="ta" data-text="a"></span>
       <span id="tb" data-text="b"></span>
       <button id="ba" data-on-click="a++">A</button>
       <button id="bb" data-on-click="b++">B</button>
     </div>`,
  );
  const ta = () => win.document.getElementById("ta")!.textContent;
  const tb = () => win.document.getElementById("tb")!.textContent;
  const click = (id: string) => (win.document.getElementById(id) as unknown as HTMLElement).click();
  expect([ta(), tb()]).toEqual(["0", "100"]);
  click("ba");
  click("ba");
  expect([ta(), tb()]).toEqual(["2", "100"]); // b untouched by a's changes
  click("bb");
  expect([ta(), tb()]).toEqual(["2", "101"]);
});
test("data-for preserves arrays of objects from encoded component scope", () => {
  const items = [
    {
      question: "One",
      answer: "First",
    },
    {
      question: "Two",
      answer: "Second",
    },
    {
      question: "Three",
      answer: "Third",
    },
    {
      question: "Four",
      answer: "Fourth",
    },
  ];

  const encoded = Buffer.from(
    JSON.stringify({
      items,
    }),
    "utf8",
  ).toString("base64");

  const dom = mountHtml(`
      <div data-wrn-scope="${encoded}">
        <article data-for="item, index in items">
          <span>{index + 1}</span>
          <strong>{item.question}</strong>
        </article>
      </div>
    `);

  expect(dom.querySelectorAll("article")).toHaveLength(4);

  expect(dom.querySelectorAll("strong").map((element) => element.textContent)).toEqual([
    "One",
    "Two",
    "Three",
    "Four",
  ]);
});

test("$emit inside component functions does not depend on a global browser event", () => {
  const behavior = Buffer.from(
    JSON.stringify({
      functions: `
        function showOverlay(sourceEvent) {
          visible = true
          $emit("open", { source: sourceEvent.type })
        }
      `,
      watches: [],
      lifecycle: {},
    }),
  ).toString("base64");

  delete (globalThis as Record<string, unknown>).event;

  const win = mount(
    `<div data-scope="visible: false" data-wrn-events="open" data-wrn-behavior="${behavior}">
       <button data-on-click="showOverlay(event)">Open overlay</button>
       <span>{visible}</span>
     </div>`,
  );

  const root = win.document.querySelector("[data-wrn-events]")!;
  let detail: Record<string, unknown> | undefined;
  root.addEventListener("open", (event) => {
    detail = (event as unknown as CustomEvent).detail;
  });

  win.document.querySelector("button")!.click();

  expect(win.document.querySelector("span")!.textContent).toBe("true");
  expect(detail).toEqual({ source: "click" });
});

test("template literals work inside browser API call arguments", () => {
  const behavior = Buffer.from(
    JSON.stringify({
      functions: `function save() {
        window.localStorage.setItem(\`prefs:\${count}\`, JSON.stringify(count))
        message = \`Saved \${count}\`
      }`,
      watches: [],
      lifecycle: {},
    }),
  ).toString("base64");
  const win = mount(
    `<div data-scope="count: 3, message: 'ready'" data-wrn-behavior="${behavior}">` +
      `<button data-on-click="save()">{message}</button></div>`,
  );
  win.document.querySelector("button")!.click();
  expect(win.localStorage.getItem("prefs:3")).toBe("3");
  expect(win.document.querySelector("button")!.textContent).toBe("Saved 3");
});

test("compiled client functions are not overwritten by fallback behavior parsing", () => {
  const behavior = Buffer.from(
    JSON.stringify({
      functions: `function save() { message = \`fallback\` }`,
      watches: [],
      lifecycle: {},
    }),
  ).toString("base64");
  const win = new Window() as unknown as Window & Record<string, unknown>;
  win.document.body.innerHTML =
    `<div id="scope" data-scope="message: 'ready'" data-wrn-behavior="${behavior}">` +
    `<button data-on-click="save()">{message}</button></div>`;
  const scope = win.document.getElementById("scope") as unknown as HTMLElement & {
    __wrnexusClientModule?: unknown;
  };
  scope.__wrnexusClientModule = {
    bindClientScope(context: { state: Record<string, unknown> }) {
      return {
        save() {
          context.state.message = "native";
        },
      };
    },
  };
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  (0, eval)(REACTIVE_RUNTIME);
  (win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
    win.document,
  );
  win.document.querySelector("button")!.click();
  expect(win.document.querySelector("button")!.textContent).toBe("native");
});

test("$route updates after client navigation", () => {
  const win = new Window({ url: "http://localhost/" }) as unknown as Window &
    Record<string, unknown>;
  win.document.body.innerHTML =
    `<span hidden data-wrn-route-params='{"id":"42"}'></span>` +
    `<div data-scope=""><strong>{$route.pathname}</strong><em>{$route.params.id}</em></div>`;
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  (0, eval)(REACTIVE_RUNTIME);
  (win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }).__wrnexusHydrateScopes?.(
    win.document,
  );
  expect(win.document.querySelector("strong")!.textContent).toBe("/");
  expect(win.document.querySelector("em")!.textContent).toBe("42");
  expect(win.document.querySelector("[data-wrn-route-params]")).toBeNull();
  win.history.pushState({}, "", "/settings?tab=chat");
  win.dispatchEvent(
    new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
      "wrnexus:navigated",
    ) as unknown as Parameters<Window["dispatchEvent"]>[0],
  );
  expect(win.document.querySelector("strong")!.textContent).toBe("/settings");
});

test("cache invalidation refetches matching client Async boundaries", async () => {
  let requests = 0;
  (globalThis as Record<string, unknown>).fetch = () => {
    requests++;
    return Promise.resolve(Response.json({ data: { value: requests } }));
  };
  const win = mount(
    `<section data-wrn-async="uploads" data-wrn-async-tags="uploads" data-wrn-async-retries="0">` +
      `<div data-wrn-async-content></div>` +
      `<template data-wrn-async-loading>Loading</template>` +
      `<template data-wrn-async-success data-wrn-async-alias="uploads"><b>{uploads.value}</b></template>` +
      `<template data-wrn-async-error data-wrn-async-alias="error">{error.message}</template>` +
      `</section>`,
  );
  const runtime = win as unknown as { __wrnexusHydrateAsyncBoundaries?: (root: unknown) => void };
  runtime.__wrnexusHydrateAsyncBoundaries?.(win.document);
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(win.document.querySelector("b")?.textContent).toBe("1");
  win.dispatchEvent(
    new (win as unknown as { CustomEvent: typeof CustomEvent }).CustomEvent(
      "wrnexus:cache:invalidate",
      { detail: { tags: ["uploads"] } },
    ) as unknown as Parameters<Window["dispatchEvent"]>[0],
  );
  await new Promise((resolve) => setTimeout(resolve, 0));
  expect(requests).toBe(2);
  expect(win.document.querySelector("b")?.textContent).toBe("2");
});

// --- parent-scope ownership across a component boundary --------------------
// Slot content and `@output` handlers on a component tag are authored in the
// PARENT (page) source, but SSR splices them inside the child component's
// [data-scope] root. Without an owner-chain rule the child scope claims them,
// so the page's own functions and state are invisible to markup the page
// itself wrote.

test("slot content is hydrated by the parent scope, not the component it lands in", () => {
  const win = mount(
    `<div data-scope="pageValue: 'page', pageFlag: true">` +
      `<div data-scope="pageValue: 'child', pageFlag: false" data-wrn-hydration="Modal:x">` +
      `<div data-wrn-events="confirm" class="view">` +
      `<wrn-slot data-wrn-slot=""><b>{pageValue}</b><i data-show="pageFlag">shown</i></wrn-slot>` +
      `</div></div></div>`,
  );
  expect(win.document.querySelector("b")?.textContent).toBe("page");
  expect(win.document.querySelector("i")?.getAttribute("data-show")).toBe("true");
});

test("slot event handlers write to the parent scope", () => {
  const win = mount(
    `<div data-scope="count: 0">` +
      `<span id="out">{count}</span>` +
      `<div data-scope="count: 99" data-wrn-hydration="Modal:x">` +
      `<div data-wrn-events="confirm">` +
      `<wrn-slot data-wrn-slot=""><button data-on-click="count++">go</button></wrn-slot>` +
      `</div></div></div>`,
  );
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#out")?.textContent).toBe("1");
});

test("component output handlers run in the parent scope", () => {
  const win = mount(
    `<div data-scope="saved: ''">` +
      `<span id="out">{saved}</span>` +
      `<div data-scope="saved: 'child'" data-wrn-hydration="Modal:x">` +
      `<div data-wrn-events="confirm" data-wrn-out-confirm="saved = 'yes'"></div>` +
      `</div></div>`,
  );
  const target = win.document.querySelector("[data-wrn-events]") as unknown as {
    __wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
  };
  const handlers = target.__wrnexusOutputHandlers?.confirm;
  expect(handlers && handlers.size).toBe(1);
  handlers!.forEach((handler) => handler({}));
  expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});

test("a camelCase output reaches a parent binding despite attribute lowercasing", () => {
  /*
   * A parent writes @sizeChange; HTML lowercases attribute names, so the
   * handler registers under "sizechange" while the component emits
   * "sizeChange". The lookup used to miss and fall through to a DOM dispatch,
   * so the binding was never invoked and nothing reported an error. Every
   * camelCase output in the library was undeliverable -- LayoutSplitter's
   * sizeChange, DataTable's pageChange and rowClick, Map's markerClick and
   * twelve more. Verified in a browser before and after the fix.
   */
  const win = mount(
    `<div data-scope="saved: ''">` +
      `<span id="out">{saved}</span>` +
      `<div data-scope="n: 0" data-wrn-hydration="LayoutSplitter:x">` +
      `<div data-wrn-events="sizechange" data-wrn-out-sizechange="saved = 'yes'">` +
      `<button data-on-click="output.sizeChange({ size: 45 })">go</button>` +
      `</div></div></div>`,
  );

  const target = win.document.querySelector("[data-wrn-events]") as unknown as {
    __wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
  };
  // The registry is keyed as the DOM gave it: lowercased, not as authored.
  expect(Object.keys(target.__wrnexusOutputHandlers ?? {})).toContain("sizechange");
  expect(target.__wrnexusOutputHandlers?.sizeChange).toBeUndefined();

  // Emitting through the real output proxy, under the camelCase name the
  // component actually writes, must still reach the parent.
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});

test("development runtime warns once for an output with no parent binding", () => {
  const win = new Window() as unknown as Window & Record<string, unknown>;
  win.document.body.innerHTML = `<div data-scope="" data-wrn-events="complete"><button data-on-click="output.complete({})">go</button></div>`;
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  (globalThis as Record<string, unknown>).CustomEvent = (
    win as unknown as { CustomEvent: unknown }
  ).CustomEvent;
  const warnings: unknown[][] = [];
  const originalWarn = console.warn;
  console.warn = (...args: unknown[]) => warnings.push(args);
  try {
    (0, eval)(getReactiveRuntime(true));
    (
      win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
    ).__wrnexusHydrateScopes?.(win.document);
    (win.document.querySelector("button") as unknown as HTMLElement).click();
    (win.document.querySelector("button") as unknown as HTMLElement).click();
  } finally {
    console.warn = originalWarn;
  }
  expect(warnings).toHaveLength(1);
  expect(String(warnings[0]?.[0])).toContain("WRN-DEV-OUTPUT-UNHANDLED");
});

test("development runtime warns when a component binding names a missing function", () => {
  const win = new Window() as unknown as Window & Record<string, unknown>;
  win.document.body.innerHTML =
    `<div data-scope="">` +
    `<div data-scope="" data-wrn-hydration="Child:x">` +
    `<div data-wrn-events="save" data-wrn-out-save="missingSave(payload)"></div>` +
    `</div></div>`;
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  (globalThis as Record<string, unknown>).CustomEvent = (
    win as unknown as { CustomEvent: unknown }
  ).CustomEvent;
  const warnings: unknown[][] = [];
  const originalWarn = console.warn;
  const originalError = console.error;
  console.warn = (...args: unknown[]) => warnings.push(args);
  console.error = () => {};
  try {
    (0, eval)(getReactiveRuntime(true));
    (
      win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
    ).__wrnexusHydrateScopes?.(win.document);
    const target = win.document.querySelector("[data-wrn-events]") as unknown as {
      __wrnexusOutputHandlers?: Record<string, Set<(payload: unknown) => unknown>>;
    };
    target.__wrnexusOutputHandlers?.save?.forEach((handler) => handler({ id: 1 }));
    target.__wrnexusOutputHandlers?.save?.forEach((handler) => handler({ id: 2 }));
  } finally {
    console.warn = originalWarn;
    console.error = originalError;
  }
  expect(warnings).toHaveLength(1);
  expect(String(warnings[0]?.[0])).toContain("WRN-DEV-BINDING-MISSING");
  expect(String(warnings[0]?.[0])).toContain("missingSave");
});

test("development runtime warns for referenced theme tokens absent from rendered CSS", () => {
  const win = new Window() as unknown as Window & Record<string, unknown>;
  win.document.head.innerHTML =
    `<style>:root { --wire-present-test: red; } .probe { ` +
    `color: var(--wire-present-test); background: var(--wire-missing-test); }</style>`;
  win.document.body.innerHTML = `<div class="probe" data-scope=""></div>`;
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  (globalThis as Record<string, unknown>).CustomEvent = (
    win as unknown as { CustomEvent: unknown }
  ).CustomEvent;
  const warnings: unknown[][] = [];
  const originalWarn = console.warn;
  console.warn = (...args: unknown[]) => warnings.push(args);
  try {
    (0, eval)(getReactiveRuntime(true));
    (
      win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
    ).__wrnexusHydrateScopes?.(win.document);
  } finally {
    console.warn = originalWarn;
  }
  expect(warnings).toHaveLength(1);
  expect(String(warnings[0]?.[0])).toContain("WRN-DEV-THEME-TOKEN-MISSING");
  expect(String(warnings[0]?.[0])).toContain("--wire-missing-test");
});

test("development runtime accepts component-local and inline wire variables", () => {
  const win = new Window() as unknown as Window & Record<string, unknown>;
  win.document.head.innerHTML =
    `<style>.component { --wire-component-local: red; color: var(--wire-component-local); ` +
    `background: var(--wire-inline-local); }</style>`;
  win.document.body.innerHTML =
    `<div class="component" style="--wire-inline-local: blue" data-scope=""></div>`;
  (globalThis as Record<string, unknown>).window = win;
  (globalThis as Record<string, unknown>).document = win.document;
  (globalThis as Record<string, unknown>).location = win.location;
  (globalThis as Record<string, unknown>).NodeFilter = (
    win as unknown as { NodeFilter: unknown }
  ).NodeFilter;
  (globalThis as Record<string, unknown>).MutationObserver = (
    win as unknown as { MutationObserver: unknown }
  ).MutationObserver;
  (globalThis as Record<string, unknown>).CustomEvent = (
    win as unknown as { CustomEvent: unknown }
  ).CustomEvent;
  const warnings: unknown[][] = [];
  const originalWarn = console.warn;
  console.warn = (...args: unknown[]) => warnings.push(args);
  try {
    (0, eval)(getReactiveRuntime(true));
    (
      win as unknown as { __wrnexusHydrateScopes?: (root: unknown) => void }
    ).__wrnexusHydrateScopes?.(win.document);
  } finally {
    console.warn = originalWarn;
  }
  expect(warnings).toHaveLength(0);
});

test("production runtime strips development diagnostics", () => {
  const production = getReactiveRuntime();
  expect(production).not.toContain("WRN-DEV-");
  expect(production).not.toContain("warnOnce");
  expect(REACTIVE_RUNTIME).toContain("__WRNEXUS_DEV_START__");
});

// --- browser globals + regex literals in client expressions ----------------
// Client functions and inline handlers are interpreted by the runtime's own
// eval-free expression engine (so a strict CSP needs no unsafe-eval). Anything
// the engine cannot resolve silently becomes undefined, so an author calling
// an ordinary browser API gets a confusing "not a function" instead of the
// behavior they wrote.

test("client expressions can call browser globals like alert", () => {
  const win = mount(
    `<div data-scope="msg: 'hi'"><button data-on-click="alert(msg)">go</button></div>`,
  ) as unknown as Window & {
    alert?: (message: string) => void;
  };
  const seen: string[] = [];
  win.alert = (message: string) => seen.push(String(message));
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(seen).toEqual(["hi"]);
});

test("client expressions resolve JS builtins", () => {
  const win = mount(
    `<div data-scope="raw: ' 42 ', out: 0, keys: ''">` +
      `<button data-on-click="out = parseInt(raw, 10); keys = Object.keys({ a: 1, b: 2 }).join('-')">go</button>` +
      `<span id="n">{out}</span><span id="k">{keys}</span>` +
      `</div>`,
  );
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#n")?.textContent).toBe("42");
  expect(win.document.querySelector("#k")?.textContent).toBe("a-b");
});

test("client expressions support regex literals", () => {
  const win = mount(
    `<div data-scope="email: 'dev@wrnexus.io', ok: ''">` +
      // String.raw so the backslashes reach the runtime: in a plain template
      // literal `\s` collapses to `s` and the test would pass against a
      // pattern the author never wrote.
      String.raw`<button data-on-click="ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? 'yes' : 'no'">go</button>` +
      `<span id="out">{ok}</span></div>`,
  );
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#out")?.textContent).toBe("yes");
});

test("a regex literal with a division sign nearby still parses as division", () => {
  const win = mount(
    `<div data-scope="a: 10, b: 4, out: 0"><button data-on-click="out = a / b">go</button><span id="out">{out}</span></div>`,
  );
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#out")?.textContent).toBe("2.5");
});

test("a string literal that spells an operator stays a value", () => {
  const win = mount(
    `<div data-scope="parts: 'a.b.c', joined: '', first: ''">` +
      `<button data-on-click="joined = ['x','y'].join('-'); first = parts.split('.')[0]">go</button>` +
      `<span id="j">{joined}</span><span id="f">{first}</span></div>`,
  );
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#j")?.textContent).toBe("x-y");
  expect(win.document.querySelector("#f")?.textContent).toBe("a");
});

// --- toast() ---------------------------------------------------------------
// The runtime raises notifications through a window event and never imports
// the Toaster component, so an app can host toasts however it likes.

test("toast() dispatches a wrnexus:toast event when a toaster is mounted", () => {
  const win = mount(
    `<div data-toaster="true"></div>` +
      `<div data-scope="who: 'Ada'"><button data-on-click="toast.success('hi ' + who, { title: 'Done' })">go</button></div>`,
  ) as unknown as Window & { addEventListener: Window["addEventListener"] };
  const seen: Record<string, unknown>[] = [];
  win.addEventListener("wrnexus:toast", ((event: { detail: Record<string, unknown> }) => {
    seen.push(event.detail);
  }) as unknown as Parameters<Window["addEventListener"]>[1]);
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(seen).toEqual([{ message: "hi Ada", title: "Done", tone: "success" }]);
});

test("toast() falls back to the console when no toaster is mounted", () => {
  const win = mount(
    `<div data-scope=""><button data-on-click="toast('orphan')">go</button></div>`,
  ) as unknown as Window & {
    addEventListener: Window["addEventListener"];
  };
  const dispatched: unknown[] = [];
  win.addEventListener("wrnexus:toast", ((event: unknown) =>
    dispatched.push(event)) as unknown as Parameters<Window["addEventListener"]>[1]);
  const original = console.info;
  const logged: string[] = [];
  console.info = (...args: unknown[]) => void logged.push(args.map(String).join(" "));
  try {
    (win.document.querySelector("button") as unknown as HTMLElement).click();
  } finally {
    console.info = original;
  }
  expect(dispatched).toEqual([]);
  expect(logged.join("\n")).toContain("orphan");
});

test("toast is reachable as a real global for compiled client modules", () => {
  const win = mount(`<div data-scope=""></div>`) as unknown as Window & {
    toast?: unknown;
    wireToast?: unknown;
  };
  expect(typeof win.wireToast).toBe("function");
  expect(typeof win.toast).toBe("function");
});

test("data-show is evaluated inside a data-for row", () => {
  const win = mount(
    `<div data-scope="rows: [{ id: 1, label: 'a', extra: '' }, { id: 2, label: 'b', extra: 'yes' }]">` +
      `<ul><li data-for="row in rows" data-key="row.id">` +
      `<span class="label">{row.label}</span>` +
      `<span class="extra" data-show="row.extra">{row.extra}</span>` +
      `</li></ul></div>`,
  );
  const extras = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
  expect(extras.length).toBe(2);
  // First row has no extra: hidden. Second row does: visible.
  expect(extras[0]!.getAttribute("data-show")).toBe("false");
  expect(extras[0]!.style.display).toBe("none");
  expect(extras[1]!.getAttribute("data-show")).toBe("true");
  expect(extras[1]!.style.display).not.toBe("none");
});

test("data-show in a data-for row can read component scope, not just the item", () => {
  const win = mount(
    `<div data-scope="showAll: false, rows: [{ id: 1 }]">` +
      `<ul><li data-for="row in rows" data-key="row.id">` +
      `<b data-show="showAll">always?</b>` +
      `</li></ul></div>`,
  );
  const node = win.document.querySelector("b") as unknown as HTMLElement;
  expect(node.getAttribute("data-show")).toBe("false");
  expect(node.style.display).toBe("none");
});

// --- nested data-for -------------------------------------------------------
// A table is rows containing cells, so a loop inside a loop has to work. The
// inner template used to be left unexpanded (and its mustaches baked against
// the outer item, where the inner name is undefined), so nested rows rendered
// blank.

test("a data-for nested inside a data-for renders its rows", () => {
  const win = mount(
    `<div data-scope="rows: [{ id: 1, cells: [{ v: 'a1' }, { v: 'b1' }] }, { id: 2, cells: [{ v: 'a2' }, { v: 'b2' }] }]">` +
      `<table><tbody>` +
      `<tr data-for="row in rows" data-key="row.id">` +
      `<td data-for="cell in row.cells" class="cell">{cell.v}</td>` +
      `</tr>` +
      `</tbody></table></div>`,
  );
  const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.textContent);
  expect(cells).toEqual(["a1", "b1", "a2", "b2"]);
});

test("a nested loop can read the enclosing item and component scope", () => {
  const win = mount(
    `<div data-scope="prefix: 'x', groups: [{ name: 'g1', items: ['p', 'q'] }]">` +
      `<ul><li data-for="group in groups">` +
      `<span data-for="item in group.items" class="leaf">{prefix}-{group.name}-{item}</span>` +
      `</li></ul></div>`,
  );
  const leaves = [...win.document.querySelectorAll(".leaf")].map((n) => n.textContent);
  expect(leaves).toEqual(["x-g1-p", "x-g1-q"]);
});

// A binding inside a loop row must react to state the row's list expression
// never touched. Effects registered during the initial render sweep used to be
// skipped entirely (forEach fixes its range up front), so they never ran, never
// subscribed, and stayed frozen at their first value for the life of the page.
test("a data-show inside a loop reacts to state the list expression never reads", () => {
  const win = mount(
    `<div data-scope="rows: [{ id: 1 }, { id: 2 }], showExtra: false">` +
      `<div data-for="row in rows" data-key="row.id">` +
      `<b class="extra" data-show="showExtra">detail</b>` +
      `</div>` +
      `<button data-on-click="showExtra = true">go</button>` +
      `</div>`,
  );
  const hidden = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
  expect(hidden.length).toBe(2);
  expect(hidden.every((n) => n.style.display === "none")).toBe(true);

  (win.document.querySelector("button") as unknown as HTMLElement).click();

  const shown = [...win.document.querySelectorAll(".extra")] as unknown as HTMLElement[];
  expect(shown.every((n) => n.style.display !== "none")).toBe(true);
});

test("a checked binding inside a loop toggles with state", () => {
  const win = mount(
    `<div data-scope="rows: [{ id: 1 }, { id: 2 }], chosen: 2">` +
      `<ul><li data-for="row in rows" data-key="row.id">` +
      `<input class="box" type="checkbox" data-wrn-bind-0='["checked","{row.id === chosen}"]' />` +
      `</li></ul>` +
      `<button data-on-click="chosen = 1">pick first</button></div>`,
  );
  const boxes = () => [...win.document.querySelectorAll(".box")] as unknown as HTMLInputElement[];
  expect(boxes().map((b) => b.checked)).toEqual([false, true]);

  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(boxes().map((b) => b.checked)).toEqual([true, false]);
});

// Rows rebuilt AFTER hydration register their effects outside the initial
// sweep, so nothing ran them: data-show kept its raw expression and the
// element defaulted to visible. Re-sorting a table therefore drew a second
// copy of every header label.
test("data-show still resolves in rows rebuilt after the first render", () => {
  const win = mount(
    `<div data-scope="rows: [{ id: 1, on: true }, { id: 2, on: false }]">` +
      `<div data-for="row in rows" data-key="row.id">` +
      `<b class="yes" data-show="row.on">yes</b>` +
      `</div>` +
      `<button data-on-click="rows = [{ id: 1, on: false }, { id: 2, on: true }]">swap</button>` +
      `</div>`,
  );
  const shown = () =>
    ([...win.document.querySelectorAll(".yes")] as unknown as HTMLElement[]).map(
      (node) => node.style.display !== "none",
    );
  expect(shown()).toEqual([true, false]);

  // New item objects mean brand new clones, hydrated outside the first sweep.
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(shown()).toEqual([false, true]);
});

// data-html is the one directive that does not escape, so it is a separate
// opt-in rather than a flag on data-text.
test("data-html renders markup while data-text escapes it", () => {
  const win = mount(
    `<div data-scope="markup: '<b>bold</b>'">` +
      `<span id="raw" data-html="markup"></span>` +
      `<span id="safe" data-text="markup"></span>` +
      `</div>`,
  );
  expect(win.document.querySelector("#raw")?.innerHTML).toBe("<b>bold</b>");
  expect(win.document.querySelector("#raw b")?.textContent).toBe("bold");
  // data-text must still show the markup as literal characters.
  expect(win.document.querySelector("#safe b")).toBeNull();
  expect(win.document.querySelector("#safe")?.textContent).toBe("<b>bold</b>");
});

test("data-html applies inside a data-for row", () => {
  const win = mount(
    `<div data-scope="rows: [{ id: 1, cell: '<b>one</b>' }, { id: 2, cell: '<i>two</i>' }]">` +
      `<ul><li data-for="row in rows" data-key="row.id">` +
      `<span class="cell" data-html="row.cell"></span>` +
      `</li></ul></div>`,
  );
  const cells = [...win.document.querySelectorAll(".cell")].map((n) => n.innerHTML);
  expect(cells).toEqual(["<b>one</b>", "<i>two</i>"]);
});

// Statement bodies given to the expression engine -- lifecycle hooks, inline
// handlers -- are ordinary authored code and contain comments. The leading
// slash used to fall through to the regex-literal branch and kill the body
// with "Unclosed regular expression".
test("comments are ignored inside interpreted statements", () => {
  const win = mount(
    `<div data-scope="total: 0">` +
      `<button data-on-click="// bump the counter&#10;total = total + 2 /* twice */">go</button>` +
      `<span id="out">{total}</span></div>`,
  );
  (win.document.querySelector("button") as unknown as HTMLElement).click();
  expect(win.document.querySelector("#out")?.textContent).toBe("2");
});

/*
 * happy-dom builds its own KeyboardEvent, which is structurally distinct from
 * the DOM lib Event that dispatchEvent is typed against. Same cast the window
 * dispatches above use, kept in one place.
 */
function keydown(win: Window, key: string): Event {
  const ctor = (win as unknown as { KeyboardEvent: new (type: string, init: unknown) => unknown })
    .KeyboardEvent;
  return new ctor("keydown", { key, bubbles: true }) as unknown as Event;
}

test("roving focus moves with arrow keys and wraps", () => {
  const win = mount(
    `<div data-wrn-roving="horizontal">
       <button data-wrn-roving-item id="a">A</button>
       <button data-wrn-roving-item id="b">B</button>
       <button data-wrn-roving-item id="c">C</button>
     </div>`,
  );
  const doc = win.document;
  const a = doc.querySelector("#a") as unknown as HTMLElement;
  const c = doc.querySelector("#c") as unknown as HTMLElement;

  expect(a.getAttribute("tabindex")).toBe("0");
  expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("-1");

  a.focus();
  a.dispatchEvent(keydown(win, "ArrowRight"));
  expect(doc.activeElement!.id).toBe("b");
  expect(doc.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
  expect(a.getAttribute("tabindex")).toBe("-1");

  (doc.querySelector("#b") as unknown as HTMLElement).dispatchEvent(keydown(win, "ArrowLeft"));
  expect(doc.activeElement!.id).toBe("a");

  a.dispatchEvent(keydown(win, "ArrowLeft"));
  expect(doc.activeElement!.id).toBe("c");

  c.dispatchEvent(keydown(win, "ArrowRight"));
  expect(doc.activeElement!.id).toBe("a");
});

test("roving focus honours Home and End and skips disabled items", () => {
  const win = mount(
    `<div data-wrn-roving="vertical">
       <button data-wrn-roving-item id="a">A</button>
       <button data-wrn-roving-item id="b" disabled>B</button>
       <button data-wrn-roving-item id="c">C</button>
     </div>`,
  );
  const doc = win.document;
  const a = doc.querySelector("#a") as unknown as HTMLElement;
  a.focus();

  a.dispatchEvent(keydown(win, "ArrowDown"));
  expect(doc.activeElement!.id).toBe("c");

  (doc.querySelector("#c") as unknown as HTMLElement).dispatchEvent(keydown(win, "Home"));
  expect(doc.activeElement!.id).toBe("a");

  a.dispatchEvent(keydown(win, "End"));
  expect(doc.activeElement!.id).toBe("c");
});

test("horizontal roving ignores vertical arrows so the page still scrolls", () => {
  const win = mount(
    `<div data-wrn-roving="horizontal">
       <button data-wrn-roving-item id="a">A</button>
       <button data-wrn-roving-item id="b">B</button>
     </div>`,
  );
  const a = win.document.querySelector("#a") as unknown as HTMLElement;
  a.focus();
  a.dispatchEvent(keydown(win, "ArrowDown"));
  expect(win.document.activeElement!.id).toBe("a");
});

test("roving tabindex starts on the selected item, not the first", () => {
  const win = mount(
    `<div data-wrn-roving="horizontal">
       <button data-wrn-roving-item id="a">A</button>
       <button data-wrn-roving-item id="b" aria-selected="true">B</button>
     </div>`,
  );
  expect(win.document.querySelector("#b")!.getAttribute("tabindex")).toBe("0");
  expect(win.document.querySelector("#a")!.getAttribute("tabindex")).toBe("-1");
});

test("nested roving groups do not capture the outer group items", () => {
  const win = mount(
    `<div data-wrn-roving="horizontal" id="outer">
       <button data-wrn-roving-item id="a">A</button>
       <div data-wrn-roving="vertical" id="inner">
         <button data-wrn-roving-item id="x">X</button>
         <button data-wrn-roving-item id="y">Y</button>
       </div>
       <button data-wrn-roving-item id="b">B</button>
     </div>`,
  );
  const doc = win.document;
  const a = doc.querySelector("#a") as unknown as HTMLElement;
  a.focus();
  a.dispatchEvent(keydown(win, "ArrowRight"));
  expect(doc.activeElement!.id).toBe("b");
});

test("opening a modal dialog traps Tab inside it", () => {
  const win = mount(
    `<div>
       <button id="outside">Outside</button>
       <div data-show="true">
         <section role="dialog" aria-modal="true" tabindex="-1">
           <button id="first">First</button>
           <button id="last">Last</button>
         </section>
       </div>
     </div>`,
  );
  const doc = win.document;
  const last = doc.querySelector("#last") as unknown as HTMLElement;
  last.focus();
  last.dispatchEvent(keydown(win, "Tab"));
  expect(doc.activeElement!.id).toBe("first");
});

test("a hidden dialog does not trap Tab", () => {
  const win = mount(
    `<div>
       <button id="outside">Outside</button>
       <div data-show="false">
         <section role="dialog" aria-modal="true" tabindex="-1">
           <button id="first">First</button>
         </section>
       </div>
     </div>`,
  );
  const doc = win.document;
  const outside = doc.querySelector("#outside") as unknown as HTMLElement;
  outside.focus();
  outside.dispatchEvent(keydown(win, "Tab"));
  expect(doc.activeElement!.id).toBe("outside");
});

test("an empty or false roving attribute opts the group out entirely", () => {
  const win = mount(
    `<div data-wrn-roving="">
       <button data-wrn-roving-item="false" id="a">A</button>
       <button data-wrn-roving-item="false" id="b">B</button>
     </div>`,
  );
  const doc = win.document;
  const a = doc.querySelector("#a") as unknown as HTMLElement;
  expect(a.getAttribute("tabindex")).toBeNull();
  a.focus();
  a.dispatchEvent(keydown(win, "ArrowRight"));
  expect(doc.activeElement!.id).toBe("a");
});

test("json inside a textarea is left alone, not read as mustaches", () => {
  const win = mount(
    `<div data-scope="count: 7">
       <textarea id="editor">{"id":"a-1","count":3}</textarea>
       <span id="out">{count}</span>
     </div>`,
  );
  const doc = win.document;
  // The surrounding scope still interpolates normally...
  expect(doc.querySelector("#out")!.textContent).toBe("7");
  // ...but the textarea holds data, and an expression engine would have
  // evaluated {"id":"a-1","count":3} away and left it empty.
  expect(doc.querySelector("#editor")!.textContent).toBe('{"id":"a-1","count":3}');
});

test("a closed drawer does not hold the body scroll lock", () => {
  const win = mount(
    `<div data-ui-component="Drawer" data-open="false">
       <div class="layer">
         <section role="dialog" aria-modal="true" tabindex="-1">
           <button id="inside">Inside</button>
         </section>
       </div>
     </div>`,
  );
  const doc = win.document;
  /*
   * A drawer animates open, so its panel cannot be hidden with data-show --
   * display:none is not transitionable. It publishes data-open instead. Reading
   * only data-show made every closed drawer look open, which locked the body
   * and left the page unscrollable.
   */
  expect(doc.body.style.overflow).not.toBe("hidden");

  const outside = doc.querySelector("#inside") as unknown as HTMLElement;
  outside.focus();
  outside.dispatchEvent(keydown(win, "Tab"));
  // No trap either: focus is free to leave a closed dialog.
  expect(doc.activeElement!.id).toBe("inside");
});

test("an open drawer locks the body scroll and traps Tab", () => {
  const win = mount(
    `<div data-ui-component="Drawer" data-open="true">
       <section role="dialog" aria-modal="true" tabindex="-1">
         <button id="first">First</button>
         <button id="last">Last</button>
       </section>
     </div>`,
  );
  const doc = win.document;
  expect(doc.body.style.overflow).toBe("hidden");
  const last = doc.querySelector("#last") as unknown as HTMLElement;
  last.focus();
  last.dispatchEvent(keydown(win, "Tab"));
  expect(doc.activeElement!.id).toBe("first");
});

test("splitter keyboard steps move the divider and clamp at the bounds", () => {
  const win = mount(
    `<div data-wrn-splitter="horizontal" data-wrn-splitter-min="20" data-wrn-splitter-step="10"
          style="--wrn-split: 50%">
       <div>left</div>
       <div data-wrn-splitter-handle role="separator" tabindex="0"
            aria-valuenow="50" aria-valuemin="20" aria-valuemax="80"></div>
       <div>right</div>
     </div>`,
  );
  const doc = win.document;
  const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
  const handle = doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement;

  handle.dispatchEvent(keydown(win, "ArrowRight"));
  expect(handle.getAttribute("aria-valuenow")).toBe("60");
  expect(root.style.getPropertyValue("--wrn-split").trim()).toBe("60%");

  handle.dispatchEvent(keydown(win, "ArrowLeft"));
  expect(handle.getAttribute("aria-valuenow")).toBe("50");

  // Home and End go to the bounds, not to 0 and 100: a pane dragged to
  // nothing cannot be recovered with a pointer.
  handle.dispatchEvent(keydown(win, "Home"));
  expect(handle.getAttribute("aria-valuenow")).toBe("20");
  handle.dispatchEvent(keydown(win, "End"));
  expect(handle.getAttribute("aria-valuenow")).toBe("80");

  // Already at the maximum; another step must not exceed it.
  handle.dispatchEvent(keydown(win, "ArrowRight"));
  expect(handle.getAttribute("aria-valuenow")).toBe("80");
});

test("a vertical splitter responds to up and down instead", () => {
  const win = mount(
    `<div data-wrn-splitter="vertical" data-wrn-splitter-min="25" data-wrn-splitter-step="5"
          style="--wrn-split: 50%">
       <div>top</div>
       <div data-wrn-splitter-handle role="separator" tabindex="0"
            aria-valuenow="50" aria-valuemin="25" aria-valuemax="75"></div>
       <div>bottom</div>
     </div>`,
  );
  const handle = win.document.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement;

  handle.dispatchEvent(keydown(win, "ArrowDown"));
  expect(handle.getAttribute("aria-valuenow")).toBe("55");
  // The other axis is left alone so the page can still scroll sideways.
  handle.dispatchEvent(keydown(win, "ArrowRight"));
  expect(handle.getAttribute("aria-valuenow")).toBe("55");
});

test("splitter announces its new size for the component to re-emit", () => {
  const win = mount(
    `<div data-wrn-splitter="horizontal" data-wrn-splitter-min="10" data-wrn-splitter-step="10"
          style="--wrn-split: 50%">
       <div>left</div>
       <div data-wrn-splitter-handle role="separator" tabindex="0"
            aria-valuenow="50" aria-valuemin="10" aria-valuemax="90"></div>
       <div>right</div>
     </div>`,
  );
  const doc = win.document;
  const root = doc.querySelector("[data-wrn-splitter]") as unknown as HTMLElement;
  const seen: number[] = [];
  root.addEventListener("wrnexus:splitter:resize", (event) =>
    seen.push((event as CustomEvent).detail.size),
  );

  (doc.querySelector("[data-wrn-splitter-handle]") as unknown as HTMLElement).dispatchEvent(
    keydown(win, "ArrowRight"),
  );
  expect(seen).toEqual([60]);
});
