/**
 * The infrastructure that switches itself on after dark, and the two things
 * about it a picture cannot check.
 *
 * A screenshot tells you a bridge is lit. It does not tell you the lamps are on
 * the deck *this* build drew rather than on a deck computed a second time and
 * a metre out, and it does not tell you they will be in the same place in the
 * 2x render as they were in the 1x preview — which is the property the capture
 * scripts rely on when they shoot the same frame at two resolutions and compare
 * them. Both are asserted here.
 *
 * The world below is San Francisco's real projection: one scene unit is 94.34 m
 * and heights carry the pack's 3.6x exaggeration. A tidy 1:1 fake would pass
 * while a lamp sat 3.6 times too high over the roadway, which is precisely the
 * class of bug `bridges.ts` already has a comment about.
 */

import assert from "node:assert/strict";
import test from "node:test";

import { bridgeLights, planBridge } from "../../engine/bridges.ts";
import { MODEL_X_METRICS, buildHeadlampPool } from "../../assets/vehicles/modelX.ts";
import type { Bridge } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";

const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;
const EXAGGERATION = 3.6;

function board(latScale: number, exaggeration: number, ground = () => 0): World {
  const metresPerUnit = 111_320 / latScale;
  const lngScale = latScale * Math.cos((CENTRE.lat * Math.PI) / 180);
  return {
    project(lat: number, lng: number): [number, number] {
      return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * latScale];
    },
    groundAt: ground,
    metres(value: number): number {
      return (value / metresPerUnit) * exaggeration;
    },
    metresPerUnit,
  } as unknown as World;
}

const bayWorld = () => board(LAT_SCALE, EXAGGERATION);

/** The pack's Golden Gate, coordinate for coordinate. */
const GOLDEN_GATE: Bridge = {
  name: "Golden Gate Bridge",
  path: [
    [37.8025, -122.4752],
    [37.8106, -122.4775],
    [37.8155, -122.4783],
    [37.825, -122.479],
    [37.8325, -122.4798],
    [37.8375, -122.4806],
  ],
  towers: [
    [37.8155, -122.4783],
    [37.825, -122.479],
  ],
  towerHeight: 227,
  deckHeight: 67,
  sag: 0.55,
  color: 0xc0442c,
};

/** Metres, matching the constants `bridgeLights` is written against. */
const DECK_LAMP_SPACING_M = 50;
const DECK_LAMP_HEIGHT_M = 12;
const HEAD_LIGHT_CLEARANCE_M = 6;

function triples(flat: readonly number[]): [number, number, number][] {
  const out: [number, number, number][] = [];
  for (let i = 0; i + 2 < flat.length; i += 3) {
    out.push([flat[i] as number, flat[i + 1] as number, flat[i + 2] as number]);
  }
  return out;
}

// ---- The deck run ----------------------------------------------------------

test("the deck lamps stand on the deck, at lamp height, in pairs", () => {
  const world = bayWorld();
  const lit = bridgeLights(world, GOLDEN_GATE);
  const lamps = triples(lit.deck);

  assert.ok(lamps.length >= 80, `a 2.7 km crossing wants a real run of lamps, got ${lamps.length}`);
  assert.equal(lamps.length % 2, 0, "lamps come one per kerb, so the count is even");

  // The deck is flat at `deckHeight` everywhere except the ramps at each end,
  // so the great majority of lamps sit at exactly deck + lamp height. That is
  // the assertion that fails the moment somebody recomputes the deck profile
  // somewhere else and the two answers drift.
  const deckY = world.metres(GOLDEN_GATE.deckHeight);
  const expected = deckY + world.metres(DECK_LAMP_HEIGHT_M);
  const onTheFlat = lamps.filter(([, y]) => Math.abs(y - expected) < 1e-6);
  assert.ok(
    onTheFlat.length > lamps.length * 0.6,
    `most lamps belong on the flat deck at ${expected.toFixed(4)}; ${onTheFlat.length} of ${lamps.length} were`,
  );
  // The rest are on the ramps at each end, where the deck comes down onto the
  // shore — so the run is bounded above by the flat deck's lamp height and below
  // by a lamp standing on the landing. Neither bound is decoration: the first
  // catches a lamp that has floated up to the cable, the second one that has
  // sunk into the roadway.
  const lift = world.metres(DECK_LAMP_HEIGHT_M);
  for (const [, y] of lamps) {
    assert.ok(y <= expected + 1e-6, `a deck lamp never rises above the deck run (${y})`);
    assert.ok(y >= lift - 1e-6, `a deck lamp is above whatever the deck is resting on (${y})`);
    assert.ok(y < world.metres(GOLDEN_GATE.towerHeight), "a deck lamp is not a tower light");
  }
});

