/**
 * The port kit, held to the four things a screenshot cannot check.
 *
 * **It is four draw calls, and it stays four when a second port arrives.** That
 * is the whole design in one assertion. `socal mobile` measures 140 draw calls
 * against a cap of 170 — thirty spare for this feature and every future one —
 * and the obvious shape for a kit like this, a `Group` per port with a mesh per
 * surface, lands at fifteen for two ports and fifty for a board with six. So the
 * merge-across-ports property is asserted here rather than assumed: **four ports
 * must produce exactly the same mesh count as one.**
 *
 * **No crane is a `Group`.** Fifty-six gantries at eleven boxes each is 616
 * matrices in one `InstancedMesh` or 616 draw calls, and this repo has already
 * made the second mistake twice — a suspension bridge at ~34 draw calls, and
 * twelve identical asphalt freeways that could never merge because a fresh
 * material was allocated per ribbon. A test is the only thing that keeps the
 * first answer once somebody wants a crane to be pickable.
 *
 * **Every geometry carries position, normal AND uv, indexed.** `airports.ts:43`
 * records the scar: `mergeGeometries` returns `null` for a bucket whose
 * attribute sets disagree, and the bucket vanishes with no error and no missing
 * pixels to notice — it is simply not there. The `Batch` warns; this asserts it
 * never had to.
 *
 * **It constructs no light.** CONTRACT §4 gives `Atmosphere` sole ownership of
 * the rig, and a working container terminal under high-mast floods is one of the
 * more tempting exceptions in the product — `fires.ts` records the last time
 * somebody nearly took it. `setLighting` reaches for a material's `emissive`
 * instead, which is a property of a surface and not a light in the scene.
 *
 * The world below is a real board projection rather than a tidy 1:1 fake —
 * Southern California's `latScale: 285` and `verticalExaggeration: 3.4`, which
 * puts one scene unit at 390.6 m. A 1:1 fake would pass while every crane was
 * four hundred times too tall.
 *
 * There is no `document` in a node test, so `yardAtlas` correctly returns
 * `null` and the yard material falls back to a flat colour. The geometry — which
 * is what this file is about — is identical either way, because `groundQuad`
 * writes uv whether or not anything samples it.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";

import {
  createPortLayer,
  createPorts,
  craneStations,
  metresBetween,
  pathLengthMetres,
  yardCorners,
  PORT_PALETTE,
  type PortLayer,
} from "../../engine/ports.ts";
import type { PortLayerFactory, PortLayer as ScenePortLayer } from "../../engine/scene.ts";
import { LOS_ANGELES, LONG_BEACH, PORTS } from "../../cities/socal.ts";
import type { LightingState, Port } from "../../engine/types.ts";
import type { World } from "../../engine/world.ts";

/** `socal.ts`: centre 33.82 / -118.05, `latScale: 285`, exaggeration 3.4. */
function socalWorld(ground: (lat: number, lng: number) => number = () => 0): World {
  const centre = { lat: 33.82, lng: -118.05 };
  const latScale = 285;
  const lngScale = latScale * Math.cos((centre.lat * Math.PI) / 180);
  const metresPerUnit = 111_320 / latScale;
  return {
    project(lat: number, lng: number): [number, number] {
      return [(lng - centre.lng) * lngScale, -(lat - centre.lat) * latScale];
    },
    groundAt: ground,
    metresPerUnit,
    metres(value: number): number {
      return (value / metresPerUnit) * 3.4;
    },
  } as unknown as World;
}

/**
 * `createPortLayer` must be a `PortLayerFactory` once its ports are closed over,
 * and the module's own `PortLayer` must be the one `scene.ts` declares.
 *
 * Asserted at compile time, which is the only place it can be. Both are
 * type-only imports, so nothing about `scene.ts` is pulled into this test at
 * runtime and `engine/ports.ts` still imports nothing from it — that is the
 * whole point of the arrangement.
 */
const _factory: PortLayerFactory = (world, options) => createPortLayer(world, PORTS, options);
const _sameShape: (layer: PortLayer) => ScenePortLayer = (layer) => layer;
void _factory;
void _sameShape;

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

const NOON: LightingState = {
  sun: { direction: [0.1, 0.94, 0.32], color: 0xfff3e2, intensity: 2.4 },
  hemisphere: { sky: 0x8db2d4, ground: 0xa9a291, intensity: 0.6 },
  ambient: { color: 0xffffff, intensity: 0.2 },
  sky: { top: 0x8db2d4, horizon: 0xe6ded0 },
  fog: { color: 0xe6ded0, near: 200, far: 900 },
};
/**
 * Full night, and note what it does with `sun`: the key light is **thirty
 * degrees up**, because `atmosphere.ts` hands the key over to the moon after
 * dark and floors the direction besides. A layer that reads night off
 * `sun.direction[1]` passes a naive fixture and then stays dark on the board on
 * every moonlit night. This fixture exists to fail that implementation.
 */
