/**
 * Lane C selftests — wind field, storm timelines, rain, debris.
 *
 * The asserts live in weather.selftest.js as a plain case list, because they
 * import nothing but weather.core.js (no THREE, no DOM) and so also run under
 * `node web/world/js/tests/run-node.mjs` — a one-second loop for tuning a storm
 * curve, instead of a browser round trip. This file is the browser half: it
 * feeds them the fetched storms and adds the checks that need the real
 * weather.js adapter (contract shape, THREE.Vector3 out).
 *
 * Lane A: a.test.js's 'gust telegraph always gives at least 1.2 s of warning'
 * is lifted onto the real wind below, per your note — the stub's claim to it is
 * now redundant and yours to drop whenever suits. Logged in THREADS.
 */

import * as THREE from '../../vendor/three.module.js';
import { assert, fixedLoop } from '../testkit.js';
import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js';
import { loadStorm, createWind, forecastLines } from '../weather.js';
import { createDebris } from '../debris.js';
import { createSkyFx, RainShadow } from '../skyfx.js';
import { SailRig, HARDWARE } from '../sail.js';
import { weatherCases } from './weather.selftest.js';

// Keep in step with data/storms/. The node runner globs the directory, so this
// list going stale shows up here first — as it did when storm_03 landed and the
// ponding case reached for a storm the browser half had never loaded.
const STORMS = [
  'storm_01_gentle', 'storm_02_wildnight', 'storm_03_southerly',
  // SPRINT6: the week's two variants — night 3 (same force, half the warning)
  // and night 5 (less wind, 2.8× the hail).
  'storm_03b_earlybuster', 'storm_02b_icenight',
];

