/**
 * The draw-call reclaim in `engine/structures.ts`.
 *
 * These are budget tests, and they are here because the budget is the reason
 * anything else in this build can be made to look better. The city measured 616
 * draw calls against a cap of 650 while the office spent 8% of its triangle
 * allowance: indoors quality is nearly free, outdoors it is not, and every call
 * this module gives back is one the exterior Model X and the aircraft get to
 * spend. `scripts/performance-budget.mjs` is the real gate, but it needs a
 * built bundle, a browser and eleven seconds a cell — these run in
 * milliseconds and fail on the line that caused the regression.
 *
 * Two invariants, and they are the two ways this file has gone wrong before:
 *
 *   1. **A material is per colour, not per call site.** `roadRibbon` used to
 *      close over `new THREE.MeshLambertMaterial({ color })`, so twelve
 *      identical asphalt decks were twelve materials — and two meshes that do
 *      not share a material can never be merged, whatever else you do.
 *   2. **Geometry is merged per bucket.** A suspension bridge used to arrive as
 *      about thirty-four meshes of one colour.
 *
 * There is a third thing the tests below quietly guard, and it is the one that
 * fails silently: `mergeGeometries` returns `null` when the attribute sets
 * disagree, so a ribbon without UVs sitting in a bucket beside a tube that has
 * them loses the whole bucket. Asserting on merged vertex counts is what catches
 * that, because a dropped bucket looks exactly like a very efficient one.
 */

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

import { createBridge, createBridges, createRoads } from "../../engine/structures.ts";
import type { Bridge, City, Road } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";

/**
 * The smallest thing `structures.ts` will accept: San Francisco's projection,
 * ground at sea level, and no heightfield.
 *
 * A real `World` builds one, which is 0.53M lattice points and a couple of
 * seconds — none of which any assertion here depends on. The *scale* does
 * matter and used to be a tidy 20 units per degree with metres straight
 * through: `bridges.ts` sizes its members against `metresPerUnit` and compares
 * span lengths against tower heights, so a world whose projection and whose
 * `metres()` disagree gives a bridge nothing real to be checked against.
 */
const LAT_SCALE = 1180;
const CENTRE = { lat: 37.7749, lng: -122.4194 };
const METRES_PER_UNIT = 111_320 / LAT_SCALE;

function flatWorld(city: Partial<City>): World {
  const lngScale = LAT_SCALE * Math.cos((CENTRE.lat * Math.PI) / 180);
  return {
    city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
    project(lat: number, lng: number): [number, number] {
      return [(lng - CENTRE.lng) * lngScale, -(lat - CENTRE.lat) * LAT_SCALE];
    },
    groundAt(): number {
      return 0;
    },
    metres(value: number): number {
      return (value / METRES_PER_UNIT) * 3.6;
    },
    metresPerUnit: METRES_PER_UNIT,
  } as unknown as World;
}

const GOLDEN_GATE: Bridge = {
  name: "golden-gate",
  path: [
    [37.806, -122.4756],
    [37.8199, -122.4783],
    [37.8324, -122.4796],
  ],
  towers: [
    [37.8104, -122.4767],
    [37.8249, -122.4787],
  ],
  deckHeight: 67,
  towerHeight: 227,
  sag: 0.45,
  color: 0xc0553b,
};

/** Everything painted the bridge's own colour, which is everything but the road. */
function structureOf(root: THREE.Object3D): THREE.Mesh | undefined {
  return meshes(root).find((mesh) => mesh.name !== "bridge:roadway");
}

function meshes(root: THREE.Object3D): THREE.Mesh[] {
  const found: THREE.Mesh[] = [];
  root.traverse((object) => {
    if (object instanceof THREE.Mesh) found.push(object);
  });
  return found;
}

function materialsIn(root: THREE.Object3D): Set<THREE.Material> {
  const set = new Set<THREE.Material>();
  for (const mesh of meshes(root)) {
    if (Array.isArray(mesh.material)) for (const material of mesh.material) set.add(material);
    else set.add(mesh.material);
  }
  return set;
}

// ---- Bridges ---------------------------------------------------------------

test("a suspension bridge is two draw calls: structure and roadway", () => {
  const bridge = createBridge(flatWorld({}), GOLDEN_GATE);

  // The spec's number was six for a bridge painted one colour throughout, and
  // it is two now for a reason worth stating: the deck of a bridge is a road,
  // and painting it International Orange with the towers is most of why the
  // Golden Gate used to read as a red line. Everything structural is still one
  // material — anything above two is a part that fell out of a bucket.
  const distinct = materialsIn(bridge);
  assert.equal(distinct.size, 2, `the bridge holds ${distinct.size} materials, not two`);
  assert.equal(meshes(bridge).length, 2, "the bridge did not merge into two meshes");
  assert.ok(structureOf(bridge), "nothing in the bridge is painted the bridge's colour");
});

