/**
 * Render the built app and write a PNG you can open and judge.
 *
 * The defects this exists for cannot be asserted. "The ocean has no specular
 * response", "the board ends in a hard diamond edge", "the aircraft are
 * illegible at board scale" are all true of code that typechecks, passes every
 * test and meets every performance budget. The only instrument that finds them
 * is a picture, so this makes taking one cheap.
 *
 *   node scripts/look.mjs <name> [--url <path-and-query>] [--phone] [--at <iso>]
 *                                [--wait <ms>] [--click <text>] [--api <origin>]
 *                                [--chapter <data-view>] [--expect <short label>]
 *
 * `--chapter` aims at a chapter by its **identity** — the `data-view` attribute
 * the button carries, which is the chapter's id in the city pack — and not by
 * its position in the list or by the text printed on it. Both of the other two
 * have already produced a photograph of somewhere else in this repo: an index
 * because a pack was reordered under it, and button text because two of
 * California's six chapters are *doors* whose labels ("LA", "SF") match a
 * click and then leave the board entirely. `--expect` asserts the printed short
 * label as a second, independent check on the same button, which is what
 * `scripts/brand-assets/shots.mjs` does and is worth keeping.
 *
 * Writes /tmp/tera-look/<name>.png. `--at` pins the clock, because the sun's
 * position is computed from the real one and a shot taken at 03:00 tells you
 * nothing about how the water reads at noon.
 *
 * This box has an AMD card and no monitor; the ANGLE/Vulkan flags below are what
 * make Chrome render headless here rather than falling back to a blank canvas.
 */

import { spawn } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { chromium } from "playwright";

const args = process.argv.slice(2);
const name = args[0] ?? "look";

/**
 * The frames this project keeps taking, by name.
 *
 * `node scripts/look.mjs sf-night` and nothing else — no query string to
 * remember and no timestamp to get wrong. The point is not convenience, it is
 * that two people asking for "the night board" get the same photograph: an
 * acceptance criterion that reads "shoot sf-night and look at it" is only worth
 * writing if `sf-night` means one thing.
 *
 * Every field is a default. An explicit flag on the command line still wins, so
 * a preset is a starting point rather than a cage, and adding one is two lines.
 */
const PRESETS = {
  // The fire boards. California is the one with fires on it on a normal day;
  // SoCal is the one that must be *empty* and say so — see ARCHITECTURE.md §9.2.
  "fires-california": { url: "/?city=california" },
  "fires-socal": { url: "/?city=socal" },
  // The LA studio's upper floor. The door lands on the SF studio, so the shot
  // switches rooms and waits for the swap.
  "mateo-loft": { url: "/?city=socal&view=office", click: "LA HQ" },
  // Night. 04:35Z is 21:35 in Los Angeles: full dark, and well clear of both
  // twilight edges, so a shot taken a minute late is the same shot.
  "sf-night": { url: "/?city=sf", at: "2026-08-23T04:35:00Z" },
  "sky-night": { url: "/?city=california", at: "2026-08-23T04:35:00Z" },
  "socal-night": { url: "/?city=socal", at: "2026-08-23T04:35:00Z" },
  /*
   * The aeroplane glyph, at the four stand-offs its clamp has to serve.
   *
   * TWO OF THESE WERE PHOTOGRAPHS OF SOMEWHERE ELSE, AND THE SET AS A WHOLE WAS
   * DESCRIBED WRONG. The old comment read "a whole board, two chapter closeups
   * on that board, and a detailed metro", and `glyph-la` / `glyph-sf` were
   * `click: "^LA$"` / `click: "^SF$"` — the California board's chapters 04 and
   * 05. Neither of those is a camera pose. Both are matched against
   * `CALIFORNIA_DESTINATIONS` in `main.ts` and call `switchCity()` rather than
   * `flyTo()`, so clicking them left California entirely: `glyph-la` returned a
   * SoCal frame and `glyph-sf` a Bay Area one, byte-comparable to `hero-socal`
   * and `hero-sf`. Two of the four stand-offs the set claimed to cover were
   * duplicates of two others.
   *
   * Photographing the fix turned up the rest of it. California's six chapters
   * are one whole-board pose, TWO DRIVES (`la-sf-us-101` and `la-sf-i-5` call
   * `requestControlMode("drive")` at `main.ts:4493`, so clicking either lands a
   * chase camera on a freeway, not a stand-off), two doors, and the north. The
   * state board therefore has exactly TWO aerial poses on it — `State` and
   * `North` — and "two chapter closeups on that board" was never available.
   *
   * So the clamp's four stand-offs are spread across the boards that have them,
   * which is a better ladder anyway: three orders of magnitude of stand-off,
   * every rung an aerial camera, every rung aimed by `data-view`.
   */
  "glyph-board": { url: "/?city=california", chapter: "california-overview", expect: "State" },
  "glyph-north": { url: "/?city=california", chapter: "shasta-cascades", expect: "North" },
  "glyph-bay": { url: "/?city=sf", chapter: "bay-area", expect: "The Bay" },
  "glyph-fidi": { url: "/?city=sf", chapter: "fidi", expect: "FiDi" },
  /*
   * The corridor, which is a drive and is photographed as one.
   *
   * These are what `glyph-la` and `glyph-sf` now resolve to: the two California
   * chapters that stay on the California board. They are named for what they
   * are rather than for the glyph, because the frame they produce is a chase
   * camera behind the EV and no aeroplane is in it.
   */
  "corridor-101": { url: "/?city=california", chapter: "la-sf-us-101", expect: "101" },
  "corridor-i5": { url: "/?city=california", chapter: "la-sf-i-5", expect: "I-5" },
  // The opening move, landed. `--reduced` collapses it to a cut, which is what
  // makes an arrival frame reproducible.
  "hero-california": { url: "/?city=california" },
  "hero-socal": { url: "/?city=socal" },
  "hero-sf": { url: "/?city=sf" },
};

