/**
 * Regenerate the share cards.
 *
 *   node scripts/brand-assets/capture.mjs
 *
 * Two passes, because the cards are backed by the running app rather than by a
 * drawing of it. First it shoots the city and the office out of a built `dist/`;
 * then it renders `og.html` over those shots at exactly 1200x630 and writes the
 * two PNGs into `public/`, from where Vite copies them verbatim.
 *
 * Rasterising with headless Chromium rather than a converter is the convention
 * `lumbridge-v4/scripts/brand-assets/README.md` already set on this box, for the
 * reason it gives: there is no ImageMagick, no `rsvg-convert` and no `sharp`
 * here, and a browser renders the CSS the card was designed in anyway.
 *
 * ### Why the art is a screenshot and not an illustration
 *
 * Because the thing is worth looking at, and because an illustration of it goes
 * stale silently. The card that shipped on lumbridgecorp.com was a viewport
 * screenshot of a marketing page that had since been rewritten, so the preview
 * advertised a positioning the site no longer used and nothing noticed for a
 * month. A card regenerated from `dist/` by one command is a card that can be
 * kept true by running that command.
 */

import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { serve, launch, clockShim, FURNITURE, hide } from "./harness.mjs";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..", "..");
const PUBLIC = join(ROOT, "public");

/**
 * The hour is chosen per card, and it is not midday.
 *
 * The sun is real — `observe()` computes it from `new Date()` — so a card
 * regenerated at two in the morning is an honest photograph of a black
 * rectangle. The clock is shifted rather than frozen because the app drives
 * everything else off `requestAnimationFrame`, and a stopped clock stalls the
 * frame loop the screenshot is waiting on.
 *
 * Both cards used to be shot at 12:40, which is the worst light a heightfield
 * ever gets: the sun is behind the camera, nothing casts, and the state board
 * reads as one flat sheet of sand. Late afternoon rakes the Sierra and the Basin
 * and Range and separates the Central Valley from both. The office keeps a
 * late-morning sun because an interior wants light coming *through* the glazing,
 * and at 17:30 an office lit from one low angle is half a photograph of a wall.
 */
const AFTERNOON = "2026-08-06T17:30:00-07:00";
const LATE_MORNING = "2026-08-06T11:20:00-07:00";

/**
 * Wait for the app rather than for a clock.
 *
 * `#boot` hidden **and** `#chapters` populated. Terrain is built in a worker and
 * the Bay is ~83k instances; how long that takes depends on the machine and what
 * else is running, so the twenty-second sleep this replaced was either wrong or
 * wasteful and was usually both.
 */
async function ready(page) {
  await page.waitForFunction(
    () =>
      document.getElementById("boot")?.hidden === true &&
      document.querySelectorAll("#chapters .chapter").length > 0,
    undefined,
    { timeout: 180_000 },
  );
}

/**
 * Fly to a chapter by index, having checked it is the chapter we mean.
 *
 * The card used to get here with `keyboard.press("2")`, which is an unguarded
 * index into pack data: when the default board became California, "2" stopped
 * being a city and became the US-101 corridor in drive mode, and the card that
 * shipped for a fortnight was a chase camera on a freeway running through
 * kilometre-wide buildings under a headline that reads "Cities from above."
 * Nothing failed. It rendered, and it looked deliberate.
 *
 * So this asserts the short label the same way `shots.mjs` does, and for the same
 * reason: a reordered pack must stop the run, not requantify the marketing.
 */
async function flyTo(page, index, expect) {
  const found = await page.evaluate((i) => {
    const button = [...document.querySelectorAll("#chapters .chapter")][i];
    if (!button) return null;
    button.click();
    const spans = [...button.querySelectorAll("span")];
    const label = spans.length > 1 ? spans[spans.length - 1] : null;
    return { id: button.getAttribute("data-view"), label: (label?.textContent ?? "").trim() };
  }, index);
  if (found === null) throw new Error(`no chapter at index ${index} — the card cannot be framed`);
  if (found.label !== expect) {
    throw new Error(
      `chapter ${index} is "${found.label}" (${found.id ?? "no id"}), not "${expect}" — ` +
        `a city pack was reordered, so the share card would be a picture of somewhere else`,
    );
  }
  await page.waitForTimeout(2500);
}

/**
 * The default sensor. 2x, so the art is still sharp when a timeline shows the
 * card at 600px wide on a retina screen.
 *
 * A card is 1200x630 and the art is `cover`-cropped into it, so the *aspect* of
 * this frame decides which axis gets cropped and therefore which axis
 * `background-position` can move the subject along. At 1400x900 — aspect 1.56
 * against the card's 1.90 — the crop is entirely vertical, the horizontal
 * position does nothing at all, and a subject centred in the app frame is
 * centred under the headline no matter what the CSS asks for. The state board is
 * shot on a wider sensor for exactly that reason; `three.js` holds the *vertical*
 * field of view, so a wider frame is more world either side and the board comes
 * out the same height.
 */
