#!/usr/bin/env node
/**
 * What a board switch actually costs, in the four numbers that decide whether
 * retaining a board was worth it.
 *
 *     node scripts/switch-cost.mjs                    # all six directions, once each
 *     node scripts/switch-cost.mjs --repeat 3         # median of three per direction
 *     node scripts/switch-cost.mjs --only california->sf
 *     node scripts/switch-cost.mjs --json /tmp/switch-cost.json
 *     node scripts/switch-cost.mjs --dist /some/other/dist
 *
 * ## Why this exists, and why the frame timer could not answer it
 *
 * `performance-budget.mjs` measures a *settled* board: it waits `warmup-ms`,
 * throws away everything before that, and samples a stationary camera. That is
 * the right instrument for "is this board affordable" and it is structurally
 * incapable of seeing a board *change*, because the change happens entirely
 * inside the window it discards. So the most-complained-about moment in the
 * product — the pause between one board and the next — was the one moment
 * nothing measured. Every claim about it was a feeling.
 *
 * It is also the moment where this box's frame timer lies worst. The GPU here
 * never leaves 500 MHz of a possible 2,725, so p95 frame interval flips between
 * 16.8 and 33.3 ms on fragment changes with geometry identical to the digit.
 * None of the four numbers below is a frame time. They are wall-clock cover,
 * main-thread block, the single worst task, and how many frames the page
 * managed to paint — and every one of them is a count or a duration that a
 * throttled GPU does not move.
 *
 * ## The numbers
 *
 * **coveredMs** — from the gesture to the end of the switch, where the end is
 * whichever comes last: the destination board's chapters appearing, or the boot
 * card coming off. Today the card is last, because `building()` in `main.ts`
 * adds `.done` and hides the card 300 ms later; `opaqueMs` is reported beside it
 * as click-to-`.done`, the part that is fully opaque.
 *
 * **arrivalMs and card** — when the destination was on screen, and whether a
 * boot card was raised at all. These two are the *shape* of the switch rather
 * than its length, and they are the pair that tells you which world you are in:
 * `card=yes` with `arrival ≈ covered - 300` is the disposal model, and `card=no`
 * with `blank` near zero is retention. Arrival is detected by watching
 * `#chapters` for the destination's signature chapter — not by the card, which
 * retention removes, and not by the board tab, which the continuity work
 * removes.
 *
 * **blockedMs** — the sum of Long Tasks inside that window. This is the part
 * that is genuinely frozen: no input, no animation, no clock. Everything else
 * in `coveredMs` is a live, animating page that simply has nothing to draw.
 * The gap between the two is the prize: it is the fraction of the pause that
 * exists only because the outgoing board was disposed before the incoming one
 * was built, and retention deletes it without a single new triangle.
 *
 * **longestTaskMs** — the worst single task. Sums are consoling and a 512 ms
 * task is what a hitch feels like, so the maximum is reported separately and is
 * the number to watch after retention lands: today it hides inside a full-screen
 * card with a percentage on it, which reads as *busy*; behind a live picture the
 * same half second reads as a *hang*.
 *
 * **frames / blankFrames** — frames the page painted during the switch, and how
 * many of them issued zero WebGL draw calls. A blank frame is the compositor
 * doing its job over a scene that no longer exists. Two thirds blank is the
 * disposal model — 46 of 69 on the baseline below — and that ratio collapsing
 * toward zero is retention working, measured rather than felt.
 *
 * ## The baseline, measured
 *
 * `bcac6aa`, before any retention work, desktop 1440x900, Chrome/ANGLE/Vulkan on
 * a Radeon RX 6700 XT, `--repeat 3` and the median of each column:
 *
 * | direction | covered | opaque | blocked | longest | frames | blank | live |
 * |---|---|---|---|---|---|---|---|
 * | california->sf | 1,715 | 1,415 | 608 | 526 | 69 | 46 | 65% |
 * | socal->sf | 1,627 | 1,326 | 523 | 459 | 69 | 46 | 68% |
 * | california->socal | 1,109 | 809 | 400 | 312 | 45 | 22 | 64% |
 * | sf->socal | 1,027 | 726 | 328 | 245 | 44 | 21 | 68% |
 * | sf->california | 1,120 | 820 | 340 | 196 | 49 | 26 | 70% |
 * | socal->california | 1,061 | 761 | 319 | 205 | 47 | 24 | 70% |
 *
 * Two things to read off it before changing anything.
 *
 * **Cost is a property of the destination, not of the pair.** The two arrivals
 * at the Bay Area differ by 5% and the two at California by 6%, while Bay Area
 * against California is 1.6x. Nothing is being reused between boards today, and
 * this table is what will show that changing.
 *
 * **Roughly two thirds of every pause is not work.** 64-70% of the cover is a
 * live, animating page with an empty world in it, because the outgoing board was
 * disposed before the incoming one was built. That is the fraction retention
 * gets back without a single new triangle — and the residual, the 526 ms task on
 * an arrival at the Bay Area, is a separate and nameable defect (main-thread
 * instance placement for 83,137 buildings) that retention does not touch and
 * will make *more* visible, because today it hides behind a full-screen card
 * with a percentage on it and afterwards it will freeze a live picture.
 *
 * Reproduce it before trusting a change, on the same box, with nothing else
 * competing for the GPU. This is a wall-clock instrument on a shared machine:
 * treat 5% as noise and 2x as a finding.
 *
 * ## GPU flags
 *
 * Same ladder as `performance-budget.mjs` and `ui-smoke.mjs`, for the same
 * reason: this box has an AMD card and no monitor, `--use-angle=vulkan` is what
 * makes Chrome render headlessly on it at all, and the SwiftShader rung behind
 * it keeps the script runnable on a CI box with no card. A software fall-back is
 * reported in the output, because switch cost measured on SwiftShader is a
 * different quantity and must not be compared to the table above.
 */

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 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}`);

// A six-direction sweep is ten minutes of browser. `--help` must not start one:
// this file was asked for its usage once and answered by launching Chrome.
if (has("help") || args.includes("-h")) {
  console.log(
    [
      "node scripts/switch-cost.mjs [options]",
      "",
      "  --dist <path>        build to measure (default ./dist)",
      "  --repeat <n>         takes per direction, median reported (default 1)",
      "  --only <a->b>        one direction, e.g. --only california->sf",
      "  --viewport <name>    desktop | mobile (default desktop)",
      "  --settle-ms <ms>     wait on the first board before switching (default 2500)",
      "  --timeout-ms <ms>    per-wait timeout (default 90000)",
      "  --json <path>        write the full result, samples included",
      "  --software           SwiftShader only; NOT comparable to the GPU baseline",
      "  --headed             watch it happen",
      "",
      "Reports, per direction: how long the switch covered the screen, when the",
      "destination arrived, whether a boot card was raised at all, blocked",
      "main-thread milliseconds, the longest single task, and how many painted",
      "frames issued zero WebGL draw calls. See the header for the baseline.",
    ].join("\n"),
  );
  process.exit(0);
}

const DIST = resolve(option("dist", join(ROOT, "dist")));
const REPEAT = Math.max(1, Number(option("repeat", "1")) || 1);
const SETTLE_MS = Number(option("settle-ms", "2500")) || 2500;
const READY_TIMEOUT_MS = Number(option("timeout-ms", "90000")) || 90_000;
const ONLY = option("only", null);
const JSON_OUT = option("json", null);
const softwareOnly = has("software");
const headed = has("headed");

/**
 * The three boards, and every ordered pair of them.
 *
 * Six directions rather than three, because the cost is not symmetric and the
 * asymmetry is the interesting part: leaving the Bay Area is cheap and arriving
 * at it is not, so a table that averaged the two would hide the only direction
 * anybody complains about. `id` is the `?city=` value, which is also what the
 * board tab and the places rung both carry.
 */
const BOARDS = [
  /*
   * `signature` is one chapter `data-view` that exists on this board and on no
   * other, and it is how arrival is detected.
   *
   * Not the pressed board tab, which is what this file checked first and which
   * stopped existing within the hour when the continuity work removed the tab
   * strip. Not the boot card either: retention's whole point is that a switch
   * stops raising one, so an instrument that timed "card up to card down" would
   * report nothing at all on the build it exists to measure. A chapter id is
   * pack data, it is what `scripts/fixtures/chapter-identity.json` pins, and the
   * moment it appears in `#chapters` is the moment the destination board is on
   * screen. `all` is shared by both metro boards, which is why neither is
   * identified by its first chapter.
   */
  { id: "california", label: "California", signature: "california-overview" },
  { id: "sf", label: "the Bay Area", signature: "hayes-valley" },
  { id: "socal", label: "SoCal", signature: "dtla" },
];
const DIRECTIONS = BOARDS.flatMap((from) =>
  BOARDS.filter((to) => to.id !== from.id).map((to) => ({ from, to })),
);