const MIDNIGHT: LightingState = {
  ...NOON,
  sun: { direction: [0.1, 0.5, 0.32], color: 0x2a3550, intensity: 1.15 },
  sky: { top: 0x05070f, horizon: 0x121a2c },
};

/** Civil twilight: the sky has blue in it and the floods are half up. */
const DUSK: LightingState = {
  ...NOON,
  sun: { direction: [0.1, 0.02, 0.32], color: 0xffb27a, intensity: 0.4 },
  sky: { top: 0x101a3a, horizon: 0x3b4a68 },
};

describe("the port kit is four draw calls and stays four", () => {
  it("returns at most six meshes for the whole board", () => {
    const group = createPorts(socalWorld(), PORTS);
    const drawn = meshes(group);
    assert.ok(
      drawn.length <= 6,
      `a port board must fit in six meshes; got ${drawn.length}: ${drawn.map((m) => m.name).join(", ")}`,
    );
    // And it is not accidentally empty: San Pedro Bay has stone, yards, water
    // and cranes, so all four buckets must be present.
    const names = new Set(drawn.map((mesh) => mesh.name));
    for (const bucket of ["ports:stone", "ports:yard", "ports:channel", "ports:cranes"]) {
      assert.ok(names.has(bucket), `missing bucket ${bucket}`);
    }
  });

  it("costs the same number of meshes for four ports as for one", () => {
    const world = socalWorld();
    const one = meshes(createPorts(world, [LOS_ANGELES])).length;
    const four: Port[] = [
      LOS_ANGELES,
      LONG_BEACH,
      { ...LOS_ANGELES, id: "USLAX-B" },
      { ...LONG_BEACH, id: "USLGB-B" },
    ];
    const many = meshes(createPorts(world, four));
    assert.equal(
      many.length,
      one,
      `four ports drew ${many.length} meshes against one port's ${one} — the buckets stopped merging across ports`,
    );
  });

  it("draws nothing at all for a board with no port", () => {
    const group = createPorts(socalWorld(), []);
    assert.equal(meshes(group).length, 0);
    assert.equal(group.children.length, 0);
  });
});

