import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";

import { DEVICE_PANEL_CSS } from "../../ui/devicePanel.ts";
import { FIRE_PANEL_CSS } from "../../ui/firePanel.ts";
import { ONBOARDING_CSS } from "../../ui/onboarding.ts";
import { TOUCH_TARGET_PX } from "../../ui/tokens.ts";

function read(relative: string): string {
  return readFileSync(new URL(`../../../${relative}`, import.meta.url), "utf8");
}

const html = read("index.html");

/**
 * The stylesheet with its own prose removed.
 *
 * Every structural assertion below runs against this rather than the raw file,
 * for two reasons that both bit once while writing it: a CSS comment quoting a
 * selector reads as that selector to a line scanner, and a comment containing a
 * `}` truncates any "slice to the closing brace" rule-body extraction. The raw
 * text is still used for the z-index assertion, deliberately — that one is meant
 * to be as literal as the `grep` in the build spec.
 */
const css = html.replace(/\/\*[\s\S]*?\*\//g, "");

/** Any `z-index: 4`-shaped declaration. A `var(--z-…)` reference is not one. */
const RAW_Z_INDEX = /z-index:\s*\d/g;

describe("the stylesheet", () => {
  it("writes no raw z-index outside the :root token block", () => {
    const start = html.indexOf(":root {");
    const end = html.indexOf("\n      }", start);
    const outside = html.slice(0, start) + html.slice(end);
    const found = outside.match(RAW_Z_INDEX) ?? [];
    assert.deepEqual(
      found,
      [],
      `index.html writes ${found.length} raw z-index literal(s) outside :root — use var(--z-…)`,
    );
  });

  it("writes no raw z-index in any of the injected stylesheets", () => {
    // These four modules mount a `<style>` at runtime and each used to carry its
    // own stacking literal, invisible to every other file on the page. Two of
    // them collided with rules in index.html.
    for (const file of [
      "src/tools/godmode.ts",
      "src/profile/webcamPanel.ts",
      "src/profile/editor.ts",
      "src/media/officeScreenPanel.ts",
    ]) {
      const found = read(file).match(RAW_Z_INDEX) ?? [];
      assert.deepEqual(found, [], `${file} writes a raw z-index literal`);
    }
    for (const [name, css] of [
      ["devicePanel", DEVICE_PANEL_CSS],
      ["onboarding", ONBOARDING_CSS],
      // The third, added when the fire panel landed. It injects one
      // `<style>` per document exactly as `devicePanel` does, so it is subject
      // to exactly the same rule, and this is the shared gate rather than its
      // own file's copy of the assertion.
      ["firePanel", FIRE_PANEL_CSS],
    ] as const) {
      assert.deepEqual(css.match(RAW_Z_INDEX) ?? [], [], `${name} writes a raw z-index literal`);
    }
  });

  it("has no trace of the two removed control-bar ids left anywhere", () => {
    // ~100 lines of CSS and two JS bindings survived the removal of the elements
    // themselves. Some of the rules were not merely dead — see the next test.
    //
    // The ids are assembled rather than written out, because the release gate
    // greps the whole tree for them and a test file containing the literal
    // string would be the last remaining hit.
    for (const id of ["drive", "walk"].map((name) => `${name}-controls`)) {
      assert.equal(html.includes(id), false, `index.html still references #${id}`);
    }
  });

  it("guards every .touch-play-controls offset rule behind a coarse pointer", () => {
    /*
     * The live layout bug this closes.
     *
     * `body:has(.touch-play-controls:not([hidden])) .mode-dock { bottom: 10rem }`
     * had no pointer guard, and `.touch-play-controls` is only `display: none`
     * on a fine pointer — `:has()` still matches an element that is not
     * displayed. So starting a drive with a mouse hid the key-hint strip and
     * shoved the mode dock up 160px to make room for controls that are not
     * drawn on that device, at exactly the moment a new driver needed the hints.
     *
     * The rule below is structural rather than textual: find every line that
     * both selects `.touch-play-controls` in a `:has()` and sets an offset, and
     * require each one to be inside a `(pointer: coarse)` block or prefixed with
     * `body.touch-capable`.
     */
    const lines = css.split("\n");
    let coarseDepth = 0;
    let braceDepthAtCoarse = 0;
    let braceDepth = 0;
    const unguarded: string[] = [];

    for (const line of lines) {
      if (coarseDepth === 0 && line.includes("@media (pointer: coarse)")) {
        coarseDepth = 1;
        braceDepthAtCoarse = braceDepth;
      }
      if (line.includes(":has(.touch-play-controls") && !line.includes("display")) {
        const guarded = coarseDepth > 0 || line.includes("body.touch-capable");
        if (!guarded) unguarded.push(line.trim());
      }
      braceDepth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
      if (coarseDepth > 0 && braceDepth <= braceDepthAtCoarse) coarseDepth = 0;
    }

    assert.deepEqual(
      unguarded,
      [],
      "an offset rule keyed off .touch-play-controls escaped its pointer guard",
    );
  });

  it("keeps page zoom, and keeps the canvas reaching the notch", () => {
    const viewport = html.match(/<meta name="viewport" content="([^"]+)"/)?.[1] ?? "";
    assert.ok(viewport.includes("viewport-fit=cover"));
    // Deliberately preserved. Taking page zoom away from everyone to protect one
    // element is the accessibility mistake the rule exists to avoid, and the
    // canvas is already protected by `touch-action: none`.
    assert.equal(viewport.includes("user-scalable=no"), false);
    assert.equal(viewport.includes("maximum-scale"), false);
  });

  it("pays for viewport-fit=cover: every fixed edge carries a safe-area inset", () => {
    // The rule the notch imposes. A `bottom: var(--s4)` on a phone puts a
    // control under the home indicator: visible, unpressable.
    for (const selector of [".rail", ".source", ".mode-dock", ".panel-toggle"]) {
      const block = css.slice(css.indexOf(`${selector} {`));
      const body = block.slice(0, block.indexOf("}"));
      assert.match(
        body,
        /env\(safe-area-inset-/,
        `${selector} is pinned to an edge without a safe-area inset`,
      );
    }
  });

  it("cannot draw an office tab's badge on top of its own name", () => {
    /*
     * The defect that was on every screenshot of every studio.
     *
     * The old rule was `grid-template-columns: minmax(0, 1fr) auto` with nothing
     * clipping the first track, and `minmax(0, 1fr)` lets a track shrink below
     * its content while a plain `<span>` paints outside it — so at the ~85px
     * each tab gets in a three-up strip, "SF HQ · Studio" was drawn straight
     * over the ACTIVE badge.
     *
     * Two properties make that impossible now and both are asserted: the tab is
     * a single-column grid, so the name and the badge are on different rows and
     * cannot occupy the same box at any width; and the name clips.
     */
    const board = css.slice(css.indexOf(".board {"));
    const boardBody = board.slice(0, board.indexOf("}"));
    assert.match(boardBody, /display:\s*grid/);
    assert.equal(
      /grid-template-columns/.test(boardBody),
      false,
      "an office tab must be one column: two tracks is what let the badge and the name collide",
    );

    const name = css.slice(css.indexOf(".board__name {"));
    const nameBody = name.slice(0, name.indexOf("}"));
    assert.match(nameBody, /overflow:\s*hidden/);
    assert.match(nameBody, /text-overflow:\s*ellipsis/);
    assert.match(nameBody, /min-width:\s*0/);
  });

  it("carries every element the chrome applier writes to", () => {
    // `mount.ts` resolves these by id once and then never checks again. A
    // renamed id would silently stop applying one decision rather than throwing.
    const required = [
      "scene", "panel", "panel-toggle", "panel-toggle-label", "scrim",
      "title", "subtitle", "clock", "cities", "boards-title",
      "enter", "walk", "fly", "screens", "devices",
      "device-section", "device-host", "office-invite", "office-note",
      "fire-section", "fire-host",
      "chapters", "blurb",
      "topright", "tier", "tier-label", "tier-who", "tier-signin", "tier-character",
      "tier-adds", "presence-host", "webcam-face-indicator", "corner", "minimap",
      "minimap-readout",
      "mode-dock", "play-hud", "play-hud-mode", "play-hud-primary", "play-hud-status",
      "rail", "detail", "detail-text", "detail-close", "hint", "plan-toggle", "help",
      "touch-play-controls", "play-stick", "play-stick-knob", "play-stick-label",
      "touch-primary", "touch-secondary", "touch-pitch-up", "touch-pitch-down",
      "touch-assist", "touch-reset", "touch-camera", "touch-map",
      "source", "shortcuts", "shortcuts-body", "shortcuts-close",
      "onboarding-host", "boot", "boot-step",
    ];
    for (const id of required) {
      assert.ok(html.includes(`id="${id}"`), `index.html is missing #${id}`);
    }
  });

  it("keeps the boot card inline, above everything, and first", () => {
    // It is in the document on purpose: it is on screen at first paint, before
    // the module graph has been fetched, let alone before the ~2.3 s heightfield
    // build. Moving it into a module would make the page blank for that whole
    // time.
    assert.ok(html.includes('class="boot" id="boot"'));
    assert.ok(html.indexOf('id="boot"') < html.indexOf('src="/src/main.ts"'));
    const boot = css.slice(css.indexOf(".boot {"));
    assert.match(boot.slice(0, boot.indexOf("}")), /z-index:\s*var\(--z-boot\)/);
  });

  it("clears the touch target in both injected panels", () => {
    for (const [name, css] of [
      ["devicePanel", DEVICE_PANEL_CSS],
      ["onboarding", ONBOARDING_CSS],
    ] as const) {
      const targets = [...css.matchAll(/min-height:\s*(?:var\(--tap,\s*)?(\d+)px/g)].map((m) =>
        Number(m[1]),
      );
      assert.ok(targets.length > 0, `${name} declares no touch target at all`);
      for (const size of targets) {
        assert.ok(size >= TOUCH_TARGET_PX, `${name} has a ${size}px target, below ${TOUCH_TARGET_PX}px`);
      }
    }
  });

  it("respects prefers-reduced-motion everywhere something animates", () => {
    assert.ok(html.includes("@media (prefers-reduced-motion: reduce)"));
    assert.ok(ONBOARDING_CSS.includes("@media (prefers-reduced-motion: reduce)"));
    assert.ok(DEVICE_PANEL_CSS.includes("@media (prefers-reduced-motion: reduce)"));
  });
});

/**
 * The places rail's fade is a two-file mechanism: `markOverflow` in
 * `ui/mount.ts` writes `data-overflow`, and the stylesheet decides what that
 * looks like. Nothing else connects them, so either half can be renamed without
 * the other noticing and the failure is silent — a list that clips a row and
 * offers no sign it scrolls, which is exactly the fault the fade was added to
 * fix. This asserts the two halves still speak the same four words.
 */
describe("the places rail's overflow fade", () => {
  const mount = read("src/ui/mount.ts");

  /** Every value `markOverflow` can assign, read out of the code itself. */
  const emitted = ["both", "top", "bottom", "none"];

  it("emits exactly the values this test knows about", () => {
    const line = /const edge =[\s\S]*?;/.exec(mount)?.[0] ?? "";
    assert.notEqual(line, "", "markOverflow no longer computes an `edge`");
    for (const value of emitted) {
      assert.ok(line.includes(`"${value}"`), `markOverflow stopped emitting ${value}`);
    }
    const quoted = [...line.matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
    assert.deepEqual(new Set(quoted), new Set(emitted), "markOverflow emits a new value");
  });

  it("masks every clipped state and leaves the unclipped one alone", () => {
    for (const value of ["top", "bottom", "both"]) {
      assert.ok(
        css.includes(`.places[data-overflow="${value}"]`),
        `no fade rule for data-overflow="${value}"`,
      );
    }
    assert.ok(
      !css.includes('.places[data-overflow="none"]'),
      "a list that is not clipped must not be masked",
    );
  });

  it("keeps the unprefixed mask alongside the -webkit- one", () => {
    const rules = css.split(".places[data-overflow=").slice(1);
    assert.equal(rules.length, 3);
    for (const rule of rules) {
      const body = rule.slice(0, rule.indexOf("}"));
      assert.ok(body.includes("-webkit-mask-image:"), "missing the prefixed mask");
      assert.ok(/[^-]mask-image:/.test(body), "missing the standard mask");
    }
  });
});
