/**
 * scorecard.selftest.js — GATE 2.3: the editor scores the wind the game plays.
 * [Lane B, SPRINT14; co-owned with Lane C]
 *
 * SPRINT13's whole lesson, made into an assert. Three harnesses independently
 * rebuilt site wind by hand and each got it wrong in a different way (funnel
 * off twice, frozen tree sway once); the fix was `windForSite()`, one door, and
 * the rule that no tool builds site wind again. Sprint 14 adds a fourth place
 * that scores a yard — A's editor — so the rule needs a tripwire rather than a
 * promise. This is it:
 *
 *   the wind the EDITOR scores == the wind the GAME plays,
 *   at one probe point, at one storm second, EXACTLY.
 *
 * Exactly, not nearly. A tolerance is precisely where the funnel-off bug hid:
 * it moved the Sprint-13 headline from 91.5 FULL to 39.8 TATTERED, so any
 * epsilon loose enough to feel "safe" is loose enough to hide the bug this
 * assert exists to catch. Two chains that agree agree to the last bit.
 *
 * ── The two chains, built independently on purpose ─────────────────────────
 *
 *   GAME   loadSite(name) → createWorld → dress() → **main.js's own
 *          `createWindRouter`**, wired by main.js's own two lines
 *          (loadSiteInto: setVenturi then setSheltersFromTrees). Importing the
 *          router rather than retyping those lines is C's correction to my
 *          first draft, and it matters: my version built BOTH sides with
 *          `windForSite`, so it could only ever have caught a bad INPUT, never
 *          a router that routed wrong.
 *
 *   EDITOR siteClone-shaped object → buildScoringWorld() → dress() →
 *          windForSite(storm, clone, scoringWorld.anchors). The editor's yard
 *          is an object that was never a file, and its scoring world is built
 *          fresh (A's page renders on a calm stub whose sway closures must
 *          never reach a score — C's landmine 2 wearing a new hat). This is
 *          the path SCORE IT actually runs, which is what my half adds over
 *          C's: C pins the BUILDER against the game, this pins the thing the
 *          BUTTON runs against the game. Three chains, one number.
 *
 * The clone goes through a JSON round-trip because that is what `siteClone()`
 * and the export actually produce: if canonicalisation ever dropped or
 * reshaped something wind-relevant, the editor would score a yard whose
 * weather nobody could play. This file does not import `editor.js` — the pin
 * is about the two WIND CHAINS, and importing A's page would make lane/b's
 * selftest unable to run on lane/b.
 *
 * ── WHERE the probe goes: C's knobs, and the three wrong answers before them ──
 *
 * **This pin uses Lane C's knobs, not the ones I proposed.** C co-owns gate
 * 2.3, measured my proposal, and moved all three; converging beats landing a
 * second pin that disagrees, so the numbers below are theirs.
 *
 *     site   site_02_corner_block   the only yard with a shipped venturi
 *     storm  storm_02_wildnight     (mine, kept)
 *     probe  (-6, y, 0)             the authored throat centre — site geometry,
 *                                   not a magic number; speedAt ignores y
 *     second t = 60.0               the alignment plateau, where the funnel is
 *                                   worth about a third of the answer
 *     match  exact `===`
 *
 * I proposed backyard_01 / bed corner / t=30. All three were wrong, and it is
 * worth writing down because they are the SAME failure three times:
 *
 *  1. backyard_01 declares `venturi: []`. There, "setVenturi called with an
 *     empty list" and "setVenturi never called" are the same number — the
 *     regression the pin exists to catch is invisible. (Found while measuring;
 *     it is why the pin moved to site_02 at all.)
 *  2. site_02's BED cannot see the funnel either — the throat's disc doesn't
 *     reach it. Measured Δ 0.0000 m/s, independently by both of us.
 *  3. t = 30 is a second where the wildnight's direction does not line up with
 *     the gap: C measured the funnel worth 0.4% at the throat there. **This one
 *     I missed.** My throat measurement read a healthy Δ +4.948 m/s and I
 *     trusted it — but that was on `storm_03b_earlybuster`, not on the
 *     wildnight this pin flies. A sensitivity measured on one storm says
 *     nothing about another, and I generalised across exactly that gap. The
 *     mutation check I was proud of would have passed on the wrong storm.
 *
 * A pin at 0.4% passes with the funnel wired backwards. Hence the vacuity
 * guard below: it re-measures what the funnel is WORTH at the pin and fails
 * under 25% — the check that would have caught my proposal on its own.
 *
 * ── The bed is kept, as a SECOND probe, on C's offer ───────────────────────
 *
 * C offered to carry the garden bed as a second probe rather than the one, and
 * I took it: what the sail SHADES is what the audit is ultimately about, and
 * equality there is a real claim even though the funnel is not what decides it.
 * It is labelled honestly and carries no funnel tripwire, because it cannot. A
 * second probe that measures a different thing is worth having; a first probe
 * that measures nothing is not.
 *
 * A finding worth more than the pin, which C reached independently: the corner
 * block's funnel does not reach its garden bed at all. It bites the RIGGING
 * ZONE — what the site JSON's own comment says ("q1 is 3.5 m away, inside the
 * radius"). The funnel on that yard is a HARDWARE-LOAD story, not a
 * garden-exposure story, and the score card should be read that way.
 *
 * ── Vacuous-pass guard ─────────────────────────────────────────────────────
 *
 * `a === b` also passes when both are 0, or both are garbage from a chain that
 * silently did nothing. Every equality assert here is paired with a liveness
 * assert (finite, non-trivial) and every claim of sensitivity is paired with a
 * mutation assert. An assert that cannot fail is documentation, and
 * documentation cannot fail.
 */