describe("every gantry on the board is one InstancedMesh", () => {
  it("has no Group anywhere in the layer", () => {
    const group = createPorts(socalWorld(), PORTS);
    const groups: string[] = [];
    group.traverse((object) => {
      if (object !== group && object instanceof THREE.Group) groups.push(object.name || "(unnamed)");
    });
    assert.deepEqual(groups, [], `a crane became a Group: ${groups.join(", ")}`);
  });

  it("puts eleven boxes per gantry in a single instanced mesh", () => {
    const group = createPorts(socalWorld(), PORTS);
    const cranes = meshes(group).filter((mesh) => mesh.name === "ports:cranes");
    assert.equal(cranes.length, 1, "there must be exactly one crane mesh for the whole board");
    const mesh = cranes[0];
    assert.ok(mesh instanceof THREE.InstancedMesh);
    const gantries = PORTS.flatMap((port) => port.cranes ?? []).reduce(
      (total, row) => total + row.count,
      0,
    );
    assert.equal(gantries, 56, "San Pedro Bay is authored with fifty-six gantries");
    // The census, and the reason it is a number and not a range. A gantry was
    // five boxes — two legs, a portal beam, a boom, a backreach — and fifty-six
    // of those read from altitude as fifty-six crosses. It is eleven now, the
    // six new ones being the A-frame mast and its apex cap, the two stays that
    // sling the boom off it, the sill under the legs and the machinery house
    // over the tail. 132 triangles a gantry against 60: **7,392 for the whole
    // of San Pedro Bay against 3,360**, on a board whose desktop budget is 1.7
    // million and which measures 1.43 million with this in it. The count is
    // pinned because the thing being defended is not the triangles, it is that
    // adding a part to a crane must stay an entry in a matrix list — the moment
    // it becomes a child mesh this is 616 draw calls.
    assert.equal((mesh as THREE.InstancedMesh).count, gantries * 11);
    const triangles = (mesh as THREE.InstancedMesh).count * 12;
    assert.equal(triangles, 7392);
  });

  it("raises exactly the booms the pack asked for, from the far end of the rail", () => {
    // Pier T East is the quiet frontage: five of its six booms are up.
    const row = (LONG_BEACH.cranes ?? []).find((crane) => crane.id === "pier-t-east");
    assert.ok(row);
    const stations = craneStations(row);
    assert.equal(stations.length, 6);
    assert.equal(stations.filter((station) => station.idle).length, 5);
    // Clustered at one end, never scattered — a random pattern of raised booms
    // reads as a fault rather than as a berth with nothing alongside.
    assert.equal(stations[0]?.idle, false);
    assert.ok(stations.slice(1).every((station) => station.idle));
  });

  it("puts a raised boom higher than a lowered one, through the exaggerated axis", () => {
    const world = socalWorld();
    const working = createPorts(world, [
      { ...LOS_ANGELES, cranes: [{ ...(LOS_ANGELES.cranes ?? [])[0]!, idleFraction: 0 }] },
    ]);
    const idle = createPorts(world, [
      { ...LOS_ANGELES, cranes: [{ ...(LOS_ANGELES.cranes ?? [])[0]!, idleFraction: 1 }] },
    ]);
    /**
     * The true world-space top of a gantry: every box's own axis-aligned
     * extent, not `position.y + scale.x / 2`.
     *
     * The cheap version was right when the only tilted box was the boom, whose
     * long axis is X. It is wrong for the mast and the two stays — a strut
     * standing at eighty degrees has almost all of its length in Y and none of
     * it in the X the shortcut reads. Measuring the rotated half-extent is four
     * more lines and cannot be fooled by which axis a part happens to be long
     * on.
     */
    const topOf = (group: THREE.Object3D) => {
      const mesh = meshes(group).find((m) => m.name === "ports:cranes") as THREE.InstancedMesh;
      const matrix = new THREE.Matrix4();
      const position = new THREE.Vector3();
      const scale = new THREE.Vector3();
      const quaternion = new THREE.Quaternion();
      const basis = new THREE.Matrix4();
      let highest = -Infinity;
      for (let i = 0; i < mesh.count; i += 1) {
        mesh.getMatrixAt(i, matrix);
        matrix.decompose(position, quaternion, scale);
        basis.makeRotationFromQuaternion(quaternion);
        const e = basis.elements;
        // Row 1 of the rotation, dotted with the half-extents' magnitudes.
        const halfY =
          0.5 *
          (scale.x * Math.abs(e[1]!) + scale.y * Math.abs(e[5]!) + scale.z * Math.abs(e[9]!));
        highest = Math.max(highest, position.y + halfY);
      }
      return highest;
    };
    /**
     * 1.1, where it used to be 1.4, and the mast is the whole reason.
     *
     * A working gantry's tallest part is no longer its own deck line — it is
     * the apex of the A-frame, `CRANE_MAST_RISE` of the portal above the
     * girder. So the ratio this asserts is now the ratio between a raised boom
     * tip and an apex, and on real hardware that is 1.16 (a 104 m apex under a
     * 121 m boom tip) to 1.25 (99 m under 133), not 1.4. Asking for 1.4 would
     * be asking for a mast shorter than any gantry has.
     *
     * It still catches the bug it was written for. Rotating the boom instead of
     * composing it takes the tip through the *unexaggerated* horizontal, which
     * on this board lands it below the apex — ratio under 1, and a failure.
     */
    assert.ok(
      topOf(idle) > topOf(working) * 1.1,
      "a raised boom must reach well above the mast apex; the vertical axis is exaggerated and the horizontal is not, so the boom has to be composed rather than rotated",
    );
  });
});

describe("no bucket is silently dropped", () => {
  it("gives every geometry position, normal, uv and an index", () => {
    const group = createPorts(socalWorld(), PORTS);
    for (const mesh of meshes(group)) {
      const geometry = mesh.geometry;
      for (const attribute of ["position", "normal", "uv"]) {
        assert.ok(
          geometry.getAttribute(attribute),
          `${mesh.name} has no ${attribute} — mergeGeometries drops a bucket whose attribute sets disagree, in silence`,
        );
      }
      assert.ok(geometry.getIndex(), `${mesh.name} is not indexed`);
    }
  });

  it("never warns that a bucket failed to merge", () => {
    const warnings: unknown[][] = [];
    const original = console.warn;
    console.warn = (...args: unknown[]) => warnings.push(args);
    try {
      createPorts(socalWorld(), PORTS);
    } finally {
      console.warn = original;
    }
    assert.deepEqual(warnings, []);
  });
});