const VIEWPORTS = {
  desktop: { width: 1440, height: 900, deviceScaleFactor: 1 },
  mobile: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
};
const VIEWPORT_NAME = option("viewport", "desktop");
const VIEWPORT = VIEWPORTS[VIEWPORT_NAME];
if (!VIEWPORT) throw new Error(`--viewport must be one of ${Object.keys(VIEWPORTS).join(", ")}`);

// ---- The deployment: the built bundle and a server that says nothing -------
//
// Every optional source off, exactly as `ui-smoke.mjs` serves it. A switch that
// waits on a weather fetch is measuring somebody's uplink, and CONTRACT §0's
// visitor has no key anyway.

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 };
}

// ---- Chrome ---------------------------------------------------------------

const COMMON_ARGS = ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-gpu-blocklist"];

async function rendererOf(browser) {
  const page = await browser.newPage();
  try {
    await page.goto("about:blank");
    return await page.evaluate(() => {
      const gl = document.createElement("canvas").getContext("webgl2");
      const extension = gl?.getExtension("WEBGL_debug_renderer_info");
      return extension ? String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL)) : null;
    });
  } finally {
    await page.close();
  }
}

async function launch() {
  const resolver = "--host-resolver-rules=MAP tera.lumbridgecorp.com 127.0.0.1";
  const ladder = softwareOnly
    ? [["swiftshader", ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"]]]
    : [
        ["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",
        headless: !headed,
        args: [...COMMON_ARGS, resolver, ...flags],
      });
      const renderer = await rendererOf(browser);
      const software = renderer !== null && /SwiftShader|llvmpipe/i.test(renderer);
      if (renderer !== null && (backend === "swiftshader" || !software)) {
        return { browser, backend, renderer, software };
      }
      await browser.close();
    } catch (error) {
      last = error;
    }
  }
  throw new Error(`Chrome launch failed: ${last instanceof Error ? last.message : String(last)}`);
}