import * as THREE from '../../web/world/vendor/three.module.js';
import { createWorld, loadSite } from '../../web/world/js/world.js';
import { loadStorm, createWind, windForSite } from '../../web/world/js/weather.js';
// main.js's OWN router, not a retyped copy of its two wiring lines — C's
// correction, and the right call: a pin that retypes the thing it pins agrees
// with itself by construction.
import { createWindRouter } from '../../web/world/js/main.js';
import { buildScoringWorld } from './scorecard.js';
import { auditSweep, auditSweepAsync } from './sweep.js';

const assert = (cond, msg) => { if (!cond) throw new Error(msg); };

/**
 * GATE 2.3's pinned knobs — Lane C's, adopted. One copy, both lanes, no drift.
 * If either half moves these, it moves them here and says so in THREADS.
 */
export const PIN = {
  site: 'site_02_corner_block',
  storm: 'storm_02_wildnight',
  t: 60.0,
  probeY: 0,
  /** The funnel must be worth at least this much AT THE PIN or the pin is
   *  decoration. C's threshold, shared rather than re-chosen. */
  MIN_FUNNEL_SHARE: 0.25,
};

/** Probe sets per yard. Each probe says what it is ALLOWED to claim. */
const PROBES = {
  site_02_corner_block: {
    // THE pin: the authored throat centre, where the funnel decides the answer
    throat: { x: -6, z: 0 },
    // C's offered SECOND probe — what the sail shades. Funnel-blind by
    // geometry (Δ 0.0000), so it carries equality only, and says so.
    bed: 'gardenBed',
    tree: { x: 7, z: -1 },            // tr1 — the shelter half
  },
  backyard_01: {
    bed: 'gardenBed',
    tree: { x: -9, z: 2 },            // t1, the gum whose sway is dynamic load
  },
};

const vec = (p) => new THREE.Vector3(p.x, PIN.probeY, p.z);
const resolveProbes = (name, world) => Object.fromEntries(
  Object.entries(PROBES[name]).map(([k, v]) =>
    [k, vec(v === 'gardenBed' ? { x: world.gardenBed.x, z: world.gardenBed.z } : v)]));

/**
 * The GAME chain: main.js's OWN router, wired by main.js's own two lines
 * (loadSiteInto — `setVenturi(siteDef.wind.venturi)` then
 * `setSheltersFromTrees(anchors.filter(type === 'tree'))`).
 */
