/**
 * What one pose actually costs, in triangles and draw calls.
 *
 * `scripts/performance-budget.mjs` measures the poses a board is *judged* on and
 * refuses to move its caps. That is the right instrument for "did this change
 * regress the product" and the wrong one for "how much room is there at the
 * bottom of the descent" — it never goes there, because no budget cell does.
 *
 *   node scripts/cost-at.mjs --url "/?city=california&one=1" \
 *     --lat 37.7897 --lng -122.3972 --standoffs 200000,60000,20000,7700,2000
 *
 * The instrumentation is `performance-budget.mjs`'s, deliberately: the same four
 * patched GL entry points and the same triangle arithmetic, so a number from
 * here is comparable with a number from there rather than nearly comparable.
 *
 * Camera aiming goes through `__teraCamera.seek`, which takes true metres, so
 * one stand-off list reads the same on a 94 m board and a 1,919 m one.
 */

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

const args = process.argv.slice(2);
const flag = (name, fallback) => {
  const at = args.indexOf(name);
  return at < 0 ? fallback : args[at + 1];
};

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));
  });
});
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);
  server.stdout.on("data", (c) => {
    seen += String(c);
    const m = /http:\/\/(?:localhost|127\.0\.0\.1):(\d+)/.exec(seen);
    if (m) { clearTimeout(settle); resolve(Number(m[1])); }
  });
  server.stderr.on("data", (c) => { seen += String(c); });
});
if (bound !== PORT) {
  console.error(`cost-at: preview bound ${bound}, not ${PORT}`);
  process.exit(1);
}
{
  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("cost-at: that port is not serving this dist/");
    process.exit(1);
  }
}

const browser = await chromium.launch({
  channel: "chrome",
  args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });

/** `performance-budget.mjs`'s counters, restated so the numbers are comparable. */
await page.addInitScript(() => {
  const state = { calls: 0, tris: 0 };
  globalThis.__teraCost = state;
  const triangleCount = (mode, count) =>
    mode === 4 ? count / 3 : mode === 5 || mode === 6 ? Math.max(0, count - 2) : 0;
  const patch = (proto, countAt, instancesAt = null) => (method) => {
    if (!proto) return;
    const original = proto[method];
    if (!original) return;
    proto[method] = function (...v) {
      state.calls += 1;
      const instances = instancesAt === null ? 1 : Number(v[instancesAt]) || 0;
      state.tris += triangleCount(Number(v[0]), Number(v[countAt]) || 0) * instances;
      return original.apply(this, v);
    };
  };
  for (const proto of [globalThis.WebGLRenderingContext?.prototype, globalThis.WebGL2RenderingContext?.prototype]) {
    patch(proto, 2)("drawArrays");
    patch(proto, 1)("drawElements");
    patch(proto, 2, 3)("drawArraysInstanced");
    patch(proto, 1, 4)("drawElementsInstanced");
  }
});

await page.goto(`http://localhost:${PORT}${flag("--url", "/")}`, { waitUntil: "networkidle", timeout: 60000 });
await page.waitForTimeout(Number(flag("--wait", "11000")));
try { await page.getByText(/^Skip$/).first().click({ timeout: 2500 }); await page.waitForTimeout(1000); } catch { /* not shown */ }

const lat = Number(flag("--lat", "37.7897"));
const lng = Number(flag("--lng", "-122.3972"));
const standoffs = String(flag("--standoffs", "200000,60000,20000,7700,2000")).split(",").map(Number);

console.log(`cost-at: ${flag("--url", "/")}  at ${lat}, ${lng}`);
console.log("  standoff       triangles   draws       lots   lot");
for (const standoffM of standoffs) {
  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) };
  }, { lat, lng, standoffM });
  if (placed === null) {
    console.error("cost-at: no __teraCamera hook on this build");
    break;
  }
  // Let the detail LOD settle: the repack is driven from the frame loop and a
  // measurement taken on the frame the camera moved is a measurement of the
  // previous pose.
  await page.waitForTimeout(2500);
  // One frame's worth, sampled over several so a single odd frame cannot decide it.
  const sample = await page.evaluate(async () => {
    const s = globalThis.__teraCost;
    const frames = [];
    for (let i = 0; i < 12; i++) {
      const c0 = s.calls, t0 = s.tris;
      await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
      frames.push({ calls: s.calls - c0, tris: s.tris - t0 });
    }
    frames.sort((a, b) => a.tris - b.tris);
    return frames[Math.floor(frames.length / 2)];
  });
  /*
   * What the city layer itself is drawing, when the board offers the readout.
   *
   * A triangle total cannot answer "did the board coarsen": at these poses the
   * terrain, the water and the roads are more than half of it, so a total that
   * fell could be either. `__teraCamera.lots()` reads the packed instance count
   * and the lot size off the mesh instead. Older builds have no such hook and
   * print blanks.
   */
  const lots = await page.evaluate(() => globalThis.__teraCamera?.lots?.() ?? null);
  const km = (standoffM / 1000).toFixed(1).padStart(8);
  const packed = lots === null ? "" : String(lots.packed);
  const lot = lots === null || lots.lotMetres === null ? "" : `${lots.lotMetres} m`;
  console.log(
    `  ${km} km   ${String(Math.round(sample.tris / 2)).padStart(9)}   ${String(Math.round(sample.calls / 2)).padStart(5)}` +
      `   ${packed.padStart(8)}   ${lot.padStart(5)}`,
  );
}

await browser.close();
shutdown();
