#!/usr/bin/env node
/**
 * site_audit — is this site winnable, BEFORE it ships?  [Lane B, SPRINT9]
 *
 *   node tools/site_audit/audit.mjs                          # the shipped yard
 *   node tools/site_audit/audit.mjs web/world/data/sites/site_02.json
 *   node tools/site_audit/audit.mjs --storm storm_03_southerly
 *
 * WHY THIS EXISTS, in one number: p1 = 7.4 kN.
 *
 * In SPRINT6 the yard shipped with a bed-covering quad whose corner pulled
 * 7.4 kN against a shop whose best hardware holds 6.5. No loadout could hold it
 * at any price, so the wild night was unwinnable — and it took four lanes two
 * sprints to find out, because nothing checked the site's GEOMETRY against the
 * shop's PRICE LIST at authoring time. Geometry decides winnability. A site is a
 * balance decision disguised as art, and it should be audited the minute it's
 * drawn, not the sprint after it ships.
 *
 * What it does NOT do: score the garden. Winnability is decided before any of
 * that — by whether a quad exists that (a) covers the bed and (b) has peak
 * corner loads the budget can hold. The garden drain only decides how BADLY you
 * lose a site that was already unwinnable. That's also why this runs in node:
 * no skyfx, no document, no browser, ~20 s per site.
 */

import { readFile } from 'node:fs/promises';
import { HARDWARE, START_BUDGET } from '../../web/world/js/contracts.js';
import { AUDIT, auditSweep } from './sweep.js';

const argv = process.argv.slice(2);
const stormArg = (() => { const i = argv.indexOf('--storm'); return i >= 0 ? argv[i + 1] : 'storm_02_wildnight'; })();
const sitePath = argv.find((a) => !a.startsWith('--') && a !== stormArg) || null;

/**
 * THE SHIPPED YARD, headless — a dump of the DRESSED yard, and it has to be.
 *
 * Node cannot dress: dress() needs GLTFLoader (a bare 'three' specifier) and
 * fetch()es .glb over file://, neither of which works outside a browser. And
 * that matters far more than it sounds, because createWorld() still SUCCEEDS in
 * node — it just hands back the GRAYBOX yard:
 *
 *     graybox (node)          dressed (browser, what the player plays)
 *     h1  x = -5.00           h1  x = -3.00      ← dress() adopts E's fascia
 *     t1  (-9.00, 2.00)       t1  (-9.96, 0.54)  ← dress() adopts branch_anchor_01
 *     t1b/t1c/t2b: ABSENT     t1b/t1c/t2b: exist ← dress() adds them
 *
 * A headless tool that "reads world.js for the real yard" therefore gets the
 * house at x=±5 — the exact fictional yard that made the SPRINT6 harness report
 * the wild night unwinnable, memorialised at the top of balance.test.js. Reading
 * live code is not the same as reading truth. The dump below is the real yard;
 * live world.js, in node, is not.
 *
 * So the deal is: dumped values for what only dress() knows, and a HARD
 * CROSS-CHECK against live world.js for everything dress() never touches.
 * dress() only adopts h1..h3 / t1 / t2 and adds the branch anchors — the four
 * posts are pure world.js, so they are re-verified on every run (verifyPosts).
 * That check exists because the first draft of this file typed p4 straight from
 * world.js's `postSpecs` and missed that posts are RAKED 8° away from centre:
 * it sat 0.56 m off, and the audit dutifully reported on a post that isn't there.
 *
 * This whole apparatus is why SPRINT9 gate 2 (sites as DATA) is worth it. The
 * moment `data/sites/backyard_01.json` exists, pass it and none of this is used.
 */
const BACKYARD_01 = {
  name: 'backyard_01 (dressed-yard dump — posts verified live, see comment)',
  dumped: true,
  bed: { x: 1, z: 2, w: 6, d: 4 },
  anchors: [
    // dress()-only: adopted from E's GLBs. Unverifiable headless.
    ['h1', 'house', -3.00, 2.48, -9.95], ['h2', 'house', 0.00, 2.48, -9.95], ['h3', 'house', 3.00, 2.48, -9.95],
    ['t1', 'tree', -9.96, 3.45, 0.54], ['t2', 'tree', 7.83, 2.93, -0.85],
    ['t1b', 'tree', -9.94, 3.96, 2.78], ['t1c', 'tree', -10.38, 5.05, 2.98], ['t2b', 'tree', 8.14, 3.70, -0.96],
    // pure world.js — cross-checked against live code on every run.
    ['p1', 'post', -4.85, 3.95, 5.93], ['p2', 'post', 4.31, 3.96, 6.46], ['p3', 'post', 0.00, 3.95, 7.56],
    ['p4', 'post', -3.72, 4.00, -1.40],
  ].map(([id, type, x, y, z]) => ({ id, type, pos: { x, y, z } })),
};

