/**
 * Re-shoot everything the site shows of this engine, in one command.
 *
 *   npm run refresh                  # build, stills, cards, films, manifests
 *   npm run refresh -- --dry-run     # what it would shoot; no browser, no build
 *   npm run refresh -- --stills-only
 *   npm run refresh -- --cards-only
 *   npm run refresh -- --films-only
 *   npm run refresh -- --rough       # films at 24 frames, for looking at
 *   npm run refresh -- --skip-build  # you just built; do not build again
 *
 * ### Why this exists
 *
 * The imagery on lumbridgecorp.com is four artefacts produced by three scripts
 * into two repos: product stills and a TypeScript manifest into `lumbridge-v4`,
 * time-lapse films and a second manifest into the same place, and two share
 * cards into this repo's `public/`. Re-shooting after an engine change was a
 * *procedure* — build first, because the scripts photograph `dist/` and not
 * `src/`; then three commands in an order that matters; then check the run
 * actually got the GPU; then commit this repo before regenerating the manifest
 * so it names a clean sha. A procedure that lives in somebody's head is a
 * procedure that gets half-run, and the evidence is in the tree: the stills were
 * re-shot at one commit, the films at another two weeks older, and nothing said
 * so until a human compared two files.
 *
 * So: one command, and the parts you can skip are flags rather than the memory
 * of which script does what.
 *
 * ### The three failures this must never report as success
 *
 * All three produce output that looks deliberate, which is why they are
 * checked here rather than left to the eye:
 *
 * 1. **SwiftShader.** `launch()` in `harness.mjs` asks for `--use-angle=vulkan`
 *    and falls back to software when the card is not reachable, printing which
 *    it got. The pictures are the same pictures — measured, half a level out of
 *    255 — but a film goes from ninety seconds to twenty-two minutes, so a run
 *    that has silently fallen back reads as a hang. Every child's output is
 *    scanned for that line and the run stops on it.
 *
 * 2. **A chapter `expect` guard firing.** Chapters are pack data and pack data
 *    gets reordered; the guard in `shots.mjs` refuses to shoot when the button at
 *    an index is not the chapter the shot list names. It has already earned its
 *    keep once, when the studios were rebuilt and chapter 0 became "Front Door".
 *    When it fires, the answer is to fix the shot list, not to re-run — so this
 *    surfaces it as its own headline rather than as one line of a stack trace
 *    fifty lines up the log.
 *
 * 3. **A `play` guard firing.** Three stills take control of a body before the
 *    shutter opens — the EV on US-101, the crow over the Bay, the aircraft on the
 *    corridor — and `enterPlay()` in `shots.mjs` refuses to shoot when the dock
 *    did not hand over the mode, when the HUD names a different body, or when the
 *    route ran out during the wait. Every one of those otherwise delivers an orbit
 *    wide shot under a filename that says an agent is driving. Same treatment as
 *    the chapter guard, and for the same reason: re-running cannot fix it.
 *
 * ### It re-stamps the provenance manifest, narrowly
 *
 * `PROVENANCE.json` records a SHA-256 for every tracked binary and
 * `npm run provenance` fails on a mismatch, so every legitimate re-shoot of the
 * share cards used to leave that gate red. This re-stamps an entry only when the
 * file is one this run wrote, its `origin` is `repository-generated`, and its
 * `generator` names a script this run actually executed. Every other mismatch is
 * reported and left alone, because a hash that moved without this pipeline
 * running is the thing the gate exists to catch.
 *
 * ### What "changed" means in the summary
 *
 * Every deliverable is hashed before and after, so the summary can say which
 * pictures actually moved rather than which commands were run. Renders are never
 * byte-identical — aircraft are crossing and cloud shadow is drifting between any
 * two runs — so "changed" here means the bytes differ, not that the framing did.
 * The size delta is the useful column: a WebP that halved or doubled is a frame
 * worth opening.
 */

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { dirname, join, relative, resolve } from "node:path";
import { readdir, readFile, writeFile } from "node:fs/promises";

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

