/**
 * What one statewide board would actually cost, measured rather than argued.
 *
 *   node scripts/merge-feasibility.mjs [--no-still] [--out <dir>]
 *
 * **Nothing here is wired into the product.** No board id, no route, no flag
 * that reaches a deploy. It builds the merged pack in node, counts what comes
 * out, and takes one photograph — and the photograph is the deliverable, because
 * the decision it supports is not an engineering one.
 *
 * ## The question
 *
 * "Why are there still three boards" has an answer that costs nothing —
 * `cities/reconcile.ts`, four data rules — and an answer that costs a
 * rebuild: one pack, one lattice, one lot size, from Yreka to the border.
 * The second one is priced here.
 *
 * A lot is `LOT = 0.42` **scene units** (`blocks.ts`), so how much ground a
 * building stands on is decided entirely by the board's metres-per-unit: 40 m on
 * the Bay Area, 164 m on Southern California, 806 m on the state. A merged board
 * therefore has to pick one, and the pick is the whole decision:
 *
 *   - at Bay Area density the state is **dead** — the anonymous city alone is
 *     tens of millions of triangles against a 2.6M desktop cap;
 *   - at Southern California density it **fits**, and San Francisco is drawn the
 *     way Southern California is drawn now: 164 m lots instead of 40 m.
 *
 * The second is a real option and it costs the product its best-looking board.
 * That trade is the owner's to make against a picture, which is why this ends
 * with `--out/merge-sf-40m-vs-164m.png` rather than with a recommendation.
 *
 * ## What the still actually varies
 *
 * `LOT`, not `latScale`. Rescaling San Francisco's pack would move its camera,
 * its terrain lattice and every chapter pose at once, and the resulting pair
 * would differ in a dozen ways of which lot size was one. Changing `LOT` to
 * `0.42 × (390.6 / 94.34) = 1.7392` in a **throwaway tree under /tmp** puts
 * exactly 164 m of ground under a building and leaves the frame otherwise
 * identical — which is the comparison that was asked for. `blocks.ts` in this
 * repo is not touched, and `git status` after a run says so.
 */

import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { performance } from "node:perf_hooks";
import CALIFORNIA from "../src/cities/california.ts";
import SF from "../src/cities/sf.ts";
import SOCAL from "../src/cities/socal.ts";
import { createBlocks } from "../src/engine/blocks.ts";
import { createTerrain } from "../src/engine/terrain.ts";
import { World } from "../src/engine/world.ts";

const args = process.argv.slice(2);
const flag = (name, fallback) => {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
};
const OUT = flag("--out", "/tmp/tera-look/merge");
const STILL = !args.includes("--no-still");
mkdirSync(OUT, { recursive: true });

const REPO = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
const BUDGETS = JSON.parse(readFileSync(`${REPO}/scripts/performance-budgets.json`, "utf8"));

// ---- The merged pack --------------------------------------------------------

/** Does a lat/lng fall inside a board's rectangle? */
const inside = (city, lat, lng) =>
  lat >= city.bounds.minLat && lat <= city.bounds.maxLat &&
  lng >= city.bounds.minLng && lng <= city.bounds.maxLng;

const centroid = (polygon) => {
  let lat = 0;
  let lng = 0;
  for (const [a, b] of polygon) {
    lat += a;
    lng += b;
  }
  return [lat / polygon.length, lng / polygon.length];
};

/**
 * One California, at whatever density the caller asks for.
 *
 * The two metro rectangles do not intersect and California contains both, so
 * merging the districts is a union with one subtraction: a state district whose
 * centre falls inside a metro board is dropped, because the metro pack draws
 * that ground in far more detail. That is 116 authored districts before the
 * subtraction — which is the number that kills a per-axis focus lattice, since
 * two focus regions at opposite corners refine nearly the whole board.
 */
function merged(metresPerUnit, dropCovered = true) {
  const latScale = 111_320 / metresPerUnit;
  const metros = [SF, SOCAL];
  const covered = (polygon) => {
    const [lat, lng] = centroid(polygon);
    return metros.some((city) => inside(city, lat, lng));
  };
  const districts = [
    ...CALIFORNIA.districts.filter((d) => !dropCovered || !covered(d.polygon)),
    ...SF.districts,
    ...SOCAL.districts,
  ];
  return {
    ...CALIFORNIA,
    id: "california-merged",
    name: "California",
    latScale,
    // California's own lattice, unchanged: it is the only ground cell that
    // covers 989,000 km² at all. Southern California's coarse cell over the
    // state is 1.6M lattice points and 21 s of single-threaded field.
    landmasses: [...CALIFORNIA.landmasses, ...SF.landmasses, ...SOCAL.landmasses],
    inlandWater: [...CALIFORNIA.inlandWater, ...SF.inlandWater, ...SOCAL.inlandWater],
    parks: [...CALIFORNIA.parks, ...SF.parks, ...SOCAL.parks],
    hills: [...CALIFORNIA.hills, ...SF.hills, ...SOCAL.hills],
    districts,
    roads: [...CALIFORNIA.roads, ...SF.roads, ...SOCAL.roads],
    landmarks: [...CALIFORNIA.landmarks, ...SF.landmarks, ...SOCAL.landmarks],
    bridges: [...SF.bridges, ...SOCAL.bridges],
    airports: [...(SF.airports ?? []), ...(SOCAL.airports ?? [])],
    ports: [...(SF.ports ?? []), ...(SOCAL.ports ?? [])],
    reconciled: true,
  };
}