/** Anchors dress() never touches — so live world.js IS authoritative for these. */
const VERIFIABLE = new Set(['p1', 'p2', 'p3', 'p4']);

/**
 * Re-derive the posts from live world.js and fail loudly if the dump drifted.
 * The one guard-rail a hand-typed yard can actually have.
 *
 * SPRINT10: world.js stopped being code — createWorld now REQUIRES a site and
 * throws without one. It reads the site from data/sites/backyard_01.json, which
 * is exactly the extraction A shipped, so this cross-check now compares the dump
 * against the DATA's posts (raked in createWorld, no dress needed). loadSite()
 * itself fetch()es and can't run in node, so read the JSON and hand it over raw.
 */
async function verifyPosts(site) {
  const THREE = await import('../../web/world/vendor/three.module.js');
  const { createWorld } = await import('../../web/world/js/world.js');
  const stub = {
    sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
    speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
    gustTelegraph: () => null, setSheltersFromTrees() {}, eventsBetween: () => [],
  };
  const siteData = JSON.parse(await readFile(new URL('../../web/world/data/sites/backyard_01.json', import.meta.url), 'utf8'));
  const world = createWorld(new THREE.Scene(), { wind: stub, site: siteData }); // graybox: fine, posts are raked in createWorld
  const live = new Map(world.anchors.map((a) => [a.id, a.pos]));
  const drift = [];
  for (const a of site.anchors) {
    if (!VERIFIABLE.has(a.id)) continue;
    const l = live.get(a.id);
    if (!l) { drift.push(`${a.id}: gone from world.js entirely`); continue; }
    const d = Math.hypot(a.pos.x - l.x, a.pos.y - l.y, a.pos.z - l.z);
    if (d > 0.01) drift.push(`${a.id}: dump (${a.pos.x.toFixed(2)}, ${a.pos.y.toFixed(2)}, ${a.pos.z.toFixed(2)}) ` +
      `vs world.js (${l.x.toFixed(2)}, ${l.y.toFixed(2)}, ${l.z.toFixed(2)}) — ${d.toFixed(2)} m out`);
  }
  const missing = [...live.keys()].filter((id) => !site.anchors.some((a) => a.id === id));
  if (missing.length) drift.push(`world.js has anchors this dump has never heard of: ${missing.join(', ')}`);
  return drift;
}

async function loadSite(path) {
  if (!path) return BACKYARD_01;
  const j = JSON.parse(await readFile(path, 'utf8'));

  // A's committed site schema (SPRINT10, world.js loadSite/createWorld) is
  // DRESS-SOURCE, not resolved positions, and headless node cannot turn one into
  // the other. Two reasons, both load-bearing for an audit that must not lie:
  //
  //   · POSTS are pre-rake. The JSON carries {id,x,z,h}; world.js:361 leans every
  //     post 8° away from the yard centre before it becomes an anchor. p4's JSON
  //     spec (-3.2,-1.2) dresses to (-3.72,-1.40) — 0.56 m, the exact error this
  //     tool's own snapshot shipped with in SPRINT9. Reading x/z raw re-makes it.
  //   · HOUSE and TREE anchors have NO coordinates at all — just `node`, a name
  //     baked into E's GLB (fascia_anchor_01, branch_anchor_02…). Their world
  //     position exists only after dress() reads the empty's matrixWorld, and
  //     dress() needs GLTFLoader + fetch, neither of which runs in node.
  //
  // So a headless read of this file gets posts wrong and tree/house anchors not
  // at all — and the branch anchors are exactly where the dangerous quads live
  // (t2b pulled 10 kN in SPRINT9). Reporting on that silently is the SPRINT6
  // trap in reverse: a site called unriggable because the TOOL couldn't read it.
  //
  // The correct source of dressed positions is createWorld(site).anchors — which
  // is browser-only. Until that path lands (this is raised to A in THREADS), the
  // honest move is to refuse this schema loudly, not to guess at it.
  const dressSource = Array.isArray(j.posts) || j.house || Array.isArray(j.trees) || Array.isArray(j.structures);
  const resolved = Array.isArray(j.anchors) && j.anchors.length
    && j.anchors.every((a) => Number.isFinite(a.pos?.x ?? a.x));
  if (dressSource && !resolved) {
    const name = (j.id || j.name || 'the_site').replace(/\.json$/, '');
    throw new Error(
      `"${j.name || j.id || path}" is a DRESS-SOURCE site (posts/house/trees), and its anchor\n` +
      `  positions cannot be resolved headless: posts are pre-rake (world.js leans them 8°) and\n` +
      `  house/tree anchors are GLB node refs that only exist after dress(), which node cannot run.\n` +
      `  → Audit it in the browser, off the real dressed world.anchors:\n` +
      `        tools/site_audit/audit.html?site=${name}\n` +
      `    (serve the repo with server.py, open that URL — it dresses the yard the way the game\n` +
      `     does and runs the same sweep). Or hand THIS tool a resolved\n` +
      `     { anchors:[{id,type,pos:{x,y,z}}] } export. No argument audits the built-in snapshot.`);
  }

  // Resolved-positions shape: a flat anchors[] each carrying real coordinates.
  // This is what a dressed export (or a future createWorld dump) would hand us.
  const anchors = (j.anchors || []).map((a) => ({
    id: a.id, type: a.type || 'post',
    pos: { x: a.pos?.x ?? a.x, y: a.pos?.y ?? a.y, z: a.pos?.z ?? a.z },
  }));
  return { name: j.name || path, bed: j.gardenBed || j.bed, anchors };
}