/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
  const storms = {};
  for (const name of STORMS) storms[name] = await loadStorm(name);

  // --- the shared case list (also runs headless in node) ---
  const { cases } = weatherCases(storms);
  for (const c of cases) t.test(c.name, c.fn);

  // --- the bits that need the THREE adapter, not just the core ---

  t.test('weather.js satisfies the wind contract', () => {
    const wind = createWind(storms.storm_02_wildnight);
    const problems = checkContract('wind', wind);
    assert(problems.length === 0, problems.join('; '));
  });

  t.test('sample() returns a Vector3 and honours an out param', () => {
    const wind = createWind(storms.storm_02_wildnight);
    const pos = new THREE.Vector3(3, 0, -2);
    const a = wind.sample(pos, 12.5);
    assert(a instanceof THREE.Vector3, 'sample did not return a THREE.Vector3');
    assert(Number.isFinite(a.x) && Number.isFinite(a.y) && Number.isFinite(a.z),
      `sample returned a non-finite vector: ${a.x},${a.y},${a.z}`);
    // out param must not change the answer, only where it lands
    const out = new THREE.Vector3();
    const b = wind.sample(pos, 12.5, out);
    assert(b === out, 'out param was ignored');
    assert(a.x === b.x && a.y === b.y && a.z === b.z, 'out param changed the result');
  });

  // This assert once read `a.y === 0` — "wind is horizontal". SPRINT2 decision 3
  // made that false on purpose (gusts descend, so a flat sail pays); SPRINT3
  // decision 8 made the descent a fraction of TOTAL wind, so it's present
  // whenever it's windy, not only in gusts. Keeping the invariants that consumers
  // (player shove, rain angle, HUD) rely on: y is down-or-zero, never up, never
  // garbage, and speedAt() is horizontal-only.
  t.test('vertical wind is downward-only and rides the wind', () => {
    const wind = createWind(storms.storm_02_wildnight);
    const pos = new THREE.Vector3(0, 1.7, 0);
    const v = new THREE.Vector3();
    let sawDown = false;
    fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
      wind.sample(pos, time, v);
      assert(v.y <= 1e-9, `wind blew UP (y=${v.y.toFixed(3)}) at t=${time.toFixed(2)}`);
      assert(Number.isFinite(v.y), `vertical wind is not finite at t=${time.toFixed(2)}`);
      if (v.y < -2) sawDown = true;
    });
    assert(sawDown, 'never saw a downdraft worth the name in a whole wild night');
    // the wind meter must stay horizontal — falling air shouldn't spike the HUD
    const s = wind.sample(pos, 0.5);
    assert(Math.abs(wind.speedAt(pos, 0.5) - Math.hypot(s.x, s.z)) < 1e-9,
      'speedAt() is not the horizontal magnitude of sample()');
  });

  // Lifted from a.test.js onto the real wind (Lane A's note in this file's
  // header). This is the promise the whole storm rests on: the player can
  // always react. Deliberately walks the public surface, not the core.
  t.test('gust telegraph always gives at least 1.2 s of warning', () => {
    const wind = createWind(storms.storm_02_wildnight);
    let had = false, edges = 0;
    fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
      const tel = wind.gustTelegraph(time);
      if (tel && !had) {
        edges++;
        assert(tel.eta >= 1.2, `telegraph appeared with only ${tel.eta.toFixed(2)}s of warning`);
        assert(Number.isFinite(tel.power) && tel.power > 0, 'telegraph carried no power');
        assert(Number.isFinite(tel.dir), 'telegraph carried no direction');
      }
      had = !!tel;
    });
    assert(edges >= 5, `only ${edges} gusts telegraphed in a ${wind.duration}s storm — too quiet to test`);
  });

  // --- SPRINT2 decision 5: debris.pieces is Lane B's to read, so it's frozen ---
  t.test('debris conforms and its pieces match the frozen shape', () => {
    const wind = createWind(storms.storm_02_wildnight);
    const debris = createDebris({ wind });
    assert(checkContract('debris', debris).length === 0, checkContract('debris', debris).join('; '));

    const p = debris.spawn({ model: 'BlueCrate_v2', lateral: 0 }, 40);
    for (const [field, want] of Object.entries(DEBRIS_PIECE_FIELDS)) {
      const got = typeof p[field];
      assert(got === want, `piece.${field} is ${got}, contract says ${want}`);
      if (want === 'number') assert(Number.isFinite(p[field]), `piece.${field} is not finite`);
    }
    assert(debris.pieces.includes(p), 'spawn() returned a piece that is not in pieces');
    assert(p.r > 0 && p.mass > 0, 'a piece with no radius or no mass cannot be collided with');

    // The array is mutated in place and pieces are spliced on despawn — that's
    // documented, and B reads it fresh inside step(). Prove clear() empties it
    // rather than swapping in a new array behind their reference.
    const ref = debris.pieces;
    debris.clear();
    assert(ref === debris.pieces && debris.pieces.length === 0,
      'clear() replaced the pieces array instead of emptying it — B holds a reference');
  });

  // Lane A rebuilds skyfx on every phase change, so dispose() is on the hot path.
  // They verified sun/hemi restore exactly and spotted that fog didn't; this pins
  // both. The vacuity guards matter — a restore test where nothing ever moved is
  // a test that passes forever and checks nothing.
  t.test('skyfx.dispose() hands the scene back exactly as it found it', () => {
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x9fc4e8);
    scene.fog = new THREE.Fog(0x9fc4e8, 30, 140);
    const camera = new THREE.PerspectiveCamera();
    const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);
    const hemi = new THREE.HemisphereLight(0xbfd8ff, 0x3a4a2a, 1.8);
    const before = {
      bg: scene.background, fogColor: scene.fog.color.getHex(),
      fogNear: scene.fog.near, fogFar: scene.fog.far,
      sun: sun.intensity, hemi: hemi.intensity, children: scene.children.length,
    };

    const wind = createWind(storms.storm_02_wildnight);
    const sky = createSkyFx({ scene, camera, wind, sun, hemi });
    fixedLoop(40, FIXED_DT, (dt, time) => sky.step(dt, time, {}));

    assert(sun.intensity < before.sun * 0.9, 'the storm never dimmed the sun — this test proves nothing');
    assert(scene.fog.near !== before.fogNear, 'the storm never touched the fog — this test proves nothing');

    sky.dispose();
    assert(sun.intensity === before.sun, `sun left at ${sun.intensity}, want ${before.sun}`);
    assert(hemi.intensity === before.hemi, `hemi left at ${hemi.intensity}, want ${before.hemi}`);
    assert(scene.background === before.bg, 'scene.background not restored');
    assert(scene.fog.color.getHex() === before.fogColor,
      `fog colour left at #${scene.fog.color.getHex().toString(16)}, want #${before.fogColor.toString(16)}`);
    assert(scene.fog.near === before.fogNear && scene.fog.far === before.fogFar,
      `fog left at near=${scene.fog.near} far=${scene.fog.far}, want ${before.fogNear}/${before.fogFar}`);
    assert(scene.children.length === before.children,
      `skyfx left ${scene.children.length - before.children} object(s) in the scene`);
  });

  // --- SPRINT2 §Lane C.3: rain has to stop at the cloth ---
  // Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing
  // under test is the projection, and a flat panel makes the right answer
  // something you can work out on paper.
  const PANEL = {
    pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]),
    tris: [0, 1, 2, 0, 2, 3],
  };

  t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => {
    const s = new RainShadow();
    s.update(PANEL, 0, -1, 0);
    assert(s.live, 'shadow never built');
    assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling');
    assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling');
    assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet');
    assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled');
    assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled');
  });

  t.test('rain shadow leans with the rain, and follows the wind round', () => {
    const s = new RainShadow();
    // rain driving hard along +x: the dry ground moves +x, out from under the panel
    s.update(PANEL, 0.6, -0.8, 0);
    const shift = 3 * (0.6 / 0.8);          // 3 m of fall × the lean
    assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`);
    assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted');

    // swing the wind 180° and the patch has to swap sides. This is the southerly
    // change: the sail stops covering the bed without a single corner failing.
    s.update(PANEL, -0.6, -0.8, 0);
    assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round');
    assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung');
  });

  t.test('rain shadow: no sail, no shelter', () => {
    const s = new RainShadow();
    s.update(null, 0, -1, 0);
    assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist');
    // and rain that is not falling can't cast a shadow (guards a divide by ~0)
    s.update(PANEL, 1, 0, 0);
    assert(!s.live, 'horizontal rain projected to infinity instead of bailing out');
  });

  t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => {
    const s = new RainShadow();
    s.update(PANEL, 0, -1, 0);
    // the panel spans x,z in [-2,2]; a rect inside it is fully covered
    assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1,
      'a rect wholly under the panel is not fully covered');
    assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0,
      'a rect nowhere near the panel is covered');
    const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 });
    assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`);
  });

  // --- decision 7: the drain helper Lane A wires garden HP to ---
  t.test('gardenExposure is rain AND no cover, and needs both', () => {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera();
    const wind = createWind(storms.storm_02_wildnight);
    const sky = createSkyFx({ scene, camera, wind });
    const bed = { x: 0, z: 0, w: 4, d: 3 };

    // no sail: exposure is simply the rain
    fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, {}));
    const open = sky.gardenExposure(bed, 70);
    assert(Math.abs(open - wind.rainAt(70)) < 1e-9,
      `open ground should be exposed exactly as hard as it rains: ${open} vs ${wind.rainAt(70)}`);
    assert(open > 0.5, 'storm_02 at t=70 should be pouring');

    // a panel over the bed dries it out
    const panel = { pos: new Float32Array([-3, 3, -3, 3, 3, -3, 3, 3, 3, -3, 3, 3]), tris: [0, 1, 2, 0, 2, 3] };
    fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 70 + time, { sail: panel }));
    const covered = sky.gardenExposure(bed, 70);
    assert(covered < open * 0.5, `cloth over the bed barely helped: ${covered.toFixed(2)} vs open ${open.toFixed(2)}`);

    // and no rain means no drain, sail or not
    assert(sky.gardenExposure(bed, 0) === 0, 'the bed is draining before the rain has started');
    sky.dispose();
  });

  t.test('storm_02 lights up on its biggest gusts, storm_01 does not', () => {
    const mk = (name) => {
      const scene = new THREE.Scene();
      const wind = createWind(storms[name]);
      const sky = createSkyFx({ scene, camera: new THREE.PerspectiveCamera(), wind });
      let flashes = 0, peak = 0;
      let was = 0;
      fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
        sky.step(dt, time, {});
        if (sky.flash > was + 0.05) flashes++;      // a rising edge = a strike
        was = sky.flash;
        peak = Math.max(peak, sky.flash);
      });
      sky.dispose();
      return { flashes, peak };
    };
    const wild = mk('storm_02_wildnight');
    const gentle = mk('storm_01_gentle');
    // 3 authored strikes + the worst gusts; must be more than the authored ones
    assert(wild.flashes > 3, `wild night only flashed ${wild.flashes} times — the big gusts aren't lighting up`);
    assert(gentle.flashes === 0, `the gentle storm flashed ${gentle.flashes} times — it has no lightning at all`);
  });

  // --- SPRINT5 decision 13: hail makes the garden score respond to the rig ---
  // The whole reason hail exists: a perfect rig scored 54% vs 48% for no rig,
  // because honest rain walks under a sail. Steep hail doesn't — a sail over the
  // bed shelters it. This is the gate-2 assert: no-sail hail damage ≥ 2× a good
  // rig's, over the real storm_02 hail, through the real gardenHailExposure.
  t.test('decision 13: no-sail garden takes ≥2× the hail of a well-covered bed', () => {
    const bed = { x: 1, z: 2, w: 6, d: 4 };

    // a good rig: a quad sitting over the bed, held on rated shackles
    const s = 3.6;
    const anchors = [
      { id: 'a', pos: { x: -2, y: s, z: -0.5 }, type: 'post', sway() { return this.pos; } },
      { id: 'b', pos: { x: 4, y: s + 0.4, z: -0.5 }, type: 'post', sway() { return this.pos; } },
      { id: 'c', pos: { x: 4, y: s, z: 4.5 }, type: 'post', sway() { return this.pos; } },
      { id: 'd', pos: { x: -2, y: s + 0.4, z: 4.5 }, type: 'post', sway() { return this.pos; } },
    ];
    const rig = new SailRig({ anchors, gridN: 10 });
    rig.attach(['a', 'b', 'c', 'd'], [2, 2, 2, 2].map((i) => HARDWARE[i]), 1.0);

    const damageOverStorm = (getWorld, rigToStep) => {
      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera();
      camera.position.set(0, 6, 8);
      const wind = createWind(storms.storm_02_wildnight);
      const sky = createSkyFx({ scene, camera, wind });
      let dmg = 0;
      fixedLoop(wind.duration, FIXED_DT, (dt, time) => {
        if (rigToStep) rigToStep.step(dt, wind, time);
        sky.step(dt, time, getWorld());
        dmg += sky.gardenHailExposure(bed, time) * dt;
      });
      const cover = rigToStep ? rigToStep.coverageOver(bed, { x: 0, y: 1, z: 0 }) : 0;
      sky.dispose();
      return { dmg, cover };
    };

    const open = damageOverStorm(() => ({}), null);
    const covered = damageOverStorm(() => ({ sail: rig }), rig);

    // sanity: the rig must actually be over the bed, or this proves nothing
    assert(covered.cover > 0.6, `test rig only covers ${(covered.cover * 100).toFixed(0)}% of the bed — fix the quad, not the mechanic`);
    assert(open.dmg > 0, 'no hail damage in the open at all — storm_02 should be pelting');
    const ratio = open.dmg / Math.max(1e-6, covered.dmg);
    assert(ratio >= 2,
      `open bed took only ${ratio.toFixed(1)}× the hail of the covered one — decision 13 wants ≥2×`);
  });

  t.test('hail exposure needs BOTH hail and no cover; steep shadow, unlike rain', () => {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera();
    const wind = createWind(storms.storm_02_wildnight);
    const sky = createSkyFx({ scene, camera, wind });
    const bed = { x: 0, z: 0, w: 4, d: 3 };
    // a flat panel well above the bed
    const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };

    // storm_01 has no hail: exposure is zero regardless of cover
    const calm = createWind(storms.storm_01_gentle);
    const skyCalm = createSkyFx({ scene: new THREE.Scene(), camera, wind: calm });
    fixedLoop(1, FIXED_DT, (dt, time) => skyCalm.step(dt, 40 + time, {}));
    assert(skyCalm.gardenHailExposure(bed, 40) === 0, 'a hail-free storm still reported hail exposure');
    skyCalm.dispose();

    // at the wild night's hail peak, open bed is exposed; a panel over it is not
    fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, {}));
    const openExp = sky.gardenHailExposure(bed, 56);
    assert(openExp > 0.5, `open bed hail exposure only ${openExp.toFixed(2)} at the burst — should be high`);
    fixedLoop(1, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
    const coveredExp = sky.gardenHailExposure(bed, 56);
    assert(coveredExp < openExp * 0.4, `steep hail still hit the covered bed: ${coveredExp.toFixed(2)} vs open ${openExp.toFixed(2)}`);
    sky.dispose();
  });

  // The trap that cost gate 0 two sprints, asserted so it cannot come back.
  // skyfx.step() used to open `if (!camera) return`, so a headless harness
  // skipped the shadow rebuild and scored every rig as if the sail weren't
  // there. A camera is a view, not a weather system: the grids are physics and
  // must build without one. If someone re-adds an early return, this goes red.
  t.test('skyfx shelters the bed with NO camera — the grids are physics, not view', () => {
    const bed = { x: 0, z: 0, w: 4, d: 3 };
    const panel = { pos: new Float32Array([-3, 4, -3, 3, 4, -3, 3, 4, 3, -3, 4, 3]), tris: [0, 1, 2, 0, 2, 3] };

    const run = (withCamera) => {
      const scene = new THREE.Scene();
      const wind = createWind(storms.storm_02_wildnight);
      const sky = createSkyFx(withCamera
        ? { scene, camera: new THREE.PerspectiveCamera(), wind }
        : { scene, wind });                       // headless: no camera at all
      fixedLoop(2, FIXED_DT, (dt, time) => sky.step(dt, 55 + time, { sail: panel }));
      const out = {
        hailShadow: sky.hailShadowOver(bed),
        rainShadow: sky.rainShadowOver(bed),
        hailExp: sky.gardenHailExposure(bed, 56),
        hail: sky.hailAmount,
      };
      sky.dispose();
      return out;
    };

    const headless = run(false);
    const viewed = run(true);
    assert(headless.hailShadow > 0.9,
      `no camera → hail shadow ${headless.hailShadow.toFixed(2)} — the grid did not build, the sail is invisible to scoring`);
    assert(headless.hail > 0.5, 'no camera → hail intensity was not tracked');
    assert(headless.hailExp < 0.1,
      `no camera → bed reads ${headless.hailExp.toFixed(2)} exposed under a panel — this is the hp-36 bare-bed bug`);
    // and a camera must not CHANGE the physics, only add the view
    assert(Math.abs(headless.hailShadow - viewed.hailShadow) < 1e-9,
      `the camera changed the hail shadow: ${headless.hailShadow} vs ${viewed.hailShadow}`);
    assert(Math.abs(headless.rainShadow - viewed.rainShadow) < 1e-9,
      `the camera changed the rain shadow: ${headless.rainShadow} vs ${viewed.rainShadow}`);
  });

  // The card's copy, not just the model — this is what a player actually reads.
  t.test('forecastLines: tonight is exact, a week out hedges, never a bare NaN', () => {
    const def = storms.storm_02_wildnight;

    const tonight = forecastLines(def, 0);
    assert(tonight.name === 'WILD NIGHT', `name reads "${tonight.name}"`);
    assert(tonight.night === true, 'the wild night should read as a NIGHT');
    assert(!/–/.test(tonight.wind), `tonight's wind should be exact, got "${tonight.wind}"`);
    assert(tonight.confidence === '', 'tonight should not print a confidence line — 100% is noise');
    assert(/hail likely/.test(tonight.rain), `tonight should call the hail: "${tonight.rain}"`);

    const far = forecastLines(def, 1);
    assert(/–/.test(far.wind), `a week out should hedge with a range, got "${far.wind}"`);
    assert(/confidence/.test(far.confidence), 'a distant forecast should state its confidence');

    // no NaN/undefined can reach the card, for any storm at any lead
    for (const [name, d] of Object.entries(storms)) {
      for (const lead of [0, 0.5, 1]) {
        const f = forecastLines(d, lead);
        for (const k of ['name', 'wind', 'rain', 'confidence']) {
          assert(typeof f[k] === 'string' && !/NaN|undefined/.test(f[k]),
            `${name} @lead ${lead}: ${k} reads "${f[k]}"`);
        }
      }
    }
    // a hail-free storm must not advertise hail
    assert(!/hail/.test(forecastLines(storms.storm_01_gentle, 0).rain),
      'the gentle storm advertised hail it does not have');
  });

  t.test('every storm in data/storms/ loads and validates', () => {
    // loadStorm throws on invalid, so reaching here with all of them is the pass
    assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');
    for (const [name, def] of Object.entries(storms)) {
      assert(def.duration > 0, `${name} has no duration`);
      assert(Array.isArray(def.baseCurve), `${name} has no baseCurve`);
    }
  });
}
