#!/usr/bin/env node
/**
 * The chapter list is a public interface. This is the check that says so.
 *
 *     node scripts/check-chapter-identity.mjs           # against dist/
 *     node scripts/check-chapter-identity.mjs --dist …  # against another build
 *     node scripts/check-chapter-identity.mjs --write   # re-cut the fixture (read the warning)
 *
 * ## What it protects, and why it had to exist before the ladder work
 *
 * Twenty-six index-aimed guards outside this file point a camera at a chapter by
 * its **position in a list** and then assert its short label. Counted from the
 * source rather than taken from a plan — comment lines that *quote* the pattern
 * are excluded, which is where the "29" in the round's brief came from:
 *
 *   - `scripts/brand-assets/shots.mjs` — 21, every one of them a still on
 *     lumbridgecorp.com right now.
 *   - `scripts/brand-assets/films.mjs` — 4.
 *   - `scripts/brand-assets/capture.mjs` — 1: the Open Graph card, which is what
 *     every link to the site renders as, and the most-seen frame in the product.
 *
 * Twenty-one of them aim at a board a `?city=` can reach — 12 at the Bay Area,
 * 6 at California, 3 at SoCal — and this check covers those. The other five are
 * office shots aiming at a studio's own view list, which no `?city=` selects.
 *
 * That pattern was chosen for a good reason and it is written up at
 * `capture.mjs:70`: a card once shipped for a fortnight showing a chase camera
 * on a freeway under the headline "Cities from above", because `keyboard.press("2")`
 * is an unguarded index into pack data and the default board had changed under it.
 * The label assertion was the fix. It is a *good* fix and it is not sufficient,
 * for two reasons this repo can now demonstrate rather than argue:
 *
 * 1. **Labels are not unique across boards.** "Whole Board" is chapter 01 of the
 *    Bay Area *and* chapter 01 of SoCal; "The Valley" is Silicon Valley on one
 *    board and the San Fernando Valley on the other. A reorder that preserves
 *    labels passes every one of those twenty-six guards and shoots the wrong place.
 * 2. **A guard on the chapter you clicked says nothing about the one you did
 *    not.** Several shots take no chapter at all and inherit whatever board the
 *    URL opened on, which is a fallback rather than an assertion.
 *
 * So this file snapshots the *identity* of every chapter on every board — its
 * `data-view`, its position, its printed number and its short label — against a
 * checked-in fixture, and fails on any drift. It is deliberately not a test of
 * whether the chapters are *good*; it is a test of whether they are the same
 * ones the marketing imagery was aimed at.
 *
 * ## It reads the DOM, not the packs
 *
 * A unit test over `src/cities/*.ts` would be cheaper and would miss the thing
 * that actually breaks: the harnesses read `#chapters .chapter`, and what lands
 * there is `chromeState.ts`'s `views` array after `main.ts` has decided which
 * board is up. Number is taken from the pack *or* the ordinal, and the
 * `data-view-index` written into the button is the index into that rendered
 * list. Only the DOM knows all of it at once. `src/test/chapterIdentity.test.ts`
 * carries the fast pack-level half of the same contract, including the negative
 * case, so that a reorder fails in `npm test` in a second rather than here in a
 * minute.
 *
 * ## --write
 *
 * Regenerating the fixture is how you record a deliberate change, and it is not
 * a way to make this go green. Every drift it reports is a frame on
 * lumbridgecorp.com that is about to become a picture of somewhere else, and the
 * 26 aims above have to be re-pointed in the same commit — which means
 * re-shooting the imagery, which is an owner decision, not a refactor.
 */

import { chromium } from "playwright";
import { createServer } from "node:http";
import { readFile, writeFile } from "node:fs/promises";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = fileURLToPath(new URL("..", import.meta.url));
const DEFAULT_FIXTURE = join(ROOT, "scripts", "fixtures", "chapter-identity.json");
const MIME = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript",
  ".css": "text/css",
  ".json": "application/json",
  ".png": "image/png",
  ".svg": "image/svg+xml",
  ".webp": "image/webp",
  ".webmanifest": "application/manifest+json",
};

const args = process.argv.slice(2);
const option = (name, fallback) => {
  const at = args.indexOf(`--${name}`);
  return at < 0 ? fallback : args[at + 1];
};
const has = (name) => args.includes(`--${name}`);