const withSway = (list) => list.map((a) => ({ ...a, sway: () => a.pos }));

async function main() {
  const site = await loadSite(sitePath);
  const anchors = withSway(site.anchors);
  const def = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${stormArg}.json`, import.meta.url), 'utf8'));
  const calmDef = JSON.parse(await readFile(new URL(`../../web/world/data/storms/${AUDIT.CALM_STORM}.json`, import.meta.url), 'utf8'));

  console.log(`\nsite_audit — ${site.name}`);
  if (site.dumped) {
    const drift = await verifyPosts(site);
    if (drift.length) {
      console.log('\n✗ FAIL — the built-in yard dump no longer matches world.js:\n');
      for (const d of drift) console.log(`    ${d}`);
      console.log('\n  Every number this tool prints would be about a yard that does not exist.');
      console.log('  Fix the dump (posts are RAKED 8° — use the anchor pos, not postSpecs), or pass a site JSON.\n');
      process.exit(1);
    }
    console.log(`  posts verified against live world.js ✓   ` +
      `dress()-only anchors trusted from the dump: h1,h2,h3,t1,t2,t1b,t1c,t2b`);
    console.log(`  (node cannot dress — live world.js here is the GRAYBOX yard, house at x=±5. See comment.)`);
  }
  console.log(`storm: ${stormArg} (${def.duration}s, downdraftOfTotal ${def.gusts?.downdraftOfTotal ?? '—'})`);
  console.log(`shop: $${START_BUDGET} · ${HARDWARE.map((h) => `${h.name} $${h.cost}/${(h.rating / 1000).toFixed(1)}kN`).join(' · ')}\n`);

  // The sweep itself is shared with the browser front-end — see sweep.js.
  const { cands, rows, winners, verdict } = auditSweep({ anchors, bed: site.bed, stormDef: def, calmDef });

  if (verdict.code === 'no-cover') {
    console.log(`✗ FAIL — no quad in the ${AUDIT.BAND.lo}-${AUDIT.BAND.hi} m² band shades the bed at all.`);
    console.log(`  The site cannot be rigged. It needs an anchor near the bed before it ships.\n`);
    process.exit(1);
  }

  console.log(`${cands.length} quad(s) in band shading the bed:\n`);
  for (const r of rows) {
    const cs = r.tiers.map((c) => `${c.id} ${(c.peak / 1000).toFixed(1)}kN→${c.tier ? '$' + c.tier.cost : 'NONE'}`).join('  ');
    const mark = r.unholdable.length ? '✗ unholdable' : r.hw <= START_BUDGET ? `✓ $${r.hw}` : `✗ $${r.hw} > $${START_BUDGET}`;
    console.log(`  ${r.ids.join(',').padEnd(18)} ${r.area.toFixed(0).padStart(3)} m²  cover ${(r.cover * 100).toFixed(0).padStart(3)}%  ${mark.padEnd(14)} ${cs}`);
  }

  console.log('');
  if (!verdict.ok) {
    const best = verdict.best;
    console.log(`✗ FAIL — no affordable holding line. Cheapest is ${best.ids.join(',')} at $${best.hw} on a $${START_BUDGET} budget` +
      (best.unholdable.length ? `, and ${best.unholdable.map((c) => c.id).join('/')} exceeds the shop's ${(HARDWARE.at(-1).rating / 1000).toFixed(1)} kN ceiling at any price.` : '.'));
    console.log(`  This is the SPRINT6 p1=7.4 kN failure. Move an anchor, or the site ships unwinnable.\n`);
    process.exit(1);
  }
  const w = verdict.best;
  console.log(`✓ PASS — ${winners.length} affordable line(s). Best: ${w.ids.join(',')} — $${w.hw} hardware` +
    `${w.total <= START_BUDGET ? ` (+$${AUDIT.SPARE_COST} spare = $${w.total}, still inside budget)` : ` — no room for a $${AUDIT.SPARE_COST} spare`}` +
    `, ${w.area.toFixed(0)} m², ${(w.cover * 100).toFixed(0)}% of the bed.\n`);
}

main().catch((e) => { console.error('site_audit failed:', e.message); process.exit(2); });