// ---- Arguments --------------------------------------------------------------

function flag(name, fallback = null) {
  const i = process.argv.indexOf(`--${name}`);
  return i > -1 && process.argv[i + 1] && !process.argv[i + 1].startsWith("--")
    ? process.argv[i + 1]
    : fallback;
}
const has = (name) => process.argv.includes(`--${name}`);

const dryRun = has("dry-run");
const rough = has("rough");
const skipBuild = has("skip-build");
const siteDir = resolve(flag("site", join(ROOT, "..", "lumbridge-v4")));

/**
 * `--stills-only` and friends select; naming none of them selects all three.
 *
 * They compose, so `--stills-only --cards-only` is "stills and cards", which is
 * the pair you want after a lighting change and before you are ready to pay for
 * films.
 */
const picked = ["stills", "cards", "films"].filter((part) => has(`${part}-only`));
const parts = new Set(picked.length ? picked : ["stills", "cards", "films"]);

// ---- What each part writes ---------------------------------------------------

/**
 * The deliverables, by part, so the summary can be computed rather than claimed.
 *
 * `optional` covers the normal case for anyone who cloned this repo on its own:
 * there is no sibling `lumbridge-v4`, the scripts say so and skip the site step,
 * and a missing directory here is a note rather than a failure.
 */
const OUTPUTS = {
  stills: [
    { dir: join(siteDir, "apps", "web", "public", "shots"), match: /\.webp$/, optional: true },
    { dir: join(siteDir, "apps", "web", "src", "data"), match: /^shots\.ts$/, optional: true },
  ],
  cards: [{ dir: join(ROOT, "public"), match: /^og-.*\.png$/ }],
  films: [
    { dir: join(siteDir, "apps", "web", "public", "films"), match: /\.(mp4|webp|png|jpg)$/, optional: true },
    { dir: join(siteDir, "apps", "web", "src", "data"), match: /^films\.ts$/, optional: true },
  ],
};

async function fingerprint() {
  const seen = new Map();
  for (const part of parts) {
    for (const { dir, match } of OUTPUTS[part]) {
      let names;
      try {
        names = await readdir(dir);
      } catch {
        continue; // Not there yet, or no site checkout. `report` says so.
      }
      for (const name of names) {
        if (!match.test(name)) continue;
        const path = join(dir, name);
        const body = await readFile(path);
        const sha256 = createHash("sha256").update(body).digest("hex");
        seen.set(path, { size: body.length, hash: sha256.slice(0, 12), sha256 });
      }
    }
  }
  return seen;
}

// ---- Running a step ----------------------------------------------------------

/**
 * The two lines that mean "this run's output is not to be trusted".
 *
 * `launch()` prints exactly one of these per browser it opens, so matching the
 * text it prints is matching the check it already does rather than repeating it.
 */
const SOFTWARE_RENDERER = /GPU:.*(SwiftShader|llvmpipe)|no GPU \(/i;
/** `chapter()` in `shots.mjs`, and the office picker's equivalent beside it. */
const PACK_REORDERED = /a city pack was reordered|the OFFICES table\s*\n?\s*changed|is "[^"]*", not "/;
/**
 * `enterPlay()` in `shots.mjs` refusing to shoot a body it did not get.
 *
 * The third headline, and here for the same reason the second one is: three of
 * the stills are pictures of an agent driving, flying and gliding, and every way
 * that can go wrong produces a frame that still renders, still looks deliberate
 * and is of the wrong thing — an overview wide shot with `drive-101-day` written
 * on it. Re-running does not fix any of them. Either the dock stopped offering
 * the mode, or the body is not the one the caption describes, or the leg ran out
 * from under the wait.
 */
const BODY_REFUSED =
  /is not offered here|the mode dock has no|the dock did not take|the play HUD says|did not survive the wait|was already \d+% done|carries no percentage/;

class StepFailure extends Error {
  constructor(step, headline, detail) {
    super(headline);
    this.step = step;
    this.headline = headline;
    this.detail = detail;
  }
}