const SENSOR = { width: 1400, height: 900 };

async function shootApp(
  browser,
  url,
  file,
  { at, chapter = null, expect = null, settle = 6000, viewport = SENSOR } = {},
) {
  const page = await browser.newPage({
    viewport,
    deviceScaleFactor: 2,
    /**
     * Without this the card was framed differently every run, and had been
     * since the day it was written.
     *
     * `scenekit.ts` eases a chapter change over about two seconds of scene
     * time and `stage.ts` clamps `dt` to 50 ms a frame — so a flight is about
     * forty frames however fast they are drawn, and the four seconds this
     * waited caught the camera partway across the bay at a different point
     * each time. Two consecutive captures of an unchanged repo produced two
     * different cards, neither of them the chapter the key press had asked
     * for. It was worst under software GL, where forty frames took twenty
     * seconds; on the GPU it is a shorter race and still a race.
     *
     * Reduced motion is the app's own answer for "somebody clicked a name in a
     * list": `flyTo` sets the pose outright, so the shutter opens on the pose
     * the shot names.
     *
     * The *framing* is what this fixes, not the bytes. Aircraft are still
     * crossing and cloud shadow is still drifting, so two runs differ by a few
     * pixels and `git diff` will always show the PNG as changed. That is the
     * app being alive, and worth far less than the framing being on purpose.
     */
    reducedMotion: "reduce",
  });
  await page.addInitScript(clockShim(at));
  await page.goto(url, { waitUntil: "networkidle" });
  await ready(page);
  // Boot hiding means the scene exists, not that it has drawn a full frame.
  await page.waitForTimeout(settle);
  if (chapter !== null) await flyTo(page, chapter, expect);
  // The chrome comes off. The card supplies its own typography, and the app's
  // panels shrunk to card size are unreadable furniture. A product screenshot
  // wants the opposite — see `shots.mjs`, which keeps the instruments.
  await hide(page, [...FURNITURE.transient, ...FURNITURE.BARE]);
  await page.screenshot({ path: join(HERE, file), timeout: 120_000, animations: "disabled" });
  console.log("art   ", file);
  await page.close();
}

async function renderCard(browser, which, out) {
  const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
  await page.goto(`http://127.0.0.1:8799/og.html?card=${which}`, { waitUntil: "networkidle" });
  // The art is a background image, so `networkidle` is not proof it has decoded.
  await page.evaluate(() => document.fonts.ready);
  await page.waitForTimeout(1200);
  await page.screenshot({ path: join(PUBLIC, out) });
  console.log("card  ", out);
  await page.close();
}

/**
 * `--cards-only` re-renders the two PNGs from art already on disk.
 *
 * The app pass shoots the running city and the card pass is two seconds, and
 * every iteration on a headline needs only the second. Without the flag, tuning
 * a line of copy costs a whole app pass each time, which is how a card ends up
 * shipped with the first wording anybody tried.
 */
const cardsOnly = process.argv.includes("--cards-only");

const app = cardsOnly ? null : await serve(join(ROOT, "dist"), 5210, { spa: true });
const assets = await serve(HERE, 8799);
const browser = await launch();
try {
  if (!cardsOnly) {
    await shootApp(browser, "http://office.lumbridgecorp.com:5210/", "art-office.png", {
      at: LATE_MORNING,
    });
    /*
     * The state board, and the chapter is asserted.
     *
     * `og:image:alt` on the tera door already promises "California rendered from
     * above, with Los Angeles and San Francisco joined by the US-101 and I-5
     * corridors", and the headline on the card says "Cities from above." This is
     * the frame both of those sentences describe. Chapter 0 is where the board
     * opens, so no flight is needed — but the label is checked anyway, because
     * "the chapter I did not click" is exactly as reorderable as the one I did.
     */
    await shootApp(browser, "http://tera.lumbridgecorp.com:5210/?city=california&handover=0", "art-tera.png", {
      at: AFTERNOON,
      chapter: 0,
      expect: "State",
      // Wide, so the whole state survives the crop: see `SENSOR`.
      viewport: { width: 2000, height: 900 },
    });
  }
  await renderCard(browser, "tera", "og-tera.png");
  await renderCard(browser, "office", "og-office.png");
} finally {
  await browser.close();
  app?.close();
  assets.close();
}