const DIST = resolve(option("dist", join(ROOT, "dist")));
const WRITE = has("write");
const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000;
/*
 * `--fixture` exists so the failure can be watched.
 *
 * A guard nobody has seen fail is not a guard, and the only other way to see
 * this one fail is to break the packs — on a shared tree, with two other
 * workstreams in the same files. Point it at a deliberately scrambled copy
 * instead:
 *
 *     jq '.boards.sf.chapters |= (.[0:2] + [.[3], .[2]] + .[4:])' \
 *       scripts/fixtures/chapter-identity.json > /tmp/scrambled.json
 *     node scripts/check-chapter-identity.mjs --fixture /tmp/scrambled.json   # must FAIL
 */
const FIXTURE = resolve(option("fixture", DEFAULT_FIXTURE));

/**
 * The boards, by the `?city=` value that selects them.
 *
 * This list is itself part of the contract: `?city=` is the coordinate every
 * capture harness and every deep link uses, and `main.ts` falls back to the
 * first board rather than failing on an unknown id — so a board that quietly
 * lost its id would not throw, it would silently serve California to every shot
 * aimed at SoCal. The snapshot records which board answered, so that failure
 * shows up as a mismatch instead of as a fortnight of wrong pictures.
 */
const CITY_PARAMS = ["california", "sf", "socal"];

async function serve() {
  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://local.invalid");
    const json = (status, body) => {
      res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
      res.end(JSON.stringify(body));
    };
    if (url.pathname === "/api/v1/health") {
      return json(200, {
        auth: { mode: "jwt", entryUrl: "/login.html" },
        sources: { weather: "none", flights: "none", satellites: "none", markers: "none" },
        regions: [],
      });
    }
    if (url.pathname === "/api/v1/session") {
      return json(200, { authenticated: false, subject: null, passwordLogin: true, admin: false });
    }
    if (url.pathname.startsWith("/api/v1/")) return json(404, { error: "not_found" });
    const requested = normalize(decodeURIComponent(url.pathname)).replace(/^(?:\.\.[/\\])+/, "");
    for (const relative of [requested === "/" ? "/index.html" : requested, "/index.html"]) {
      const target = resolve(DIST, `.${relative}`);
      if (!target.startsWith(`${resolve(DIST)}/`)) continue;
      try {
        const body = await readFile(target);
        res.writeHead(200, { "content-type": MIME[extname(target)] ?? "application/octet-stream" });
        res.end(body);
        return;
      } catch {
        /* fall through to the SPA entry */
      }
    }
    res.writeHead(404).end("not found");
  });
  await new Promise((ok, fail) => {
    server.once("error", fail);
    server.listen(0, "127.0.0.1", ok);
  });
  const address = server.address();
  if (!address || typeof address === "string") throw new Error("no TCP port");
  return { server, port: address.port };
}