/**
 * Run one child, streaming its output, and read that output as it goes.
 *
 * Streaming *and* buffering, because both are wanted: a film is ninety seconds
 * of silence otherwise, and the two failures above have to be findable after the
 * fact. The buffer is a few kilobytes of console output, not the frames.
 */
function run(step, command, args, { cwd = ROOT } = {}) {
  return new Promise((resolveRun, rejectRun) => {
    const started = Date.now();
    const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
    let log = "";
    const watch = (stream, sink) => {
      stream.setEncoding("utf8");
      stream.on("data", (chunk) => {
        log += chunk;
        sink.write(chunk);
      });
    };
    watch(child.stdout, process.stdout);
    watch(child.stderr, process.stderr);
    child.on("error", (error) => rejectRun(new StepFailure(step, `could not start ${command}`, String(error))));
    child.on("close", (code) => {
      const seconds = ((Date.now() - started) / 1000).toFixed(1);
      if (SOFTWARE_RENDERER.test(log)) {
        rejectRun(
          new StepFailure(
            step,
            "the renderer came up as SwiftShader, not the card",
            "Everything this run produced is software-rendered: same pictures, roughly fifteen times\n" +
              "the wall clock, and a film that reads as a hang. Nothing has been published from it.\n" +
              "Chrome reaches the Radeon with `--use-angle=vulkan` and needs read access to\n" +
              "/dev/dri/renderD128, which group `render` grants — check `id` and `ls -l /dev/dri`,\n" +
              "then re-run. `harness.mjs` explains why no other GL flag is a substitute.",
          ),
        );
        return;
      }
      if (code !== 0 && PACK_REORDERED.test(log)) {
        rejectRun(
          new StepFailure(
            step,
            "a chapter guard fired — the shot list no longer matches the packs",
            "A shot names a chapter by index and asserts its short label before the shutter opens,\n" +
              "and the assertion failed. This is not a flake and re-running will not fix it: a pack was\n" +
              "reordered or renamed underneath the shot list, so every camera and caption downstream of\n" +
              "that index is now pointed at something else.\n" +
              "Fix `SHOTS` in shots.mjs against the pack (`node scripts/brand-assets/shots.mjs --list`\n" +
              "prints what it believes), then run this again. Do not weaken the guard.",
          ),
        );
        return;
      }
      if (code !== 0 && BODY_REFUSED.test(log)) {
        rejectRun(
          new StepFailure(
            step,
            "a play guard fired — a shot did not get the body it asked for",
            "Three stills take control of something before the shutter opens: the EV on US-101, the\n" +
              "crow over the Bay, the aircraft on the California corridor. Each asks the mode dock for a\n" +
              "mode, waits for `aria-pressed` and the play HUD, and asserts which body the HUD names.\n" +
              "One of those checks refused, and re-running will not fix it: the mode is unavailable on\n" +
              "that board, or the body changed, or the route ran out during the wait. A frame shot past\n" +
              "any of those is an orbit wide shot with the right filename.\n" +
              "`node scripts/brand-assets/shots.mjs --list` prints what each shot asks for. Do not\n" +
              "weaken the guard.",
          ),
        );
        return;
      }
      if (code !== 0) {
        rejectRun(new StepFailure(step, `${step} failed (exit ${code})`, "The child's own output is above."));
        return;
      }
      resolveRun({ seconds, log });
    });
  });
}

// ---- The plan ----------------------------------------------------------------

const NODE = process.execPath;
const script = (name) => join(HERE, name);

/**
 * The film list, read out of `films.mjs` rather than imported from it.
 *
 * `films.mjs` is a script with top-level effects — importing it opens a browser —
 * and it belongs to a different workstream, so this reads its `FILMS` ids for the
 * dry run and treats a miss as "cannot say" rather than as an error. The command
 * printed underneath is the truth either way.
 */