test("the lamp run is evenly spaced along the whole crossing", () => {
  const world = bayWorld();
  const lamps = triples(bridgeLights(world, GOLDEN_GATE).deck);
  const spacing = DECK_LAMP_SPACING_M / METRES_PER_UNIT;

  // One pair per station along the deck: walk the left kerb only.
  const left = lamps.filter((_, index) => index % 2 === 0);
  const plan = planBridge(world, GOLDEN_GATE);
  const covered = (left.length - 1) * spacing;
  assert.ok(
    covered > plan.deckLength * 0.9,
    `the run should reach both ends: covered ${covered.toFixed(2)} of ${plan.deckLength.toFixed(2)} units`,
  );

  for (let i = 1; i < left.length; i += 1) {
    const a = left[i - 1];
    const b = left[i];
    if (!a || !b) continue;
    const step = Math.hypot(b[0] - a[0], b[2] - a[2]);
    assert.ok(
      step > spacing * 0.5 && step < spacing * 1.6,
      `lamp ${i} is ${step.toFixed(3)} units from the last, against a ${spacing.toFixed(3)} pitch`,
    );
  }
});

// ---- The tower heads -------------------------------------------------------

test("each tower carries two obstruction lights, over the saddle", () => {
  const world = bayWorld();
  const lit = bridgeLights(world, GOLDEN_GATE);
  const heads = triples(lit.heads);

  assert.equal(heads.length, GOLDEN_GATE.towers.length * 2, "one light per tower leg");
  const expected = world.metres(GOLDEN_GATE.towerHeight) + world.metres(HEAD_LIGHT_CLEARANCE_M);
  for (const [, y] of heads) {
    assert.ok(Math.abs(y - expected) < 1e-6, `a head light belongs at ${expected.toFixed(4)}, got ${y}`);
  }

  // The pair straddles the deck: two legs, `deckHalf` either side of the centre.
  const [a, b] = heads;
  assert.ok(a && b);
  const across = Math.hypot(b[0] - a[0], b[2] - a[2]);
  assert.ok(
    Math.abs(across - lit.scale * 2) < 1e-6,
    `the legs are a deck apart: ${across.toFixed(4)} against ${(lit.scale * 2).toFixed(4)}`,
  );
});

// ---- Determinism -----------------------------------------------------------

test("the same bridge lights identically twice — the capture scripts rely on it", () => {
  const world = bayWorld();
  const first = bridgeLights(world, GOLDEN_GATE);
  const second = bridgeLights(world, GOLDEN_GATE);
  assert.deepEqual(first.deck, second.deck);
  assert.deepEqual(first.heads, second.heads);
  assert.equal(first.scale, second.scale);
});

// ---- Board scale -----------------------------------------------------------

test("a coarser board gets a smaller lamp, because it draws a smaller bridge", () => {
  // San Francisco at 94 m to the unit against Los Angeles at 391. A sprite size
  // in scene units tuned on the first would be several times the width of the
  // deck on the second, which is the bug `socal.ts` asked the kit to stop having.
  // The floor is metres now, so both boards draw the same 21 m half and the
  // coarser one is smaller in scene units, not clamped to 0.09 (173 m on CA).
  const fine = bridgeLights(board(1180, 3.6), GOLDEN_GATE);
  const coarse = bridgeLights(board(285, 2.2), GOLDEN_GATE);
  assert.ok(coarse.scale < fine.scale, "a coarser board draws a narrower deck");
  const fineM = fine.scale * (111_320 / 1180);
  const coarseM = coarse.scale * (111_320 / 285);
  assert.ok(
    Math.abs(fineM - coarseM) < 0.5,
    `deck half is ${fineM.toFixed(1)} m on the Bay and ${coarseM.toFixed(1)} m on SoCal`,
  );
  assert.ok(triples(coarse.deck).length >= 8, "a coarse board still gets a run, not a handful");
});

// ---- The headlamp pool -----------------------------------------------------

test("the headlamp pool lies ahead of the nose, and is off until night", () => {
  const pool = buildHeadlampPool({ reachM: 26 });
  pool.mesh.geometry.computeBoundingBox();
  const box = pool.mesh.geometry.boundingBox;
  assert.ok(box);

  // The nose is at -Z. The pool starts at the bumper and runs forward from it,
  // so every vertex is in front of the car and none is behind the rear axle.
  const nose = -MODEL_X_METRICS.length / 2;
  assert.ok(box.max.z <= nose + 1e-6, `the pool starts at the bumper (${box.max.z} vs ${nose})`);
  assert.ok(Math.abs(box.min.z - (nose - 26)) < 1e-6, "…and reaches the asked-for range");
  // Flat on the road, not a wall.
  assert.ok(Math.abs(box.max.y - box.min.y) < 1e-6, "the pool is flat");

  assert.equal(pool.mesh.visible, false, "nothing is drawn before a night level arrives");
  pool.setIntensity(0);
  assert.equal(pool.mesh.visible, false, "…and nothing at broad daylight either");
  pool.dispose();
});

test("the pool draws nothing at all with no DOM to draw its beam pattern into", () => {
  // Every bit of shape this thing has is in the texture's alpha, so a pool built
  // without one would be a hard-edged glowing rectangle across the carriageway.
  // Headless is exactly where that would go unseen, which is why it is asserted
  // here rather than left to a screenshot.
  assert.equal(typeof document, "undefined", "this test only means anything headless");
  const pool = buildHeadlampPool();
  pool.setIntensity(1);
  assert.equal(pool.mesh.visible, false);
  pool.dispose();
});
