/**
 * 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: a flat projection, ground at
 * zero, and metres straight through.
 *
 * A real `World` builds a heightfield, which is 0.53M lattice points and a
 * couple of seconds — none of which any assertion here depends on.
 */
function flatWorld(city: Partial<City>): World {
  return {
    city: { roads: [], bridges: [], inlandWater: [], ...city } as unknown as City,
    project(lat: number, lng: number): [number, number] {
      return [(lng + 122) * 20, -(lat - 37) * 20];
    },
    groundAt(): number {
      return 0;
    },
    metres(value: number): number {
      return value / 100;
    },
  } 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,
};

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 one material and one draw call", () => {
  const bridge = createBridge(flatWorld({}), GOLDEN_GATE);

  // The spec's number is six; a bridge is painted one colour throughout, so
  // anything above one is a part that was left out of the bucket.
  const distinct = materialsIn(bridge);
  assert.ok(distinct.size <= 6, `the bridge holds ${distinct.size} materials`);
  assert.equal(distinct.size, 1, `the bridge holds ${distinct.size} materials, not one`);
  assert.equal(meshes(bridge).length, 1, "the bridge did not merge into one mesh");
});

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

  // The arithmetic, because a bucket that failed to merge comes out as one
  // *span* of geometry and otherwise looks entirely healthy: a 3-point deck tube
  // is 7 × 5 = 35 vertices, two towers and four braces are 24 each = 144, three
  // cable spans at 25 × 6 = 450, and the hangers are 24 boxes of 24 less
  // whichever ones the deck-clearance test culls — call it 1,000 at the floor.
  const vertices = merged.geometry.getAttribute("position").count;
  assert.ok(vertices > 1_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 bridge = createBridge(flatWorld({}), GOLDEN_GATE);
  const merged = meshes(bridge)[0];
  assert.ok(merged);
  merged.geometry.computeBoundingBox();
  const box = merged.geometry.boundingBox;
  assert.ok(box);

  // Towers to 2.27 units, deck at 0.67, cables sagging between. 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.
  assert.ok(Math.abs(box.max.y - 2.27) < 0.05, `the towers top out at ${box.max.y.toFixed(2)}`);
  assert.ok(box.min.y > 0, "something sank below the water line");
  assert.ok(box.max.x - box.min.x > 0.4, "the bridge has no span");
});

test("two bridges are two draw calls, not sixty-eight", () => {
  const second: Bridge = { ...GOLDEN_GATE, name: "bay-bridge", color: 0x9aa6ad };
  const group = createBridges(flatWorld({ bridges: [GOLDEN_GATE, second] }));
  assert.equal(meshes(group).length, 2);
  // Different colours, so genuinely two materials. Each bridge builds its own
  // batch, which is deliberate: the cache cannot outlive the build, because
  // `createScene().dispose()` walks the scene disposing every material it finds
  // and a shared cache would hand the next board a disposed one.
  assert.equal(materialsIn(group).size, 2);
});

// ---- 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 keeps its median stroke as a second material", () => {
  const freeway: Road = {
    kind: "freeway",
    width: 0.14,
    path: [
      [37.7, -122.4],
      [37.9, -122.45],
    ],
  };
  const group = createRoads(flatWorld({ roads: [freeway] }));
  // Two colours is two calls, and that is the floor rather than a regression:
  // the stroke is a different colour from the deck it sits on.
  assert.equal(meshes(group).length, 2);
  assert.equal(materialsIn(group).size, 2);
});