/**
 * Old names that still have to answer, pointed at what they always meant.
 *
 * Deleting them would be tidier and worse: `glyph-la` appears in the write-ups
 * that commissioned these frames, and a name that 404s sends somebody back to
 * `--click "^LA$"`, which is the exact command that produced the wrong picture.
 * An alias that says out loud what it resolved to cannot do that.
 */
const ALIASES = {
  "glyph-la": "corridor-101",
  "glyph-sf": "corridor-i5",
};
const resolved = ALIASES[name] ?? name;
if (resolved !== name) {
  console.log(
    `look: "${name}" is an alias for "${resolved}". The California board's LA and SF chapters ` +
      `are doors into the metro boards, not camera poses, so a preset named for them used to ` +
      `photograph the other board. The two chapters that stay on California are the corridor ` +
      `legs, and both open in DRIVE mode — for the aeroplane glyph's aerial stand-offs use ` +
      `glyph-board, glyph-north, glyph-bay, glyph-fidi.`,
  );
}

const preset = PRESETS[resolved] ?? {};
const flag = (f, d) => {
  const i = args.indexOf(f);
  if (i !== -1) return args[i + 1];
  return preset[f.replace(/^--/, "")] ?? d;
};
const has = (f) => args.includes(f);

const OUT = "/tmp/tera-look";
mkdirSync(OUT, { recursive: true });

/**
 * The port is asked for, not guessed, and the build is then verified.
 *
 * This used to draw a random port in 4700–4899 and start `vite preview
 * --strictPort` on it. When the draw collided with an abandoned preview from an
 * earlier run — and this box accumulated a hundred and forty-seven of them in
 * one afternoon — the new preview exited on the strict-port check, `page.goto`
 * succeeded against the squatter, and Playwright silently photographed somebody
 * else's dist. Three shots came back byte-identical while the bundle under them
 * provably changed. A screenshot tool that can photograph the wrong build is
 * worse than no screenshot tool, because you believe it.
 *
 * So: take a port from the kernel rather than from `Math.random`, then read the
 * URL the preview actually bound out of its own stdout, then fetch `/` and
 * assert it is the `dist/index.html` sitting on this disk. Any one of the three
 * would have caught it; all three cost nothing.
 */
const PORT = await new Promise((resolve, reject) => {
  const probe = createServer();
  probe.once("error", reject);
  probe.listen(0, "127.0.0.1", () => {
    const { port } = probe.address();
    probe.close(() => resolve(port));
  });
});
// `detached`, and vite's own binary rather than `npx`, because of the *other*
// half of the orphan story: `npx` is a wrapper, `server.kill()` killed only the
// wrapper, and the preview it had spawned went on holding its port forever. A
// hundred and forty-seven of them accumulated in one afternoon. Detached gives
// the pair a process group, and killing the group at the end kills both.
const server = spawn(
  new URL("../node_modules/.bin/vite", import.meta.url).pathname,
  ["preview", "--port", String(PORT), "--strictPort"],
  { detached: true, stdio: ["ignore", "pipe", "pipe"] },
);
const shutdown = () => {
  try {
    process.kill(-server.pid, "SIGTERM");
  } catch {
    /* already gone */
  }
};
process.on("exit", shutdown);
const bound = await new Promise((resolve) => {
  let seen = "";
  const settle = setTimeout(() => resolve(null), 30000);
  const read = (chunk) => {
    seen += String(chunk);
    const match = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen);
    if (match) {
      clearTimeout(settle);
      resolve(Number(match[1]));
    }
  };
  server.stdout.on("data", read);
  server.stderr.on("data", read);
});
if (bound === null) {
  console.error(`look: vite preview never announced a URL on ${PORT}; is dist/ built?`);
  shutdown();
  process.exit(1);
}
if (bound !== PORT) {
  console.error(`look: preview bound ${bound}, not ${PORT} — refusing to photograph it`);
  shutdown();
  process.exit(1);
}

