/**
 * The bridge kit's judgement, which is the part a screenshot cannot check.
 *
 * `bridges.ts` decides what *kind* of bridge a `Bridge` record describes: which
 * reaches of deck hang from a cable, where the anchorages go, and which stretches
 * stand on piers instead. Those decisions are invisible in a picture except as
 * their consequences — the Bay Bridge's Yerba Buena crossing is right when there
 * is no cable over the island, and "no cable" is also exactly what a broken
 * classifier looks like. So they are asserted here.
 *
 * The world below is San Francisco's real projection, not a unit square: one
 * scene unit is 94.34 m and heights carry the pack's 3.6× exaggeration. That
 * matters because the first version of the span limit compared an exaggerated
 * tower height against an unexaggerated deck length, decided the Bay Bridge
 * could suspend two and a half kilometres, and drew it. A fake world with a
 * tidy 1:1 scale would have passed.
 */

import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";

import { buildBridge, planBridge } from "../../engine/bridges.ts";
import type { GeometrySink, SurfaceKind } from "../../engine/bridges.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;

/** San Francisco's projection, with the ground wherever the caller says. */
function bayWorld(groundAt: (lat: number, lng: number) => number = () => 0): World {
  const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
  return {
    project(lat: number, lng: number): [number, number] {
      return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
    },
    groundAt,
    metres(value: number): number {
      return (value / METRES_PER_UNIT) * EXAGGERATION;
    },
    metresPerUnit: METRES_PER_UNIT,
  } as unknown as World;
}

/** Collects what a build emitted, without a `Batch` or a renderer in sight. */
function sink(): GeometrySink & { parts: { name: string; geometry: THREE.BufferGeometry }[] } {
  const materials = new Map<string, THREE.Material>();
  const parts: { name: string; geometry: THREE.BufferGeometry }[] = [];
  return {
    parts,
    material(kind: SurfaceKind, color: number): THREE.Material {
      const key = `${kind}:${color}`;
      const hit = materials.get(key);
      if (hit) return hit;
      const made = new THREE.MeshBasicMaterial({ color });
      made.name = key;
      materials.set(key, made);
      return made;
    },
    add(name, geometry) {
      parts.push({ name, geometry });
    },
  };
}

const ROADWAY = new THREE.MeshBasicMaterial({ color: 0x3a3f42 });

/** 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,
};

/** The pack's Bay Bridge: two west-span towers, a tunnel, and one more tower. */
const BAY_BRIDGE: Bridge = {
  name: "Bay Bridge",
  path: [
    [37.7905, -122.3885],
    [37.7965, -122.3805],
    [37.8035, -122.3725],
    [37.8095, -122.3648],
    [37.8155, -122.3535],
    [37.8205, -122.3405],
    [37.8225, -122.3275],
  ],
  towers: [
    [37.7955, -122.3815],
    [37.8035, -122.3725],
    [37.8165, -122.3515],
  ],
  towerHeight: 160,
  deckHeight: 58,
  sag: 0.4,
  color: 0x9aa6b2,
};

/** The pack's San Mateo–Hayward: eleven kilometres of trestle, one high span. */
const SAN_MATEO: Bridge = {
  name: "San Mateo–Hayward Bridge",
  path: [
    [37.5745, -122.2585],
    [37.578, -122.255],
    [37.5865, -122.2405],
    [37.6, -122.212],
    [37.615, -122.175],
    [37.628, -122.128],
    [37.6305, -122.1235],
  ],
  towers: [[37.5865, -122.2405]],
  towerHeight: 58,
  deckHeight: 14,
  sag: 0.3,
  color: 0x9aa6b2,
};

// ---- The classifier --------------------------------------------------------

test("the Golden Gate is a main span between its towers", () => {
  const plan = planBridge(bayWorld(), GOLDEN_GATE);
  const main = plan.reaches.filter((reach) => reach.kind === "main");
  assert.equal(main.length, 1, "the strait is one main span, not several");
  const [span] = main;
  assert.ok(span);
  assert.deepEqual(
    [span.from, span.to],
    plan.towerStations,
    "the main span does not run tower to tower",
  );
  // One cable run: anchorage, tower, tower, anchorage.
  assert.equal(plan.chains, 1);
});

test("the Golden Gate anchors its cable short of the shore", () => {
  const plan = planBridge(bayWorld(), GOLDEN_GATE);
  // The pack runs the path 1.4 km past each tower so the span has something to
  // land on. A real anchorage sits about half a main span out and the rest is
  // approach viaduct — anchoring at the end of the path instead is what used to
  // run the side cables up the Presidio bluff.
  const kinds = plan.reaches.map((reach) => reach.kind);
  assert.equal(kinds[0], "approach", "the first reach should be viaduct, not cable");
  assert.equal(kinds[kinds.length - 1], "approach");
  assert.equal(kinds.filter((kind) => kind === "side").length, 2);
});

test("the Bay Bridge does not suspend a cable over Yerba Buena", () => {
  const plan = planBridge(bayWorld(), BAY_BRIDGE);
  const towers = plan.towerStations;
  const [west, centre, east] = towers;
  assert.ok(west !== undefined && centre !== undefined && east !== undefined);

  const between = plan.reaches.find((reach) => reach.from === centre && reach.to === east);
  assert.ok(between, "no reach runs from the west span's far tower to the east span's");
  // 2.35 km at 160 m of tower is fifteen tower-heights. Nothing ever built
  // reaches nine, and this one crosses an island.
  assert.equal(between.kind, "approach");

  const main = plan.reaches.filter((reach) => reach.kind === "main");
  assert.equal(main.length, 1, "only the west span is suspended tower-to-tower");
  assert.deepEqual([main[0]?.from, main[0]?.to], [west, centre]);

  // Two separate cable runs: the west span's, and the single-tower east span's.
  assert.equal(plan.chains, 2);
});