test("merging kept every part of the bridge", () => {
  const bridge = createBridge(flatWorld({}), GOLDEN_GATE);
  const merged = structureOf(bridge);
  assert.ok(merged);

  // The arithmetic, because a bucket that failed to merge comes out as one
  // *part* of a bridge and otherwise looks entirely healthy. Two towers are six
  // frusta, two fenders and five struts each — 13 boxes, 24 vertices apiece —
  // the deck box is four strips over about fifty stations, and the cables and
  // their hangers are the rest. Two thousand is well under the floor.
  const vertices = merged.geometry.getAttribute("position").count;
  assert.ok(vertices > 2_000, `the bridge merged down to ${vertices} vertices`);

  // The merge only happens because every part carries the same attributes.
  for (const name of ["position", "normal", "uv"]) {
    assert.ok(merged.geometry.getAttribute(name), `the merged bridge has no ${name}`);
  }
  assert.ok(merged.geometry.getIndex(), "the merged bridge lost its index");

  // A 227 m tower is the tallest thing on the board; it has to cast.
  assert.equal(merged.castShadow, true);
});

test("the bridge is still shaped like a bridge after the merge", () => {
  const world = flatWorld({});
  const bridge = createBridge(world, GOLDEN_GATE);
  const merged = structureOf(bridge);
  assert.ok(merged);
  merged.geometry.computeBoundingBox();
  const box = merged.geometry.boundingBox;
  assert.ok(box);

  // Towers to 8.66 units — 227 m at 94 m per unit and 3.6× exaggeration — with
  // the deck and its cables below. Baking the transforms into the geometry is
  // where a merge goes wrong: a part that lost its translation collapses onto
  // the origin and the box stops matching.
  const top = world.metres(227);
  assert.ok(Math.abs(box.max.y - top) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
  // Tower feet and pier footings go under the surface on purpose; nothing
  // should be a whole tower's worth of them.
  assert.ok(box.min.y > -1, `something sank to ${box.min.y.toFixed(2)}`);
  assert.ok(box.max.z - box.min.z > 20, "the bridge has no span");
});

test("two bridges are three draw calls, not sixty-eight", () => {
  const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
  const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
  // Two structures — different colours, so genuinely two materials — and one
  // roadway, because both decks are the same asphalt and one batch covers the
  // whole board. That batch still cannot outlive the build: `createScene()
  // .dispose()` walks the scene disposing every material it finds, and a cache
  // that survived would hand the next board a disposed one.
  assert.equal(meshes(group).length, 3);
  assert.equal(materialsIn(group).size, 3);
  const names = meshes(group).map((mesh) => mesh.name).sort();
  assert.deepEqual(names, ["bay-bridge", "bridge:roadway", "golden-gate"]);
});

// ---- Roads -----------------------------------------------------------------

test("identical roads share one material and one mesh", () => {
  const street: Road = {
    kind: "street",
    width: 0.1,
    path: [
      [37.7, -122.4],
      [37.75, -122.42],
      [37.8, -122.45],
    ],
  };
  const group = createRoads(flatWorld({ roads: [street, street, street] }));

  // Three streets, one colour: one draw call. Before the cache this was three
  // materials and three meshes, and it scaled with the pack.
  assert.equal(materialsIn(group).size, 1);
  assert.equal(meshes(group).length, 1);

  const merged = meshes(group)[0];
  assert.ok(merged);
  // All three really are in there — three drapes of the same path.
  const vertices = merged.geometry.getAttribute("position").count;
  assert.ok(vertices > 100, `three roads merged to ${vertices} vertices`);
  assert.ok(merged.geometry.getAttribute("uv"), "the road deck lost the UVs merging depends on");
});

test("a freeway carries its markings as texture, not as a second ribbon", () => {
  const freeway: Road = {
    kind: "freeway",
    width: 0.14,
    path: [
      [37.7, -122.4],
      [37.9, -122.45],
    ],
  };
  const group = createRoads(flatWorld({ roads: [freeway] }));
  // One ribbon, one call. The median stroke used to be a second draped ribbon
  // in a second colour; verge, shoulders, edge lines and median now live in the
  // surface texture, which costs no triangles and reads as a road rather than
  // as a line on a map.
  assert.equal(meshes(group).length, 1);
  assert.equal(materialsIn(group).size, 1);
});

test("a road is drawn wider than its carriageway, and streets less so", () => {
  const shape = (kind: Road["kind"]): number => {
    const road: Road = {
      kind,
      width: 0.2,
      path: [
        [37.7, -122.4],
        [37.9, -122.4],
      ],
    };
    const mesh = meshes(createRoads(flatWorld({ roads: [road] })))[0];
    assert.ok(mesh);
    mesh.geometry.computeBoundingBox();
    const box = mesh.geometry.boundingBox;
    assert.ok(box);
    return box.max.x - box.min.x;
  };
  // The widening is the graded right-of-way the texture paints, and a freeway
  // gets more of it than a boulevard does. Both are wider than the authored
  // 0.2, which is the carriageway alone.
  const street = shape("street");
  const freeway = shape("freeway");
  assert.ok(street > 0.2 && street < 0.35, `a street came out ${street.toFixed(3)} wide`);
  assert.ok(freeway > street, "a freeway is no wider than a street");
});