// And the served page is this checkout's build, hashed script tag and all.
{
  const served = await fetch(`http://localhost:${PORT}/index.html`).then((r) => r.text());
  const onDisk = readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
  const bundle = (html) => /src="([^"]*\/assets\/[^"]+\.js)"/.exec(html)?.[1] ?? null;
  if (bundle(served) === null || bundle(served) !== bundle(onDisk)) {
    console.error(
      `look: :${PORT} is serving ${bundle(served)}, dist/ holds ${bundle(onDisk)} — ` +
        `something else owns that port. Not photographing it.`,
    );
    shutdown();
    process.exit(1);
  }
}

const phone = has("--phone");
const browser = await chromium.launch({
  channel: "chrome",
  args: [
    "--use-gl=angle",
    "--use-angle=vulkan",
    "--enable-unsafe-swiftshader",
    "--ignore-gpu-blocklist",
  ],
});

/**
 * `--reduced` asks the page for `prefers-reduced-motion: reduce`.
 *
 * Which is not only an accessibility check. The opening arrival — `arrive()` in
 * `scene.ts`, and `beginOfficeArrival` in `main.ts` — collapses to a cut under
 * this preference, so a shot taken with it is the resting frame and nothing
 * else, whatever the machine's load did to the four and a half seconds before
 * it. Without it a slow build and a fast one photograph different cameras.
 */
const context = await browser.newContext({
  viewport: phone ? { width: 390, height: 844 } : { width: 1600, height: 1000 },
  deviceScaleFactor: 2,
  timezoneId: "America/Los_Angeles",
  ...(has("--reduced") ? { reducedMotion: "reduce" } : {}),
  ...(phone
    ? {
        isMobile: true,
        hasTouch: true,
        userAgent:
          "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
          "(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
      }
    : {}),
});
await context.clock.setFixedTime(new Date(flag("--at", "2026-08-21T20:00:00Z")));

const page = await context.newPage();

/**
 * `--api http://127.0.0.1:8431` photographs the dist against a real server.
 *
 * `vite preview` serves the static build and nothing else, so without this every
 * frame is the **keyless** experience: `/api/v1/health` 404s, `access` resolves
 * with no `feeds` at all, and the weather, the aircraft, the satellites and the
 * fires are all off. That is the right default — CONTRACT §0's stranger is
 * exactly that visitor, and most frames should be shot as they see them — but it
 * makes the one question a fire board exists to answer unphotographable.
 *
 * Proxied through Playwright rather than through a Vite config, for two reasons.
 * The build is not modified, so what is photographed is byte-identical to what
 * ships; and the response is *fulfilled* rather than redirected, so the page
 * sees a same-origin answer and no CORS header has to exist on the API for a
 * screenshot to work.
 */
const api = flag("--api", "");
if (api !== "") {
  const base = api.replace(/\/+$/, "");
  await page.route("**/api/v1/**", async (route) => {
    const url = new URL(route.request().url());
    try {
      const response = await route.fetch({ url: `${base}${url.pathname}${url.search}` });
      await route.fulfill({ response });
    } catch (error) {
      // A refused upstream must look like a refused upstream, not like a hung
      // request: the app's own degraded paths are part of what is being judged.
      console.log(`look: api proxy failed for ${url.pathname} — ${error}`);
      await route.fulfill({ status: 502, body: "{}", contentType: "application/json" });
    }
  });
}

const errors = [];
page.on("console", (m) => {
  if (m.type() === "error") errors.push(m.text());
});

await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, {
  waitUntil: "networkidle",
  timeout: 60000,
});
await page.waitForTimeout(Number(flag("--wait", "10000")));

// The first-run flow covers the scene it is teaching you about.
try {
  await page.getByText(/^Skip$/).first().click({ timeout: 2500 });
  await page.waitForTimeout(1200);
} catch {
  /* already dismissed, or not shown */
}

/**
 * The chapter, by identity, before any `--click`.
 *
 * `flyTo` eases over about two seconds of scene time, so the wait after it is
 * the same 8 s a click gets. The label check is a hard failure rather than a
 * log line: a preset that silently photographs the wrong chapter is worse than
 * one that refuses, because the picture still looks deliberate.
 */