const triangles = (mesh) => {
  const geometry = mesh.geometry;
  const perInstance = geometry.index ? geometry.index.count / 3 : geometry.attributes.position.count / 3;
  return perInstance * (mesh.isInstancedMesh ? mesh.count : 1);
};

function measure(label, metresPerUnit, dropCovered = true) {
  const city = merged(metresPerUnit, dropCovered);
  const world = new World(city);
  // `createBlocks` samples the land and park masks, so the field is built here,
  // lazily, on this thread — which is also where the placement time below is
  // measured from, and is why the field build is outside the timer.
  world.lattice();
  const t0 = performance.now();
  const blocks = createBlocks(world);
  const placementMs = performance.now() - t0;
  const lat = (city.bounds.maxLat - city.bounds.minLat) * city.latScale;
  const lng = (city.bounds.maxLng - city.bounds.minLng) * world.lngScale;
  return {
    label,
    metresPerUnit,
    lotMetres: 0.42 * metresPerUnit,
    extent: [lng, lat],
    districts: city.districts.length,
    instances: blocks.count,
    triangles: triangles(blocks),
    placementMs,
    world,
  };
}

console.log("=== the merged pack, built and counted ===\n");
console.log("A lot is 0.42 scene units, so lot size in metres is the board's scale and nothing else.");
console.log("These are the ANONYMOUS CITY only — `createBlocks`, one instanced mesh — which is the");
console.log("layer that scales with density and the layer that decides the answer.\n");

const socalDensity = measure("SoCal density", 111_320 / 285);
const bayDensity = measure("Bay Area density", 111_320 / 1180);
/**
 * The same two boards without the subtraction, because the subtraction is a
 * judgement and the reader should be able to see what it is worth.
 *
 * Keeping all 116 authored districts means the state pack's coarse Bay Area and
 * Los Angeles polygons place a second, 164 m city on top of the metro packs'
 * own. It is the wrong merge — but it is the honest upper bound, and quoting a
 * number without saying which of the two it is has already produced one 1.8x
 * disagreement in this round's own brief.
 */
const socalDensityAll = measure("SoCal density, all 116 districts", 111_320 / 285, false);
const bayDensityAll = measure("Bay Area density, all 116 districts", 111_320 / 1180, false);

const cap = BUDGETS.scenes["bay-area"].desktop.maxTriangles;
for (const row of [socalDensity, socalDensityAll, bayDensity, bayDensityAll]) {
  console.log(`${row.label}`);
  console.log(`  metres per unit   ${row.metresPerUnit.toFixed(2)}   lot ${row.lotMetres.toFixed(0)} m`);
  console.log(`  board extent      ${row.extent[0].toFixed(0)} x ${row.extent[1].toFixed(0)} units`);
  console.log(`  districts         ${row.districts}`);
  console.log(`  instances         ${row.instances.toLocaleString("en-US")}`);
  console.log(`  triangles         ${row.triangles.toLocaleString("en-US")}` +
    `   (${(row.triangles / cap).toFixed(2)}x the bay-area desktop cap of ${cap.toLocaleString("en-US")})`);
  console.log(`  placement         ${row.placementMs.toFixed(0)} ms on the main thread (NOISY — see below)\n`);
}

/**
 * Read the counts, not the clock.
 *
 * Instances and triangles are a function of the pack and the seeded RNG and come
 * back identical to the digit on every run. The placement millisecond does not:
 * five runs of the *same* build on this box gave 323, 554, 617, 909 and 1,989 ms
 * for the same 93,253 lots — a factor of six, on a box whose GPU never leaves
 * 500 MHz of a possible 2,725 and which usually has two other agents on it. The
 * brief's 432 ms is inside that spread and so is almost anything else. Quote the
 * counts; treat the milliseconds as an order of magnitude.
 */
console.log("Placement time on this box spans ~320-2,000 ms for the same 93,253 lots across runs.");
console.log("The instance and triangle counts are deterministic to the digit. Trust those.\n");

// The terrain the merged board would carry, on California's own lattice. Held
// back until after the block counts because it is the cheap half and saying so
// in the wrong order invites the wrong conclusion.
{
  const t0 = performance.now();
  const terrain = createTerrain(socalDensity.world);
  const ms = performance.now() - t0;
  let tris = 0;
  terrain.traverse((node) => {
    if (node.isMesh) tris += triangles(node);
  });
  console.log(`terrain, on California's 3,473 m cell: ${tris.toLocaleString("en-US")} triangles, ${ms.toFixed(0)} ms`);
  console.log(`whole merged board at SoCal density:   ${(tris + socalDensity.triangles).toLocaleString("en-US")} triangles\n`);
}