/**
 * Everything measured is measured in the page, before any of the page's own
 * script runs.
 *
 * Four instruments, and none of them is a frame time:
 *
 * - **Draw calls per painted frame.** The same four `drawArrays`/`drawElements`
 *   entry points `performance-budget.mjs` patches, counted per animation frame.
 *   A frame with zero of them is a frame with no world in it.
 * - **Long Tasks.** `buffered: true`, so a task that started before the observer
 *   attached is still seen.
 * - **The boot card.** A `MutationObserver` on `#boot`'s `hidden` and `class`,
 *   which is how `building()` covers and uncovers the screen. Observed rather
 *   than polled, because a poll inside a 512 ms task returns after it and would
 *   report the card as having gone away early.
 * - **A mark for the click**, written in the same task as the click itself, so
 *   the window starts at the gesture rather than at Playwright's round trip.
 *
 * The observer has to wait for `#boot` to exist: this runs at document-start and
 * the element is in `index.html`, so a `readystatechange` hook is enough and
 * costs nothing on a page that is already parsed.
 */
function instrumentation() {
  const state = {
    frames: [],
    longTasks: [],
    boot: [],
    chapters: [],
    clickAt: null,
    calls: 0,
    longTaskSupported: false,
  };
  Object.defineProperty(globalThis, "__teraSwitchCost", { value: state });

  const patch = (prototype, method) => {
    if (!prototype || typeof prototype[method] !== "function") return;
    const original = prototype[method];
    if (original.__teraSwitchCostPatched) return;
    const wrapped = function (...values) {
      state.calls += 1;
      return original.apply(this, values);
    };
    Object.defineProperty(wrapped, "__teraSwitchCostPatched", { value: true });
    prototype[method] = wrapped;
  };
  for (const prototype of [
    globalThis.WebGLRenderingContext?.prototype,
    globalThis.WebGL2RenderingContext?.prototype,
  ]) {
    for (const method of ["drawArrays", "drawElements", "drawArraysInstanced", "drawElementsInstanced"]) {
      patch(prototype, method);
    }
  }

  try {
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        state.longTasks.push({ startTime: entry.startTime, duration: entry.duration });
      }
    }).observe({ type: "longtask", buffered: true });
    state.longTaskSupported = true;
  } catch {
    /* Long Tasks API is optional. */
  }

  requestAnimationFrame(function sample(now) {
    state.frames.push({ at: now, calls: state.calls });
    state.calls = 0;
    requestAnimationFrame(sample);
  });

  const watchBoot = () => {
    const card = document.getElementById("boot");
    if (card === null) return false;
    const snapshot = () =>
      state.boot.push({
        at: performance.now(),
        hidden: card.hidden === true,
        done: card.classList.contains("done"),
      });
    snapshot();
    new MutationObserver(snapshot).observe(card, {
      attributes: true,
      attributeFilter: ["hidden", "class"],
    });
    return true;
  };
  if (!watchBoot()) document.addEventListener("readystatechange", watchBoot, { once: false });

  /*
   * When the destination board arrived, timestamped in the page.
   *
   * `#chapters` is replaced wholesale when a board mounts (`mount.ts` rebuilds
   * it from a signature over the view list), so a `childList` observer on it
   * fires once, at the moment the new board's chapters exist. That instant is
   * "the new board is on screen" — independent of the boot card, which retention
   * removes, and independent of the board tab strip, which the continuity work
   * removes. Observed rather than polled: a poll inside a 512 ms task returns
   * after the task and would credit the switch with time it did not take.
   */
  const watchChapters = () => {
    const nav = document.getElementById("chapters");
    if (nav === null) return false;
    const snapshot = () =>
      state.chapters.push({
        at: performance.now(),
        views: [...nav.querySelectorAll(".chapter")].map((node) => node.getAttribute("data-view")),
      });
    snapshot();
    new MutationObserver(snapshot).observe(nav, { childList: true, subtree: true });
    return true;
  };
  if (!watchChapters()) document.addEventListener("readystatechange", watchChapters, { once: false });
}

