/**
 * The bits of "photograph the running app" that both `capture.mjs` and
 * `shots.mjs` need.
 *
 * This file exists because the second consumer arrived. `capture.mjs` shot the
 * app to make two share cards; `shots.mjs` shoots it to make the product
 * imagery on lumbridgecorp.com. Everything below — the static server, the GL
 * flags, the two-hostnames-one-dist trick, the clock shim — was already
 * load-bearing for the first one, and copying it would have meant two capture
 * pipelines drifting apart while both claimed to photograph the same app.
 * There is one pipeline. The two scripts differ only in what they frame.
 */

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

const MIME = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript",
  ".css": "text/css",
  ".png": "image/png",
  ".webp": "image/webp",
  ".svg": "image/svg+xml",
  ".json": "application/json",
  ".webmanifest": "application/manifest+json",
};

/** A static server over one directory, with the SPA fallback the app expects. */
export function serve(dir, port, { spa = false } = {}) {
  const server = createServer(async (req, res) => {
    const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
    for (const candidate of [path, `${path}/index.html`, spa ? "/index.html" : null]) {
      if (!candidate) continue;
      try {
        const body = await readFile(join(dir, candidate));
        res.writeHead(200, {
          "content-type": MIME[extname(candidate)] ?? "application/octet-stream",
        });
        res.end(body);
        return;
      } catch {
        /* try the next candidate */
      }
    }
    res.writeHead(404).end("not found");
  });
  return new Promise((resolve) => server.listen(port, "127.0.0.1", () => resolve(server)));
}

const COMMON_ARGS = [
  "--no-sandbox",
  "--disable-dev-shm-usage",
  // The app reads its own hostname to decide which door it is. Resolving both
  // names at the local server is what makes one `dist/` produce both shots.
  "--host-resolver-rules=MAP office.lumbridgecorp.com 127.0.0.1, MAP tera.lumbridgecorp.com 127.0.0.1",
];

/**
 * The GPU is real, and headless Chrome will use it if you name the backend
 * precisely.
 *
 * This box has a Radeon RX 6700 XT with the amdgpu driver and a working RADV
 * ICD, and Chrome reaches it with **no display server at all** — no `DISPLAY`,
 * no `XDG_RUNTIME_DIR`, nothing but read access to `/dev/dri/renderD128`, which
 * membership of the `render` group already grants. So this still runs under
 * cron and over ssh, which is what the software-GL flag was protecting.
 *
 * It is worth naming the backend exactly, because most of the plausible spellings
 * silently give you SwiftShader instead and report success: `--use-angle=gl`,
 * `--use-gl=egl`, `--use-gl=desktop`, `--enable-features=Vulkan` and passing no
 * GL flags at all were all measured on this box, and all four land on
 * `SwiftShader Device (Subzero)`. Only `--use-angle=vulkan` and
 * `--use-angle=gl-egl` reach the card. Nothing else is required — the
 * `--ozone-platform`, `--enable-features=Vulkan` and `--disable-software-rasterizer`
 * flags that usually accompany this change measured as no-ops.
 *
 * What it buys, on the real film loop at 1440x900: 7.56 s/frame to 0.47, and
 * about twelve cores pegged to less than one. A 180-frame film goes from
 * twenty-two minutes to eighty seconds. The pictures are the same pictures —
 * mean absolute difference half a level out of 255, confined to MSAA edges and
 * to cloud shadow that drifts between any two runs anyway.
 */
export const GPU_ARGS = [...COMMON_ARGS, "--use-gl=angle", "--use-angle=vulkan"];

/** Where `launch()` goes when there is no usable card. See `launch()`. */
export const SOFTWARE_ARGS = [...COMMON_ARGS, "--use-gl=angle", "--use-angle=swiftshader"];

/** The renderer string a real WebGL context reports, or `null` if it has none. */
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 ext = gl && gl.getExtension("WEBGL_debug_renderer_info");
      return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : null;
    });
  } catch {
    return null;
  } finally {
    await page.close();
  }
}

/**
 * Chrome with the GPU, having checked that it actually got one.
 *
 * The check is not ceremony. `--use-angle=vulkan` is a demand, not a
 * preference: on a box where the driver is missing it does not quietly fall
 * back to software, it produces **no WebGL context at all**. The app's boot
 * curtain then never lifts, and the caller sits in `waitForFunction` until its
 * three-minute timeout before failing with something that looks nothing like
 * "there is no GPU here". Asking the context what it is costs half a second
 * once per run and turns that into a line of output and a slow, correct render.
 *
 * `SwiftShader` and `llvmpipe` both count as failure — they are what a silent
 * fallback looks like, and a run that thinks it is on the GPU while taking
 * twenty minutes a film is the confusion this whole check exists to prevent.
 */