async function gameChain(siteName, stormDef) {
  const site = await loadSite(siteName);
  const world = createWorld(new THREE.Scene(), { wind: stubProxy(), site });
  await world.dress();
  const inner = createWind(stormDef);
  const router = createWindRouter([inner]);
  router.use(inner);
  router.setVenturi(site.wind?.venturi ?? []);
  router.setSheltersFromTrees(world.anchors.filter((a) => a.type === 'tree'));
  return { site, world, wind: router };
}

/** The EDITOR chain: a siteClone-shaped object through the scoring world. */
async function editorChain(siteName, stormDef) {
  // What EDITOR.siteClone() hands over: a deep clone that has been through the
  // canonical export shape. JSON round-trip stands in for the key ordering —
  // the pin is that this survives as the SAME WEATHER.
  const clone = JSON.parse(JSON.stringify(await loadSite(siteName)));
  const built = await buildScoringWorld(clone);
  assert(built.dressed, `gate 2.3: scoring world for ${siteName} did not dress (${built.dressError}) — `
    + 'an undressed yard has no fascia hints and is a different game');
  return { site: clone, built, wind: windForSite(stormDef, clone, built.anchors) };
}

function stubProxy() {
  return {
    sample: (p, t, o) => (o || new THREE.Vector3()).set(0, 0, 4),
    speedAt: () => 4, rainAt: () => 0, rainMmPerHour: () => 0,
    gustTelegraph: () => null, eventsBetween: () => [], setSheltersFromTrees() {}, setVenturi() {},
  };
}