describe("the layer owns no light", () => {
  it("constructs no THREE.Light anywhere in the subtree", () => {
    const layer = createPortLayer(socalWorld(), PORTS, { span: 393 });
    layer.setLighting(MIDNIGHT);
    const lights: string[] = [];
    layer.group.traverse((object) => {
      if (object instanceof THREE.Light) lights.push(object.type);
    });
    assert.deepEqual(lights, [], `CONTRACT §4: Atmosphere owns the rig. Found ${lights.join(", ")}`);
    layer.dispose();
  });

  it("brings the yard up at night and puts it away by day, through emissive", () => {
    const layer = createPortLayer(socalWorld(), PORTS, { span: 393 });
    const yardMaterial = () => {
      const mesh = meshes(layer.group).find((m) => m.name === "ports:yard");
      return mesh?.material as THREE.MeshLambertMaterial;
    };
    layer.setLighting(NOON);
    assert.equal(yardMaterial().emissiveIntensity, 0);
    layer.setLighting(MIDNIGHT);
    assert.ok(yardMaterial().emissiveIntensity > 0.5, "a moonlit night is still night");
    layer.setLighting(DUSK);
    const dusk = yardMaterial().emissiveIntensity;
    assert.ok(dusk > 0.1 && dusk < 0.5, `twilight should be partway up, got ${dusk}`);
    layer.setLighting(NOON);
    assert.equal(yardMaterial().emissiveIntensity, 0);
    layer.dispose();
  });

  it("disposes its geometry and clears the group", () => {
    const layer = createPortLayer(socalWorld(), PORTS, { span: 393 });
    assert.ok(layer.group.children.length > 0);
    layer.dispose();
    assert.equal(layer.group.children.length, 0);
  });
});

describe("the kit is sized in metres against this board", () => {
  it("makes a gantry taller than a container ship is long", () => {
    const world = socalWorld();
    // The claim the module comment is built on: at 390.6 m per unit and 3.4x
    // exaggeration a 130 m gantry stands 1.13 units while a 400 m ship is 1.02
    // units long. If that ever stops being true the crane stops being the hero.
    const craneUnits = world.metres(130);
    const shipUnits = 400 / world.metresPerUnit;
    assert.ok(craneUnits > shipUnits, `${craneUnits.toFixed(3)} vs ${shipUnits.toFixed(3)}`);
    // And the arithmetic that makes a container paint rather than instances.
    assert.ok(12.2 / world.metresPerUnit < 0.04);
  });

  it("keeps the whole board's stone, yards and water within twenty thousand triangles", () => {
    // The socal mobile budget has 134,404 triangles spare. The whole port kit is
    // allowed 20,000 of them, and it is nowhere near that: this is the number
    // that stops a later "just a few more boxes" landing without anybody noticing.
    const group = createPorts(socalWorld(), PORTS);
    let triangles = 0;
    for (const mesh of meshes(group)) {
      const index = mesh.geometry.getIndex();
      const per = index ? index.count / 3 : mesh.geometry.getAttribute("position").count / 3;
      triangles += per * (mesh instanceof THREE.InstancedMesh ? mesh.count : 1);
    }
    assert.ok(triangles < 20_000, `port kit is ${triangles} triangles`);
  });

  it("uses a palette that stays off the Vincent Thomas green", () => {
    // `0x3f7d55` is already the most saturated object in the Harbour frame. Real
    // container red and Maersk blue put two more loud hues beside it and the
    // whole harbour reads as a toy. Every box colour here is under half the
    // chroma of the bridge.
    const chroma = (hex: number) => {
      const colour = new THREE.Color(hex);
      const max = Math.max(colour.r, colour.g, colour.b);
      const min = Math.min(colour.r, colour.g, colour.b);
      return max === 0 ? 0 : (max - min) / max;
    };
    const bridge = chroma(0x3f7d55);
    for (const key of ["boxLoadedA", "boxLoadedB", "boxEmpty", "boxUnknown", "stone"] as const) {
      assert.ok(
        chroma(PORT_PALETTE[key]) < bridge,
        `${key} is more saturated than the Vincent Thomas`,
      );
    }
  });
});

describe("the authoring helpers agree with the renderer", () => {
  it("derives a yard's corners the way the quad is built", () => {
    const yard = { lat: 33.75, lng: -118.25, length: 1000, width: 400, bearing: 0 };
    const corners = yardCorners(yard);
    assert.equal(corners.length, 4);
    // 1000 m along a bearing of zero is 1000 m of latitude.
    assert.ok(Math.abs(metresBetween(corners[0]!, corners[1]!) - 1000) < 2);
    assert.ok(Math.abs(metresBetween(corners[1]!, corners[2]!) - 400) < 2);
  });

  it("measures the federal breakwater at thirteen kilometres", () => {
    const total = (LOS_ANGELES.breakwater ?? []).reduce(
      (sum, arm) => sum + pathLengthMetres(arm),
      0,
    );
    assert.ok(total > 12_000 && total < 15_000, `${Math.round(total)} m`);
  });
});