async function filmIds() {
  try {
    const body = await readFile(script("films.mjs"), "utf8");
    const from = body.indexOf("const FILMS = [");
    if (from === -1) return null;
    const ids = [...body.slice(from).matchAll(/^\s{4}id: "([^"]+)"/gm)].map((m) => m[1]);
    return ids.length ? ids : null;
  } catch {
    return null;
  }
}

async function plan() {
  console.log(`refresh  ${[...parts].join(", ")}${rough ? "  (films at 24 frames)" : ""}`);
  console.log(`site     ${siteDir}`);
  console.log("");
  if (parts.has("stills") || parts.has("cards")) {
    await run("list", NODE, [script("shots.mjs"), "--list"]);
    console.log("");
  }
  if (parts.has("cards")) {
    console.log("cards   2 renders of the running app, then og.html over each at 1200x630");
    console.log("          og-tera.png     tera.lumbridgecorp.com");
    console.log("          og-office.png   office.lumbridgecorp.com");
    console.log("");
  }
  if (parts.has("films")) {
    const ids = await filmIds();
    console.log(`films   ${ids ? `${ids.length} reels: ${ids.join(", ")}` : "reel list is films.mjs's own"}`);
    console.log(`          ${NODE} ${relative(ROOT, script("films.mjs"))}${rough ? " --frames 24" : ""}`);
    console.log("");
  }
  console.log("nothing was rendered — this was --dry-run");
}

// ---- Provenance --------------------------------------------------------------

/**
 * Re-stamp the SHA-256 of the artifacts this run generated, and nothing else.
 *
 * `PROVENANCE.json` records a hash for every tracked binary, and
 * `npm run provenance` fails when one does not match. That is the right gate —
 * it is what stops an art file arriving from somewhere nobody can name — but the
 * two share cards are *outputs of this pipeline*, so every legitimate re-shoot
 * left the gate red and a human had to work out which of two hashes went in
 * which of two records. That is precisely the remembered procedure this script
 * exists to delete.
 *
 * The rule is deliberately narrow, so this stays bookkeeping rather than a hole
 * in the gate. An entry is re-stamped only when **all** of these hold:
 *
 *   - the file is one this run wrote, in a directory this run owns;
 *   - `origin` is `repository-generated` — never a copied upstream item;
 *   - `generator` names a script this run actually executed.
 *
 * Anything else that has drifted is reported and left exactly as it was, because
 * a hash that changed without this pipeline running is the thing the gate is for.
 */
async function restampProvenance(written, ranGenerators) {
  const path = join(ROOT, "PROVENANCE.json");
  let body;
  let manifest;
  try {
    body = await readFile(path, "utf8");
    manifest = JSON.parse(body);
  } catch (error) {
    console.log(`\nprovenance  could not be read (${error.message}) — left alone`);
    return;
  }
  const stamped = [];
  const refused = [];
  for (const entry of manifest.distributedArtifacts ?? []) {
    const full = join(ROOT, entry.path);
    const now = written.get(full);
    if (!now || now.sha256 === entry.sha256) continue;
    const mine =
      entry.origin === "repository-generated" &&
      ranGenerators.some((generator) => entry.generator === generator);
    if (!mine) {
      refused.push(entry.path);
      continue;
    }
    /*
     * Substitute the hash in the *text*, not by re-serialising the object.
     * `JSON.stringify(…, null, 2)` reflows every array in the file, so a
     * two-hash update arrived as a fourteen-line diff across records this run
     * had nothing to do with — which is the last thing a licensing manifest
     * should do. A SHA-256 is 64 hex characters and unique in this file, so a
     * literal replacement is exact.
     */
    if (!body.includes(entry.sha256)) {
      refused.push(`${entry.path} (recorded hash not found verbatim)`);
      continue;
    }
    body = body.replace(entry.sha256, now.sha256);
    stamped.push(entry.path);
  }
  if (stamped.length) {
    await writeFile(path, body, "utf8");
    console.log(`\nprovenance  re-stamped ${stamped.length}: ${stamped.join(", ")}`);
  }
  if (refused.length) {
    console.log(
      `\nprovenance  ${refused.length} artifact(s) no longer match their recorded hash and were NOT` +
        ` touched:\n  ${refused.join("\n  ")}\n` +
        "  Nothing this run generated wrote them. Find out what did before committing.",
    );
  }
}