console.log("Today, for comparison — measured by scripts/performance-budget.mjs at bcac6aa:");
console.log("  california 375,351 tri / 374 draws   socal 1,429,993 / 218   bay-area 2,264,956 / 209\n");

// ---- The picture ------------------------------------------------------------

if (!STILL) {
  console.log("merge-feasibility: --no-still, stopping before the photograph");
  process.exit(0);
}

/**
 * Two builds of the same commit, differing in one constant.
 *
 * `git archive` rather than a copy of the working tree: the point of the
 * picture is what a lot size does, and a working tree with three agents in it
 * is not a controlled variable.
 */
const SCRATCH = `${OUT}/scratch`;
const COARSE_LOT = (0.42 * (111_320 / 285)) / (111_320 / 1180);
const trees = [
  { name: "sf-40m", lot: null },
  { name: "sf-164m", lot: COARSE_LOT },
];

for (const tree of trees) {
  const root = `${SCRATCH}/${tree.name}`;
  rmSync(root, { recursive: true, force: true });
  mkdirSync(root, { recursive: true });
  execFileSync("bash", ["-c", `cd ${REPO} && git archive HEAD | tar -x -C ${root}`]);
  execFileSync("ln", ["-sfn", `${REPO}/node_modules`, `${root}/node_modules`]);
  if (tree.lot !== null) {
    const path = `${root}/src/engine/blocks.ts`;
    const source = readFileSync(path, "utf8");
    const next = source.replace(
      /^const LOT = 0\.42;.*$/m,
      `const LOT = ${tree.lot.toFixed(6)}; // 164 m at SF's scale — merge-feasibility.mjs, throwaway tree`,
    );
    if (next === source) throw new Error("merge-feasibility: could not find LOT in blocks.ts");
    writeFileSync(path, next);
  }
  execFileSync("npx", ["vite", "build", "--outDir", `${OUT}/dist-${tree.name}`, "--emptyOutDir"], {
    cwd: root,
    stdio: "ignore",
  });
  console.log(`merge-feasibility: built ${tree.name}${tree.lot === null ? "" : ` (LOT ${tree.lot.toFixed(4)})`}`);
}

const { chromium } = await import("playwright");
const { spawn } = await import("node:child_process");
const { createServer } = await import("node:net");

async function shoot(dist, out) {
  const port = await new Promise((resolve, reject) => {
    const probe = createServer();
    probe.once("error", reject);
    probe.listen(0, "127.0.0.1", () => {
      const { port: p } = probe.address();
      probe.close(() => resolve(p));
    });
  });
  const server = spawn(`${REPO}/node_modules/.bin/vite`,
    ["preview", "--outDir", dist, "--port", String(port), "--strictPort"],
    { detached: true, stdio: ["ignore", "pipe", "pipe"], cwd: REPO });
  await new Promise((resolve) => {
    const settle = setTimeout(resolve, 30000);
    const read = (chunk) => {
      if (/http:\/\/(?:localhost|127\.0\.0\.1):\d+/.test(String(chunk))) {
        clearTimeout(settle);
        resolve();
      }
    };
    server.stdout.on("data", read);
    server.stderr.on("data", read);
  });
  const browser = await chromium.launch({
    channel: "chrome",
    args: ["--use-gl=angle", "--use-angle=vulkan", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
  });
  const context = await browser.newContext({
    viewport: { width: 1200, height: 900 },
    deviceScaleFactor: 1,
    timezoneId: "America/Los_Angeles",
    reducedMotion: "reduce",
  });
  await context.clock.setFixedTime(new Date("2026-08-21T20:00:00Z"));
  const page = await context.newPage();
  await page.goto(`http://localhost:${port}/?city=sf`, { waitUntil: "networkidle", timeout: 60000 });
  await page.waitForTimeout(11000);
  try {
    await page.getByText(/^Skip$/).first().click({ timeout: 2000 });
    await page.waitForTimeout(1200);
  } catch {
    /* not shown */
  }
  // Downtown, by identity rather than by position in the list.
  await page.locator('.chapter[data-view="fidi"]').first().click({ timeout: 5000 });
  await page.waitForTimeout(7000);
  await page.screenshot({ path: out, clip: { x: 340, y: 0, width: 860, height: 900 } });
  await browser.close();
  try {
    process.kill(-server.pid, "SIGTERM");
  } catch {
    /* already gone */
  }
}

const halves = [];
for (const tree of trees) {
  const path = `${OUT}/${tree.name}.png`;
  await shoot(`${OUT}/dist-${tree.name}`, path);
  halves.push(path);
  console.log(`merge-feasibility: shot ${path}`);
}

const still = `${OUT}/merge-sf-40m-vs-164m.png`;
execFileSync("montage", [...halves, "-tile", "2x1", "-geometry", "+4+4", "-background", "#101418", still]);
console.log(`\nmerge-feasibility: ${still}`);
console.log("Left: San Francisco today, 40 m lots. Right: the same view at 164 m lots — what");
console.log("San Francisco looks like on a statewide board that fits. That picture is the decision.");

if (!existsSync(still)) process.exit(1);