export async function buildScorecardTests() {
  const tests = [];

  // ── the two shipped yards ────────────────────────────────────────────────
  // The pinned case first (C's knobs). backyard_01 rides along because
  // equality on a funnel-less yard is still a real claim about the clone and
  // the dress — it just carries no funnel tripwire, and says so.
  const cases = [
    { site: PIN.site, storm: PIN.storm },
    { site: 'backyard_01', storm: 'storm_02_wildnight' },
  ];

  const measured = {};
  for (const c of cases) {
    const stormDef = await loadStorm(c.storm);
    const game = await gameChain(c.site, stormDef);
    const editor = await editorChain(c.site, stormDef);

    const gProbes = resolveProbes(c.site, game.world);
    const eProbes = resolveProbes(c.site, editor.built.world);
    measured[c.site] = { stormDef, editor, probes: eProbes };

    tests.push([`gate 2.3: ${c.site} — both chains agree where the yard's probes ARE`, () => {
      for (const k of Object.keys(gProbes)) {
        assert(gProbes[k].equals(eProbes[k]),
          `gate 2.3: the chains disagree about the '${k}' probe on ${c.site} — `
          + `game (${gProbes[k].x},${gProbes[k].z}) vs editor (${eProbes[k].x},${eProbes[k].z}). `
          + 'A pin on two different points is not a pin.');
      }
    }]);

    for (const k of Object.keys(gProbes)) {
      const gSpeed = game.wind.speedAt(gProbes[k], PIN.t);
      const eSpeed = editor.wind.speedAt(eProbes[k], PIN.t);
      const gVec = game.wind.sample(gProbes[k], PIN.t, new THREE.Vector3());
      const eVec = editor.wind.sample(eProbes[k], PIN.t, new THREE.Vector3());

      tests.push([`gate 2.3: ${c.site} @${k} — editor-scored wind === game-played wind (t=${PIN.t})`, () => {
        // liveness first: `a === b` on two zeroes is not agreement, it is silence
        assert(Number.isFinite(gSpeed) && gSpeed > 1,
          `gate 2.3: the GAME chain read ${gSpeed} m/s at '${k}' — that is not a storm, it is a `
          + 'broken chain, and an equality assert against it would pass vacuously');
        assert(gSpeed === eSpeed,
          `gate 2.3 FAILED on ${c.site} @${k}: the editor would score a wind the game never plays.\n`
          + `  game   ${gSpeed}\n  editor ${eSpeed}\n  delta  ${eSpeed - gSpeed}\n`
          + 'Both sides go through windForSite(); if they differ, the INPUTS differ — check the '
          + 'venturi list survived the clone, and that the scoring world dressed.');
        assert(gVec.x === eVec.x && gVec.y === eVec.y && gVec.z === eVec.z,
          `gate 2.3: speeds matched but DIRECTIONS did not on ${c.site} @${k} — `
          + `game (${gVec.x},${gVec.y},${gVec.z}) vs editor (${eVec.x},${eVec.y},${eVec.z}). `
          + 'A sail cares which way the wind blows, so scalar agreement alone is not the pin.');
      }]);
    }
  }

  // backyard_01 carries no funnel, and the pin must SAY so rather than let a
  // future reader assume the funnel-off tripwire covers both yards.
  tests.push(['gate 2.3: backyard_01 declares no venturi (so site_02 carries the funnel tripwire)', () => {
    const v = measured['backyard_01'].editor.site.wind?.venturi ?? [];
    assert(v.length === 0,
      `backyard_01 now declares ${v.length} venturi. That is not a failure — but the funnel `
      + 'mutation check below lives on site_02 BECAUSE this yard had none, and if this yard grew '
      + 'weather the pin should cover it too. Update the probe table.');
  }]);

  // ── the mutation checks, on the yard that HAS weather ────────────────────
  // These are what stop the pin above from being decoration. Each one breaks an
  // input that a real harness has actually broken, and demands the number move.
  const fun = measured['site_02_corner_block'];
  const baseWind = () => windForSite(fun.stormDef, fun.editor.site, fun.editor.built.anchors);

  /** Minimum move a mutation must produce to count as "this probe can feel it".
   *  Not `!== 0`: a floating-point hair would satisfy that while the probe sat
   *  effectively blind. Measured deltas here are ~4.9 m/s, so 1.0 is a floor
   *  with two orders of headroom, not a threshold anyone tuned to pass. */
  const MIN_MOVE = 1.0;

  // ── C's vacuity guard, adopted — threshold and all ───────────────────────
  // The strongest of the three checks, and the one that would have caught my
  // proposal on its own. A mutation check asks "did the number MOVE"; this asks
  // "is the funnel worth ENOUGH here that a wiring bug couldn't hide in the
  // rounding". At t=30 on this storm the answer was 0.4%.
  tests.push([`GATE 2.3 guard: the funnel is worth ≥${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% at the pinned probe/second`, () => {
    const probe = fun.probes.throat;
    const on = baseWind().speedAt(probe, PIN.t);
    const off = windForSite(fun.stormDef, { ...fun.editor.site, wind: { venturi: [] } },
      fun.editor.built.anchors).speedAt(probe, PIN.t);
    const share = (on - off) / on;
    assert(Number.isFinite(share) && share >= PIN.MIN_FUNNEL_SHARE,
      `gate 2.3 GUARD FAILED: at (${probe.x},${probe.z}) t=${PIN.t} on ${PIN.storm} the funnel is `
      + `worth ${(share * 100).toFixed(2)}% (${off.toFixed(2)} → ${on.toFixed(2)} m/s), under the `
      + `${(PIN.MIN_FUNNEL_SHARE * 100).toFixed(0)}% floor. The equality pin is now decoration — it `
      + 'would pass with setVenturi deleted. Do NOT lower this floor: re-measure and move the '
      + 'second, the way C moved it off t=30 (0.4%) onto the alignment plateau.');
  }]);

  tests.push(['gate 2.3 mutation: dropping the venturi MOVES the throat (funnel-off tripwire)', () => {
    const probe = fun.probes.throat;
    const before = baseWind().speedAt(probe, PIN.t);
    const starved = { ...fun.editor.site, wind: { venturi: [] } };
    const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
    assert(Math.abs(before - after) >= MIN_MOVE,
      `gate 2.3 mutation FAILED: starving site_02 of its venturi moved the THROAT probe by `
      + `${(before - after).toFixed(4)} m/s (${before} → ${after}). This probe cannot SEE the `
      + 'funnel, so the equality pin above would stay green through exactly the funnel-off bug it '
      + 'exists to catch — the one that moved the S13 headline 91.5 → 39.8. This already happened '
      + 'THREE times on this pin (backyard_01 has no funnel; site_02\'s BED is 6.08 m from a '
      + 'radius-5 throat; t=30 on the wildnight is worth 0.4%). Re-measure and move the probe; '
      + 'do not relax the assert.');
  }]);

  tests.push(['gate 2.3 mutation: hiding the trees MOVES the tree probe (shelter tripwire)', () => {
    // setSheltersFromTrees filters on type; anchors that no longer say "tree"
    // register no shelter. The frozen-tree landmine's cousin — the wind arrives
    // unsheltered and the yard is easier than the one that ships.
    const probe = fun.probes.tree;
    const before = baseWind().speedAt(probe, PIN.t);
    const noTrees = fun.editor.built.anchors.map((a) => ({ ...a, type: a.type === 'tree' ? 'post' : a.type }));
    const after = windForSite(fun.stormDef, fun.editor.site, noTrees).speedAt(probe, PIN.t);
    assert(Math.abs(before - after) >= MIN_MOVE,
      `gate 2.3 mutation FAILED: hiding every tree moved the tree probe by `
      + `${(before - after).toFixed(4)} m/s (${before} → ${after}) — the ANCHOR half of `
      + 'windForSite is not reaching this probe, so a harness that forgot tree shelters would '
      + 'pin green.');
  }]);

  tests.push(['gate 2.3 (2nd probe): the corner block\'s funnel does NOT reach its garden bed — recorded, not a bug', () => {
    // Not a defect — it is what the site JSON's own comment describes ("q1 is
    // 3.5 m away, inside the radius"): the funnel is aimed at the RIGGING
    // ZONE, so it is a hardware-load story rather than a garden-exposure one.
    // Pinned because it is load-bearing for anyone reading the score card: if
    // this ever changes, the bed probe silently becomes funnel-sensitive and
    // every garden number on that yard moves with it.
    const probe = fun.probes.bed;
    const before = baseWind().speedAt(probe, PIN.t);
    const starved = { ...fun.editor.site, wind: { venturi: [] } };
    const after = windForSite(fun.stormDef, starved, fun.editor.built.anchors).speedAt(probe, PIN.t);
    assert(before === after,
      `The funnel now reaches site_02's garden bed: ${before} → ${after} with the venturi removed. `
      + 'That is a real design change (the bed sat 6.08 m from a radius-5 throat and felt nothing). '
      + 'Every garden number on this yard just moved — re-baseline the audit and tell A/D.');
  }]);

  // ── SPRINT15 gate 2.1: the chunked sweep is the SAME sweep ───────────────
  // scoreSite runs auditSweepAsync now — same flights, but the candidate loop
  // yields so the editor page paints progress instead of freezing for 75 s.
  // The contract is that yielding is PURE: any yieldEvery produces numbers
  // byte-identical to the sync sweep. These asserts are the tripwire for
  // anyone who later threads state across candidates (a reused rig, a shared
  // accumulator) — the exact class of bug a chunk boundary would expose.
  //
  // Synthetic five-anchor yard (multiple candidates, so chunk boundaries land
  // MID-list), 8 s storm — cheap on purpose; the real-yard reproduction lives
  // in THREADS (site_02 + site_03 cards pinned before and after the refactor).
  const YARD5 = [
    { id: 'a1', type: 'post', pos: { x: -3, y: 3.9, z: -3 } },
    { id: 'a2', type: 'post', pos: { x: 3, y: 3.9, z: -3 } },
    { id: 'a3', type: 'post', pos: { x: 3, y: 3.9, z: 3 } },
    { id: 'a4', type: 'post', pos: { x: -3, y: 3.9, z: 3 } },
    { id: 'a5', type: 'post', pos: { x: 0, y: 3.9, z: 4 } },
  ].map((a) => ({ ...a, sway: () => a.pos }));
  const YARD5_BED = { x: 0, z: 0, w: 4, d: 4 };
  const YARD5_STORM = {
    id: 'chunk_selftest_storm', duration: 8, dir: Math.PI / 2, base: 14,
    gusts: { every: 3, peak: 1.6, downdraftOfTotal: 0.2 },
  };
  // Strip nothing, hide nothing: the whole result must survive comparison.
  const sweepJSON = (r) => JSON.stringify({
    cands: r.cands, rows: r.rows, winners: r.winners,
    marginalWinners: r.marginalWinners, verdict: r.verdict,
  });

  const syncResult = auditSweep({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [] });
  const ticks = [];
  const chunk1 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
    yieldEvery: 1, onProgress: (p) => ticks.push({ ...p }) });
  const chunk3 = await auditSweepAsync({ anchors: YARD5, bed: YARD5_BED, stormDef: YARD5_STORM, venturi: [],
    yieldEvery: 3 });

  tests.push(['gate 2.1: the yielding sweep === the sync sweep, byte for byte', () => {
    // vacuity guard first — a yard whose sweep found nothing would make the
    // equality below a comparison of two empty objects
    assert(syncResult.cands.length >= 3,
      `gate 2.1: the synthetic yard swept only ${syncResult.cands.length} candidate(s) — `
      + 'not enough list for a chunk boundary to land mid-way; the purity assert is decoration. '
      + 'Fix the fixture, not the assert.');
    assert(sweepJSON(chunk1) === sweepJSON(syncResult),
      'gate 2.1 FAILED: auditSweepAsync(yieldEvery 1) returned different numbers than auditSweep '
      + 'on the same yard and storm. Yielding must be WHEN the page breathes, never WHAT gets '
      + 'computed — some state is leaking across candidates.');
  }]);

  tests.push(['gate 2.1: perturbing the chunk boundary moves NOTHING (yieldEvery 3 === yieldEvery 1)', () => {
    assert(sweepJSON(chunk3) === sweepJSON(chunk1),
      'gate 2.1 FAILED: changing yieldEvery (1 → 3) changed the sweep\'s numbers. The chunk '
      + 'boundary is a paint schedule, not an input — if moving it moves a number, a candidate '
      + 'flight is reading something a previous chunk wrote.');
  }]);

  tests.push(['gate 2.1: progress ticks fire, count monotonically, and reach the total', () => {
    const sweepTicks = ticks.filter((p) => p.phase === 'sweep');
    assert(sweepTicks.length === syncResult.cands.length,
      `gate 2.1: expected one 'sweep' tick per candidate (${syncResult.cands.length}), got `
      + `${sweepTicks.length} — a progress line that undercounts is the looks-wired-isn't `
      + 'disease on the UI layer.');
    sweepTicks.forEach((p, i) => {
      assert(p.done === i + 1 && p.total === syncResult.cands.length,
        `gate 2.1: tick ${i} read done=${p.done}/total=${p.total}, expected ${i + 1}/${syncResult.cands.length}`);
    });
  }]);

  // ── the clone is the same weather as the file ────────────────────────────
  tests.push(['gate 2.3: the export clone carries the funnel (site_02 venturi survives the round-trip)', () => {
    const v = fun.editor.site.wind?.venturi ?? [];
    assert(v.length === 1,
      `gate 2.3: the cloned site_02 has ${v.length} venturi, not 1 — the editor would score, and `
      + 'export, a corner block with no weather. The funnel lives in the SITE def; if the clone '
      + 'loses it the whole yard silently becomes an easier one.');
    assert(v[0].gain === 1.5 && v[0].axis === 2.1,
      `gate 2.3: the cloned venturi reads gain ${v[0].gain} axis ${v[0].axis}, not gain 1.5 axis 2.1.`);
  }]);

  return tests;
}