// ---- The summary -------------------------------------------------------------

function report(before, after, timings) {
  const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
  const added = [];
  const changed = [];
  const same = [];
  const gone = [];
  for (const path of paths) {
    const was = before.get(path);
    const now = after.get(path);
    if (!was) added.push({ path, now });
    else if (!now) gone.push({ path, was });
    else if (was.hash !== now.hash) changed.push({ path, was, now });
    else same.push({ path });
  }

  const kb = (n) => `${Math.round(n / 1024)}kB`;
  const show = (entries, label, line) => {
    if (!entries.length) return;
    console.log(`\n${label} (${entries.length})`);
    for (const entry of entries) console.log(`  ${line(entry)}`);
  };

  console.log("\n" + "─".repeat(72));
  for (const [step, seconds] of timings) console.log(`${step.padEnd(10)} ${seconds}s`);
  show(added, "new", ({ path, now }) => `${relative(ROOT, path).padEnd(58)} ${kb(now.size)}`);
  show(
    changed,
    "changed",
    ({ path, was, now }) => {
      const delta = now.size - was.size;
      const sign = delta > 0 ? "+" : "";
      return `${relative(ROOT, path).padEnd(58)} ${kb(now.size)} (${sign}${kb(delta)})`;
    },
  );
  show(gone, "no longer written", ({ path }) => relative(ROOT, path));
  if (same.length) console.log(`\nbyte-identical (${same.length}) — nothing about these moved`);
  if (!added.length && !changed.length) {
    console.log("\nnothing changed. If you expected a change, check that `npm run build` ran:");
    console.log("these scripts photograph dist/, not src/, and --skip-build is a way to shoot a stale one.");
  }
  console.log("");
  console.log("Commit tera first, then re-run the manifests so they name a clean sha:");
  console.log("  node scripts/brand-assets/shots.mjs --manifest-only");
  console.log("  node scripts/brand-assets/films.mjs --manifest-only");
  console.log("Then commit lumbridge-v4 and deploy it.");
}

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

if (dryRun) {
  await plan();
  process.exit(0);
}

const before = await fingerprint();
const timings = [];
try {
  if (!skipBuild) {
    // First, and not optional without saying so: every script below photographs
    // `dist/`, so skipping the build is how a "re-shoot" publishes the old engine.
    const built = await run("build", "npm", ["run", "build"]);
    timings.push(["build", built.seconds]);
  } else {
    console.log("build    skipped (--skip-build) — the stills, cards and films will photograph the dist/ already on disk\n");
  }

  if (parts.has("stills")) {
    const done = await run("stills", NODE, [script("shots.mjs"), "--site", siteDir]);
    timings.push(["stills", done.seconds]);
  }
  if (parts.has("cards")) {
    const done = await run("cards", NODE, [script("capture.mjs")]);
    timings.push(["cards", done.seconds]);
  }
  if (parts.has("films")) {
    const args = [script("films.mjs"), "--site", siteDir];
    if (rough) args.push("--frames", "24");
    const done = await run("films", NODE, args);
    timings.push(["films", done.seconds]);
  }
} catch (error) {
  if (error instanceof StepFailure) {
    console.error("\n" + "━".repeat(72));
    console.error(`FAILED at ${error.step}: ${error.headline}`);
    console.error("━".repeat(72));
    console.error(error.detail);
    console.error("");
    process.exit(1);
  }
  throw error;
}

const after = await fingerprint();
report(before, after, timings);
await restampProvenance(
  after,
  timings.map(([step]) => step).flatMap((step) =>
    step === "cards"
      ? ["scripts/brand-assets/capture.mjs"]
      : step === "stills"
        ? ["scripts/brand-assets/shots.mjs"]
        : step === "films"
          ? ["scripts/brand-assets/films.mjs"]
          : [],
  ),
);