export async function launch({ gpu = true } = {}) {
  if (gpu) {
    const browser = await chromium.launch({ channel: "chrome", args: GPU_ARGS });
    const renderer = await rendererOf(browser);
    if (renderer && !/SwiftShader|llvmpipe/i.test(renderer)) {
      console.log(`   GPU: ${renderer}`);
      return browser;
    }
    console.log(`   no GPU (${renderer ?? "no WebGL context"}) — falling back to SwiftShader, which is slow.`);
    await browser.close();
  }
  const browser = await chromium.launch({ channel: "chrome", args: SOFTWARE_ARGS });
  console.log(`   GPU: ${await rendererOf(browser)}`);
  return browser;
}

/**
 * Move the page's clock to a fixed instant.
 *
 * The sun is real — `solar.ts` computes it from `new Date()` — so a shot taken
 * 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.
 *
 * This is also the only time control available here. Scrubbing the date from
 * the godmode panel needs `can.debug`, which needs a signed-in admin; shifting
 * `Date` needs nothing, and produces the same sun.
 */
export function clockShim(iso) {
  return `{
  const target = new Date(${JSON.stringify(iso)}).getTime();
  const skew = target - Date.now();
  const Real = Date;
  globalThis.Date = class extends Real {
    constructor(...a) { super(...(a.length ? a : [Real.now() + skew])); }
    static now() { return Real.now() + skew; }
  };
}`;
}

/**
 * What a shot leaves out, by kind of shot.
 *
 * `BARE` is the share card's answer: the card supplies its own typography, and
 * the app's panels shrunk to 1200×630 are unreadable furniture.
 *
 * `CLUTTER` is the product screenshot's, and it is deliberately much shorter.
 * The left panel and the plan view are not chrome over the product, they *are*
 * the product; a picture of the engine with its instruments cropped off is a
 * stock photo of a landscape. What goes is only what is untrue outside the
 * capture, or unreadable inside it:
 *
 * - `#tier` reads "Full view" here because the capture runs against a local
 *   `dist/` with no API to say otherwise. On the deployed site an anonymous
 *   visitor gets "Public view". Shipping the local answer as marketing art
 *   would be a picture of a permission nobody browsing the site actually has.
 * - `#rail` is the keyboard hint strip, which is advice for a person holding a
 *   keyboard, not information about the city.
 * - `#onboarding-host` is the first-run card. It is *correct* — a real visitor
 *   on a first visit does see it — but it is teaching the reader how to fly a
 *   board they are looking at a photograph of, and it covers the bottom-left
 *   quarter of the frame. It is also the newest thing here, which is the
 *   general hazard this list exists for: every card added to the interface is
 *   in the product imagery by default, and silently, because the shot still
 *   renders and still looks deliberate. Anything mounted over the canvas that
 *   is not the world or an instrument reading it belongs in this list.
 */
export const FURNITURE = {
  /** Never wanted in a photograph: the boot curtain and the phone's panel scrim. */
  transient: ["#boot", "#scrim", "#onboarding-host"],
  /*
   * `#mode-dock` and `#play-hud` joined this list after they turned up in the
   * shipped share card. The dock is the VIEW / DRIVE / EXPLORE / FLY pill and the
   * HUD is the drive readout, and both are exactly the app furniture a card
   * replaces with its own typography — but because they are new, they arrived in
   * the art silently, the way `#onboarding-host` did. Anything mounted over the
   * canvas belongs in one of these two lists on the day it is added.
   */
  BARE: [
    "#panel", "#panel-toggle", "#corner", "#rail", "#source", "#tier",
    "#onboarding-host", "#mode-dock", "#play-hud",
  ],
  CLUTTER: ["#rail", "#tier", "#source", "#panel-toggle", "#onboarding-host"],
};

/** Hide selectors, and wait a beat for the layout to settle before shooting. */
export async function hide(page, selectors) {
  await page.evaluate((sels) => {
    for (const sel of sels) {
      document.querySelectorAll(sel).forEach((el) => (el.style.display = "none"));
    }
  }, selectors);
  await page.waitForTimeout(600);
}