/*
 * `--lat/--lng` plants the camera at a place instead of at a chapter.
 *
 * Every other aim in this harness goes through a control a reader also uses,
 * and that stays the default: `--chapter` clicks a chapter button. But a board
 * can then only be photographed where a chapter already points, and the merged
 * "one California" board carries the state pack's six — the whole state, the
 * north, two corridors and two doors — none of which is near a city. The board
 * exists to put cities on the state, and there was no way to take a picture of
 * one. Three attempts by clicking the minimap and guessing wheel notches landed
 * in open ocean twice and on empty coast once.
 *
 * The seek moves the camera and nothing else. `--standoff` and `--height` are
 * true metres, so a pose reads the same on a 94 m board and a 1,919 m one.
 */
const seekLat = flag("--lat", null);
const seekLng = flag("--lng", null);
if (seekLat !== null && seekLng !== null) {
  const at = {
    lat: Number(seekLat),
    lng: Number(seekLng),
    standoffM: Number(flag("--standoff", "20000")),
    heightM: flag("--height", null) === null ? undefined : Number(flag("--height", "0")),
    azimuth: flag("--azimuth", null) === null ? undefined : Number(flag("--azimuth", "0")),
  };
  const placed = await page.evaluate((pose) => {
    const cam = globalThis.__teraCamera;
    if (!cam || typeof cam.seek !== "function") return null;
    return { board: cam.board, ...cam.seek(pose) };
  }, at);
  if (placed === null) {
    console.error("look: no camera hook on the page — is this a build with publishCameraHook?");
    process.exit(1);
  }
  console.log(
    `look: seek ${at.lat},${at.lng} on ${placed.board} — standoff ${at.standoffM} m`,
  );
  await page.waitForTimeout(Number(flag("--settle", "2500")));
}

const chapter = flag("--chapter", null);
if (chapter !== null && chapter !== "") {
  const expect = flag("--expect", null);
  const found = await page.evaluate((id) => {
    const button = document.querySelector(`#chapters .chapter[data-view="${id}"]`);
    if (!(button instanceof HTMLElement)) {
      return {
        ok: false,
        available: [...document.querySelectorAll("#chapters .chapter")].map((node) =>
          node.getAttribute("data-view"),
        ),
      };
    }
    const spans = [...button.querySelectorAll("span")];
    const label = (spans[spans.length - 1]?.textContent ?? "").trim();
    const index = button.getAttribute("data-view-index");
    button.click();
    return { ok: true, label, index };
  }, chapter);
  if (!found.ok) {
    console.error(
      `look: no chapter with data-view="${chapter}" on this board — it has ` +
        `${JSON.stringify(found.available)}. Not photographing a frame nobody asked for.`,
    );
    await browser.close();
    shutdown();
    process.exit(1);
  }
  if (expect !== null && found.label !== expect) {
    console.error(
      `look: chapter "${chapter}" prints "${found.label}", the preset expects "${expect}" — ` +
        `a pack was re-labelled, so this shot would be captioned wrong.`,
    );
    await browser.close();
    shutdown();
    process.exit(1);
  }
  console.log(`look: chapter ${chapter} — "${found.label}" at position ${found.index}`);
  await page.waitForTimeout(8000);
}

/**
 * `--click` may be given more than once, and they run in order.
 *
 * One click opens a door; two get you somewhere inside it. The LA office's
 * upper storey is the case that forced this: the first click opens the
 * building and the second flies to a viewpoint on the floor above, and there is
 * no single label that does both.
 */
const clicks = args.flatMap((arg, i) => (arg === "--click" ? [args[i + 1] ?? ""] : []));
if (clicks.length === 0 && typeof preset.click === "string") clicks.push(preset.click);
for (const click of clicks) {
  if (click === "") continue;
  try {
    await page.getByText(new RegExp(click, "i")).first().click({ timeout: 5000 });
    await page.waitForTimeout(8000);
  } catch {
    console.log(`look: could not click ${click}`);
  }
}

const path = `${OUT}/${name}.png`;
await page.screenshot({ path });
console.log(`look: wrote ${path}`);
// Two 404s on a static preview are the zero-config path working: see deploy/STATIC.md.
const real = errors.filter((e) => !/404|Failed to load resource/.test(e));
console.log(real.length === 0 ? "look: no console errors" : `look: ERRORS ${JSON.stringify(real)}`);

await browser.close();
shutdown();
// Explicit, because the preview's stdout pipes are inherited by a grandchild
// and Node will happily wait on them for the rest of the afternoon.
process.exit(0);