const percentile = (values, p) => {
  if (values.length === 0) return null;
  const sorted = [...values].sort((a, b) => a - b);
  return sorted[Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)))];
};
const median = (values) => percentile(values, 0.5);
const round = (value) => (value === null || value === undefined ? null : Math.round(value));

/**
 * One switch, from a settled board to another board.
 *
 * A fresh page per sample, deliberately. A second visit to a board reuses
 * nothing today — `three` refcounts shader programs per material and deletes
 * them at zero, so disposing a board deletes its programs and coming back
 * relinks them — but that is a fact about today's engine, not a rule, and the
 * whole point of this file is to be able to see it change. A shared page would
 * bake the current answer into the instrument.
 */
async function measureSwitch(browser, port, from, to) {
  const context = await browser.newContext({
    viewport: { width: VIEWPORT.width, height: VIEWPORT.height },
    deviceScaleFactor: VIEWPORT.deviceScaleFactor,
    isMobile: VIEWPORT.isMobile,
    hasTouch: VIEWPORT.hasTouch,
  });
  const page = await context.newPage();
  const consoleErrors = [];
  page.on("pageerror", (error) => consoleErrors.push(String(error)));
  page.on("console", (message) => {
    if (message.type() !== "error") return;
    const at = message.location()?.url ?? "";
    if (/\/api\/v1\//.test(at)) return;
    consoleErrors.push(message.text());
  });
  await page.addInitScript(instrumentation);

  try {
    await page.goto(`http://tera.lumbridgecorp.com:${port}/?city=${from.id}`, {
      waitUntil: "networkidle",
      timeout: READY_TIMEOUT_MS,
    });
    const ready = () =>
      document.getElementById("boot")?.hidden === true &&
      document.querySelectorAll("#chapters .chapter").length > 0;
    await page.waitForFunction(ready, null, { timeout: READY_TIMEOUT_MS });
    await page.waitForTimeout(SETTLE_MS);

    // The onboarding coach covers the board it is teaching you about, and a
    // click that lands on its scrim is not a board switch.
    await page
      .getByText(/^Skip$/)
      .first()
      .click({ timeout: 1500 })
      .then(() => page.waitForTimeout(600))
      .catch(() => undefined);

    /*
     * The gesture, by whatever affordance this build offers for it.
     *
     * `[data-board]` is the three-tab board strip. `[data-place-board]` is a rung
     * of the places list, which carries the board it belongs to. The continuity
     * work replaces the first with the second — the tab strip is the loudest
     * "you are somewhere else now" signal on the screen and it is being removed —
     * so an instrument that only knew about tabs would stop working on exactly
     * the change it exists to measure. Which one was used is reported, because
     * they are not the same gesture: a tab selects a board and a rung selects a
     * board *and a pose on it*.
     */
    const clicked = await page.evaluate((boardId) => {
      const state = globalThis.__teraSwitchCost;
      state.frames.length = 0;
      state.longTasks.length = 0;
      state.boot.length = 0;
      state.chapters.length = 0;
      state.calls = 0;
      const tab = document.querySelector(`[data-board="${boardId}"]`);
      const rung = document.querySelector(`[data-place-board="${boardId}"]`);
      const button = tab instanceof HTMLElement ? tab : rung instanceof HTMLElement ? rung : null;
      if (button === null) return null;
      state.clickAt = performance.now();
      button.click();
      return { at: state.clickAt, via: tab instanceof HTMLElement ? "board-tab" : "place-rung" };
    }, to.id);
    if (clicked === null) {
      throw new Error(
        `nothing on screen selects "${to.id}" — no [data-board="${to.id}"] and no ` +
          `[data-place-board="${to.id}"]. If the affordance moved again, teach this file about it.`,
      );
    }

    /*
     * Wait for the destination, not for the cover.
     *
     * The signature chapter on screen and the boot card down. Both conditions,
     * because today the card outlives the arrival by 300 ms and after retention
     * there may be no card at all — and a wait written against the card alone
     * would hang forever on the build this file exists to measure. Everything
     * timed is timed by the in-page observers; this wait only decides when it is
     * safe to read them.
     */
    await page.waitForFunction(
      (signature) =>
        document.querySelector(`#chapters .chapter[data-view="${signature}"]`) !== null &&
        document.getElementById("boot")?.hidden === true,
      to.signature,
      { timeout: READY_TIMEOUT_MS },
    );

    const raw = await page.evaluate((signature) => {
      const state = globalThis.__teraSwitchCost;
      return {
        clickAt: state.clickAt,
        frames: state.frames.map((frame) => ({ at: frame.at, calls: frame.calls })),
        longTasks: state.longTasks.map((task) => ({ ...task })),
        boot: state.boot.map((entry) => ({ ...entry })),
        chapterEvents: state.chapters.map((entry) => ({
          at: entry.at,
          arrived: entry.views.includes(signature),
        })),
        longTaskSupported: state.longTaskSupported,
        views: [...document.querySelectorAll("#chapters .chapter")].map((node) =>
          node.getAttribute("data-view"),
        ),
      };
    }, to.signature);

    const start = raw.clickAt;
    const raised = raw.boot.find((entry) => entry.at >= start && !entry.hidden) ?? null;
    const done = raw.boot.find((entry) => entry.at >= start && entry.done) ?? null;
    const cleared =
      raised === null
        ? null
        : (raw.boot.find((entry) => entry.at > raised.at && entry.hidden) ?? null);
    const arrived = raw.chapterEvents.find((entry) => entry.at >= start && entry.arrived) ?? null;
    /*
     * The window runs from the gesture to whichever happened last: the
     * destination's chapters appearing, or the cover coming off.
     *
     * Today the cover is last, because `building()` fades the card 300 ms after
     * the board is up. Under retention there may be no card at all, and then
     * arrival is the whole story. Taking the later of the two means the same
     * command measures both worlds without an argument about which one it is in.
     */
    const end = Math.max(arrived?.at ?? start, cleared?.at ?? start, start);

    const inWindow = (at) => at >= start && at <= end;
    const frames = raw.frames.filter((frame) => inWindow(frame.at));
    const tasks = raw.longTasks.filter(
      (task) => task.startTime + task.duration >= start && task.startTime <= end,
    );
    // Clipped to the window: a task that straddles the click contributes only
    // the part of itself that froze the switch.
    const blockedMs = tasks.reduce(
      (sum, task) =>
        sum + Math.max(0, Math.min(end, task.startTime + task.duration) - Math.max(start, task.startTime)),
      0,
    );

    return {
      // Landing is asserted on the destination's signature chapter, not on a
      // board tab: the tab strip is chrome and the chapter id is pack data.
      ok: raw.views.includes(to.signature),
      landedOn: raw.views.includes(to.signature) ? to.id : (raw.views[0] ?? null),
      chapters: raw.views.length,
      via: clicked.via,
      bootCardRaised: raised !== null,
      coveredMs: end - start,
      arrivalMs: arrived === null ? null : arrived.at - start,
      opaqueMs: done === null ? null : done.at - start,
      raiseLatencyMs: raised === null ? null : raised.at - start,
      blockedMs,
      longestTaskMs: tasks.length === 0 ? 0 : Math.max(...tasks.map((task) => task.duration)),
      longTaskCount: tasks.length,
      longTaskSupported: raw.longTaskSupported,
      frames: frames.length,
      blankFrames: frames.filter((frame) => frame.calls === 0).length,
      consoleErrors,
    };
  } finally {
    await context.close();
  }
}

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

const { server, port } = await serve();
const { browser, backend, renderer, software } = await launch();
const started = new Date().toISOString();
const rows = [];
let failed = false;

try {
  for (const direction of DIRECTIONS) {
    const name = `${direction.from.id}->${direction.to.id}`;
    if (ONLY !== null && ONLY !== name) continue;
    const samples = [];
    for (let take = 0; take < REPEAT; take += 1) {
      const sample = await measureSwitch(browser, port, direction.from, direction.to);
      samples.push(sample);
      if (!sample.ok) failed = true;
      if (sample.consoleErrors.length > 0) failed = true;
    }
    const pick = (key) => median(samples.map((sample) => sample[key]).filter((v) => v !== null));
    rows.push({
      direction: name,
      into: direction.to.label,
      takes: samples.length,
      via: [...new Set(samples.map((sample) => sample.via))].join("+"),
      bootCard: samples.every((sample) => sample.bootCardRaised)
        ? "yes"
        : samples.some((sample) => sample.bootCardRaised)
          ? "mixed"
          : "no",
      coveredMs: round(pick("coveredMs")),
      arrivalMs: round(pick("arrivalMs")),
      opaqueMs: round(pick("opaqueMs")),
      blockedMs: round(pick("blockedMs")),
      longestTaskMs: round(pick("longestTaskMs")),
      longTaskCount: round(pick("longTaskCount")),
      frames: round(pick("frames")),
      blankFrames: round(pick("blankFrames")),
      samples: samples.map((sample) => ({
        via: sample.via,
        bootCardRaised: sample.bootCardRaised,
        coveredMs: round(sample.coveredMs),
        arrivalMs: round(sample.arrivalMs),
        opaqueMs: round(sample.opaqueMs),
        blockedMs: round(sample.blockedMs),
        longestTaskMs: round(sample.longestTaskMs),
        frames: sample.frames,
        blankFrames: sample.blankFrames,
        ok: sample.ok,
        landedOn: sample.landedOn,
        consoleErrors: sample.consoleErrors,
      })),
    });
  }
} finally {
  await browser.close();
  server.close();
}

const pad = (value, width) => String(value ?? "—").padStart(width);
const header = [
  pad("direction", 22),
  pad("via", 11),
  pad("card", 6),
  pad("covered", 9),
  pad("arrival", 9),
  pad("opaque", 8),
  pad("blocked", 9),
  pad("longest", 9),
  pad("tasks", 7),
  pad("frames", 8),
  pad("blank", 7),
  pad("live%", 7),
].join("  ");

console.log(`switch-cost — ${started}`);
console.log(`  dist       ${DIST}`);
console.log(`  viewport   ${VIEWPORT_NAME} ${VIEWPORT.width}x${VIEWPORT.height}`);
console.log(`  backend    ${backend}${software ? " (SOFTWARE — not comparable to the GPU baseline)" : ""}`);
console.log(`  renderer   ${renderer ?? "unknown"}`);
console.log(`  takes      ${REPEAT} per direction (median reported)`);
console.log("");
console.log(header);
console.log("-".repeat(header.length));
for (const row of rows) {
  const live = row.coveredMs ? Math.round(((row.coveredMs - row.blockedMs) / row.coveredMs) * 100) : null;
  console.log(
    [
      pad(row.direction, 22),
      pad(row.via, 11),
      pad(row.bootCard, 6),
      pad(row.coveredMs, 9),
      pad(row.arrivalMs, 9),
      pad(row.opaqueMs, 8),
      pad(row.blockedMs, 9),
      pad(row.longestTaskMs, 9),
      pad(row.longTaskCount, 7),
      pad(row.frames, 8),
      pad(row.blankFrames, 7),
      pad(live === null ? "—" : `${live}%`, 7),
    ].join("  "),
  );
}
console.log("");
console.log(
  "live% is the share of the cover that is NOT blocked — a live, animating page with\n" +
    "nothing to draw because the outgoing board was disposed. That is the part retention\n" +
    "gets back for free. blank frames are frames that issued zero WebGL draw calls.",
);
if (rows.every((row) => row.longTaskCount === 0)) {
  console.log("NOTE: no Long Tasks were observed at all — check longTaskSupported before believing it.");
}
for (const row of rows) {
  for (const sample of row.samples) {
    if (!sample.ok) console.log(`FAIL ${row.direction}: landed on ${sample.landedOn ?? "nothing"}`);
    if (sample.consoleErrors.length > 0) {
      console.log(`FAIL ${row.direction}: console errors ${JSON.stringify(sample.consoleErrors)}`);
    }
  }
}

if (JSON_OUT !== null) {
  await writeFile(
    JSON_OUT,
    `${JSON.stringify({ started, dist: DIST, viewport: VIEWPORT_NAME, backend, renderer, software, repeat: REPEAT, rows }, null, 2)}\n`,
  );
  console.log(`wrote ${JSON_OUT}`);
}

process.exit(failed ? 1 : 0);