async function launch() {
  const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1";
  const common = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"];
  const ladder = [
    ["vulkan", ["--use-gl=angle", "--use-angle=vulkan"]],
    ["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]],
  ];
  let last;
  for (const [backend, flags] of ladder) {
    try {
      const browser = await chromium.launch({ channel: "chrome", args: [...common, resolver, ...flags] });
      return { browser, backend };
    } catch (error) {
      last = error;
    }
  }
  throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}

/** Everything the DOM knows about the chapter strip on one board. */
async function snapshotBoard(browser, port, cityParam) {
  const page = await browser.newPage();
  try {
    await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${cityParam}`, {
      waitUntil: "networkidle",
      timeout: READY_TIMEOUT_MS,
    });
    await page.waitForFunction(
      () =>
        document.getElementById("boot")?.hidden === true &&
        document.querySelectorAll("#chapters .chapter").length > 0,
      null,
      { timeout: READY_TIMEOUT_MS },
    );
    return await page.evaluate(() => {
      const text = (node) => (node?.textContent ?? "").trim();
      const chapters = [...document.querySelectorAll("#chapters .chapter")].map((button, index) => {
        const spans = [...button.querySelectorAll("span")];
        return {
          index,
          dataView: button.getAttribute("data-view"),
          dataViewIndex: button.getAttribute("data-view-index"),
          number: text(spans[0]),
          shortLabel: text(spans[spans.length - 1]),
        };
      });
      const boards = [...document.querySelectorAll("[data-board]")].map((button) => ({
        id: button.getAttribute("data-board"),
        label: text(button.querySelector(".board__name")) || text(button),
        active: button.getAttribute("aria-pressed") === "true",
      }));
      return { chapters, boards, activeBoard: boards.find((b) => b.active)?.id ?? null };
    });
  } finally {
    await page.close();
  }
}

// ---- The aims this exists to protect --------------------------------------
//
// Read out of the capture harnesses rather than duplicated here, because a copy
// would rot in exactly the way this file is trying to stop. Nothing under
// `scripts/brand-assets/` is written to — this reads their source and reports
// which of their `chapter: N` / `expect: "…"` pairs no longer agree with the
// board they aim at.

async function readAims() {
  const files = [
    ["scripts/brand-assets/shots.mjs", "shot"],
    ["scripts/brand-assets/films.mjs", "film"],
    ["scripts/brand-assets/capture.mjs", "og-card"],
  ];
  const aims = [];
  for (const [relative, kind] of files) {
    let source;
    try {
      source = await readFile(join(ROOT, relative), "utf8");
    } catch {
      continue;
    }
    const lines = source.split("\n");
    let id = null;
    let door = null;
    let city = null;
    for (let i = 0; i < lines.length; i += 1) {
      const line = lines[i];
      if (/^\s*\*/.test(line)) continue; // a comment quoting one of these is not one
      const idAt = /^\s*id:\s*"([^"]+)"/.exec(line);
      if (idAt) {
        id = idAt[1];
        door = null;
        city = null;
      }
      const doorAt = /^\s*door:\s*"([^"]+)"/.exec(line);
      if (doorAt) door = doorAt[1];
      const cityAt = /^\s*city:\s*"([^"]+)"/.exec(line);
      if (cityAt) city = cityAt[1];
      // `capture.mjs` does not carry a shot record — it calls `shootApp` with a
      // URL. The Open Graph card is the single most-seen frame in the product
      // (it is what every link to lumbridgecorp.com renders as), so its aim is
      // read out of the URL rather than left uncovered.
      const urlAt = /"https?:\/\/[^"]*[?&]city=([a-z-]+)[^"]*"/.exec(line);
      if (urlAt) {
        city = urlAt[1];
        door = null;
        const nameAt = /"([\w-]+\.png)"/.exec(line);
        if (nameAt) id = nameAt[1];
      }
      const chapterAt = /^\s*chapter:\s*(\d+)/.exec(line);
      if (!chapterAt) continue;
      let expect = null;
      for (let j = i + 1; j < Math.min(lines.length, i + 6); j += 1) {
        const expectAt = /^\s*expect:\s*"([^"]+)"/.exec(lines[j]);
        if (expectAt) {
          expect = expectAt[1];
          break;
        }
      }
      aims.push({
        file: relative,
        line: i + 1,
        kind,
        id: id ?? "(unnamed)",
        // An office shot has no `city:` — it aims at the studio's own view list,
        // which this check does not cover and which no `?city=` can reach.
        city: door === "office" ? null : city,
        chapter: Number(chapterAt[1]),
        expect,
      });
    }
  }
  return aims;
}

// ---- Run ------------------------------------------------------------------

const { server, port } = await serve();
const { browser, backend } = await launch();
let observed;
try {
  observed = {};
  for (const cityParam of CITY_PARAMS) {
    observed[cityParam] = await snapshotBoard(browser, port, cityParam);
  }
} finally {
  await browser.close();
  server.close();
}

const snapshot = {
  takenAt: "bcac6aa",
  note:
    "The chapter strip as the capture harnesses see it. Regenerate with --write ONLY as " +
    "part of a deliberate, owner-approved re-shoot: 26 index-aimed guards in " +
    "scripts/brand-assets/** point at these positions, 21 of them at a board a ?city= " +
    "reaches, and every one of them is a frame on lumbridgecorp.com.",
  boards: Object.fromEntries(
    CITY_PARAMS.map((cityParam) => [
      cityParam,
      {
        activeBoard: observed[cityParam].activeBoard,
        boardTabs: observed[cityParam].boards.map((board) => ({ id: board.id, label: board.label })),
        chapters: observed[cityParam].chapters,
      },
    ]),
  ),
};

if (WRITE) {
  await writeFile(FIXTURE, `${JSON.stringify(snapshot, null, 2)}\n`);
  console.log(`check-chapter-identity: wrote ${FIXTURE}`);
  console.log(
    "check-chapter-identity: a re-cut fixture means the 26 aims in scripts/brand-assets/**\n" +
      "  are now pointed at whatever this build happens to render. Re-point them and re-shoot.",
  );
  process.exit(0);
}

let expected;
try {
  expected = JSON.parse(await readFile(FIXTURE, "utf8"));
} catch (error) {
  console.error(`check-chapter-identity: cannot read ${FIXTURE} — ${error}`);
  console.error("  Take one with --write, from a build you have looked at.");
  process.exit(1);
}

/*
 * TWO SEVERITIES, AND THE LINE BETWEEN THEM IS "DOES A CAMERA AIM AT IT".
 *
 * `failures` is the chapter strip: `data-view`, position, printed number, short
 * label, and how many there are. Every one of those is something a capture
 * harness resolves a frame through, so drift there is a picture of somewhere
 * else and stops the run.
 *
 * `notes` is the board chrome — the tab strip and which tab is pressed. That was
 * a hard failure in the first version of this file and it fired within the hour,
 * on the continuity work legitimately removing the three-tab strip. Nothing aims
 * a camera at a tab: `?city=` selects the board and the chapter list is what
 * proves which board answered, because the ids are unique per pack. So chrome
 * drift is printed and does not fail.
 *
 * The silent-fallback case is still caught, and caught harder: `?city=sf`
 * degrading to California renders California's six chapters, which is a
 * count-and-id failure on every row.
 */
const failures = [];
const notes = [];
const field = (where, want, got, soft = false) => {
  if (want === got) return;
  (soft ? notes : failures).push(
    `${where}: expected ${JSON.stringify(want)}, got ${JSON.stringify(got)}`,
  );
};

for (const cityParam of CITY_PARAMS) {
  const want = expected.boards?.[cityParam];
  const got = snapshot.boards[cityParam];
  if (!want) {
    failures.push(`?city=${cityParam}: not in the fixture — a board appeared`);
    continue;
  }
  field(`?city=${cityParam} pressed tab`, want.activeBoard, got.activeBoard, true);
  field(
    `?city=${cityParam} board tabs`,
    want.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "),
    got.boardTabs.map((tab) => `${tab.id}:${tab.label}`).join(" | "),
    true,
  );
  field(`?city=${cityParam} chapter count`, want.chapters.length, got.chapters.length);
  const count = Math.min(want.chapters.length, got.chapters.length);
  for (let i = 0; i < count; i += 1) {
    for (const key of ["dataView", "dataViewIndex", "number", "shortLabel", "index"]) {
      field(`?city=${cityParam} chapter[${i}].${key}`, want.chapters[i][key], got.chapters[i][key]);
    }
  }
}
for (const cityParam of Object.keys(expected.boards ?? {})) {
  if (!CITY_PARAMS.includes(cityParam)) failures.push(`?city=${cityParam}: gone from this build`);
}

// Whether or not the fixture drifted, say which aims are currently right — a
// green fixture with a misaimed shot is exactly the state this repo was in at
// bcac6aa, and it was found with a photograph rather than with a check.
const aims = await readAims();
const misaimed = [];
for (const aim of aims) {
  if (aim.city === null) continue;
  const board = snapshot.boards[aim.city];
  if (!board) {
    misaimed.push(`${aim.file}:${aim.line} ${aim.id} — city:"${aim.city}" is not a board`);
    continue;
  }
  const chapter = board.chapters[aim.chapter];
  if (!chapter) {
    misaimed.push(
      `${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} does not exist on ${aim.city}`,
    );
    continue;
  }
  if (aim.expect !== null && chapter.shortLabel !== aim.expect) {
    misaimed.push(
      `${aim.file}:${aim.line} ${aim.id} — chapter ${aim.chapter} on ${aim.city} is ` +
        `"${chapter.shortLabel}" (${chapter.dataView}), the aim says "${aim.expect}"`,
    );
  }
}

const covered = aims.filter((aim) => aim.city !== null).length;
console.log(`check-chapter-identity — dist ${DIST} (${backend})`);
for (const cityParam of CITY_PARAMS) {
  const board = snapshot.boards[cityParam];
  console.log(
    `  ?city=${cityParam.padEnd(11)} ${String(board.chapters.length).padStart(2)} chapters  ` +
      board.chapters.map((chapter) => chapter.dataView).join(", "),
  );
}
console.log(
  `  ${aims.length} index-aimed guards read from the capture harnesses; ` +
    `${covered} of them aim at a board this check covers.`,
);

if (notes.length > 0) {
  console.log("");
  console.log("NOTE — the board chrome moved. Nothing aims a camera at it, so this is not a");
  console.log("failure; re-cut the fixture with --write when the change is settled:");
  for (const line of notes) console.log(`  ${line}`);
}
if (misaimed.length > 0) {
  console.log("");
  console.log("MISAIMED — these guards would shoot a different frame than their comment claims:");
  for (const line of misaimed) console.log(`  ${line}`);
}
if (failures.length > 0) {
  console.log("");
  console.log(`FAIL — the chapter strip moved under ${failures.length} field(s):`);
  for (const line of failures) console.log(`  ${line}`);
  console.log("");
  console.log(
    "Every one of these is a capture aim that now points somewhere else. Re-point the\n" +
      "guards in scripts/brand-assets/**, re-shoot the imagery, and only then --write.",
  );
}

const bad = failures.length + misaimed.length;
if (bad === 0) console.log("check-chapter-identity: OK");
process.exit(bad === 0 ? 0 : 1);