test("a trestle with one channel tower gets a hump, not a cable across the bay", () => {
  const plan = planBridge(bayWorld(), SAN_MATEO);
  assert.equal(plan.chains, 1, "the ship-channel tower should carry exactly one cable run");
  const suspended = plan.reaches.filter((reach) => reach.kind !== "approach");
  const suspendedLength = suspended.length;
  assert.ok(suspendedLength > 0, "the tower is holding nothing up");
  // Eleven kilometres of crossing, and the cable covers a few hundred metres of
  // it. `sideLimit` for a 58 m tower is 58 × 9 × 0.55 = 287 m.
  const cableSpan = suspended.reduce((sum, reach) => sum + (reach.to - reach.from), 0);
  assert.ok(
    cableSpan < plan.stationCount * 0.2,
    `the cable covers ${cableSpan} of ${plan.stationCount} stations`,
  );
});

// ---- What comes out --------------------------------------------------------

test("a bridge is two buckets: painted structure, and roadway", () => {
  const into = sink();
  const cost = buildBridge(bayWorld(), GOLDEN_GATE, into, ROADWAY);
  const names = new Set(into.parts.map((part) => part.name));
  assert.deepEqual([...names].sort(), ["Golden Gate Bridge", "bridge:roadway"]);
  assert.ok(cost.roadway > 0, "the deck has no running surface");
  assert.ok(cost.structure > cost.roadway, "the structure should outweigh one flat plate");
  // The parts the whole kit exists for. `byPart` is the census, not the buckets.
  for (const part of ["deck", "tower", "cable", "hanger", "pier", "anchorage"]) {
    assert.ok((cost.byPart[part] ?? 0) > 0, `the bridge has no ${part}`);
  }
});

test("every part carries the attributes a merge needs", () => {
  const into = sink();
  buildBridge(bayWorld(), BAY_BRIDGE, into, ROADWAY);
  // `mergeGeometries` returns null when attribute sets disagree, and `Batch`
  // drops the whole bucket. A part missing a UV takes the bridge with it.
  for (const part of into.parts) {
    for (const attribute of ["position", "normal", "uv"]) {
      assert.ok(
        part.geometry.getAttribute(attribute),
        `${part.name} lost its ${attribute}`,
      );
    }
    assert.ok(part.geometry.getIndex(), `${part.name} is not indexed`);
  }
});

test("the towers stand at their authored height and the deck at its own", () => {
  const into = sink();
  buildBridge(bayWorld(), GOLDEN_GATE, into, ROADWAY);
  const world = bayWorld();
  const box = new THREE.Box3();
  for (const part of into.parts) {
    part.geometry.computeBoundingBox();
    if (part.geometry.boundingBox) box.union(part.geometry.boundingBox);
  }
  assert.ok(
    Math.abs(box.max.y - world.metres(227)) < 0.2,
    `the towers top out at ${box.max.y.toFixed(2)}, not ${world.metres(227).toFixed(2)}`,
  );

  const roadway = into.parts.filter((part) => part.name === "bridge:roadway");
  const deck = new THREE.Box3();
  for (const part of roadway) {
    part.geometry.computeBoundingBox();
    if (part.geometry.boundingBox) deck.union(part.geometry.boundingBox);
  }
  assert.ok(
    Math.abs(deck.max.y - world.metres(67)) < 0.05,
    `the deck sits at ${deck.max.y.toFixed(2)}, not ${world.metres(67).toFixed(2)}`,
  );
  // The ramp at each end drops the deck onto the shore, so the lowest roadway
  // is below the authored deck height rather than at it.
  assert.ok(deck.min.y < deck.max.y - 0.2, "the deck never lands on anything");
});

test("a pier is not driven through an island", () => {
  // Yerba Buena, as a hill under the middle of the crossing: ground above the
  // deck for a stretch of it. The approach must walk onto that rather than
  // standing on stilts over the top of it.
  const island = bayWorld((lat, lng) =>
    lat > 37.806 && lat < 37.813 && lng > -122.368 && lng < -122.36 ? 4 : 0,
  );
  const into = sink();
  buildBridge(island, BAY_BRIDGE, into, ROADWAY);
  const flat = sink();
  buildBridge(bayWorld(), BAY_BRIDGE, flat, ROADWAY);
  const piers = (parts: typeof into.parts) =>
    parts.filter((part) => {
      part.geometry.computeBoundingBox();
      const box = part.geometry.boundingBox;
      return box !== null && box.min.y < -0.05;
    }).length;
  assert.ok(
    piers(into.parts) < piers(flat.parts),
    "the island did not remove a single pier",
  );
});

test("the whole crossing stays inside its triangle allowance", () => {
  // The Bay Area board had 21,256 spare triangles when this kit was written and
  // five crossings to spend them on. This is the per-bridge share, and it is
  // here because the cheapest way to lose it is a spacing constant: halving
  // `stationSpacing` quadruples nothing visible and doubles the deck.
  const world = bayWorld();
  for (const bridge of [GOLDEN_GATE, BAY_BRIDGE, SAN_MATEO]) {
    const into = sink();
    const cost = buildBridge(world, bridge, into, ROADWAY);
    const total = cost.structure + cost.roadway;
    assert.ok(total < 4_000, `${bridge.name} costs ${total} triangles`);
  }
});
