/**
 * The vessel layer, counted rather than looked at.
 *
 * Three of the four things this file pins are *shapes of the scene graph*, and
 * they are here because the picture cannot see them. A harbour drawn from two
 * hundred `THREE.Mesh`es and a harbour drawn from one `InstancedMesh` are the
 * same photograph and a different frame budget — the SoCal mobile cell has
 * thirty draw calls spare for the whole of ports and ships — and this repo has
 * been bitten by exactly that twice already: a suspension bridge at ~34 draws
 * and twelve identical freeways that could never merge because each allocated
 * its own material.
 *
 * The fourth is the empty state, which is most days. A layer that draws nothing
 * must be a layer nothing *visits*: zero children, not one cheap mesh with
 * `count = 0` still walked by the renderer.
 *
 * The board below is synthetic and small, because none of these facts are about
 * California. Scale against real packs is `vehicle/vesselScale.test.ts`'s job.
 */

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

import {
  KELVIN_HALF_ANGLE_DEG,
  VESSEL_HULL_CAPACITY,
  WAKE_CAPACITY,
  bearingRotation,
  createVesselLayer,
  hullGeometry,
  metresAcross,
  wakeLengthMetres,
} from "../../engine/vessels.ts";
import type { City, LightingState, Vessel } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";

// ---- A board -------------------------------------------------------------

const BOARD: City = {
  id: "test-harbour",
  name: "Test Harbour",
  center: { lat: 33.72, lng: -118.24 },
  bounds: { minLat: 33.5, maxLat: 33.95, minLng: -118.5, maxLng: -118.0 },
  latScale: 285,
  verticalExaggeration: 3.4,
  cellLat: 0.02,
  cellLng: 0.024,
  coastFalloff: 0.02,
  landmasses: [],
  parks: [],
  inlandWater: [],
  hills: [],
  districts: [],
  landmarks: [],
  bridges: [],
  roads: [],
  chapters: [],
};

/** No `ready()`: nothing in this layer samples the heightfield. Ships float. */
function board(): World {
  return new World(BOARD);
}

function moored(id: string, overrides: Partial<Vessel> = {}): Vessel {
  return {
    id,
    kind: "container",
    lat: 33.72,
    lng: -118.24,
    bearing: 118,
    length: 300,
    beam: 45,
    speed: 0,
    course: null,
    status: "moored",
    berthId: "a",
    ...overrides,
  };
}

function underWay(id: string, overrides: Partial<Vessel> = {}): Vessel {
  return moored(id, {
    speed: 6.2,
    course: 210,
    bearing: 210,
    status: "under-way",
    berthId: undefined,
    ...overrides,
  });
}

function instanced(group: THREE.Object3D): THREE.InstancedMesh[] {
  return group.children.filter((c): c is THREE.InstancedMesh => (c as THREE.InstancedMesh).isInstancedMesh);
}

function lines(group: THREE.Object3D): THREE.LineSegments[] {
  return group.children.filter((c): c is THREE.LineSegments => (c as THREE.LineSegments).isLineSegments);
}

const DAYLIGHT: LightingState = {
  sun: { direction: [0.3, 0.8, 0.5], color: 0xfff4e2, intensity: 2.1 },
  hemisphere: { sky: 0x8fb6d8, ground: 0x9d9482, intensity: 0.6 },
  ambient: { color: 0xffffff, intensity: 0.2 },
  sky: { top: 0x2f6fb0, horizon: 0xbcd6e8 },
  fog: { color: 0xbcd6e8, near: 100, far: 900 },
};

// ---- One mesh, one line ---------------------------------------------------

describe("the whole board's ships are two draw calls", () => {
  it("draws 1 vessel and 200 vessels from exactly one InstancedMesh", () => {
    const layer = createVesselLayer(board(), { span: 400 });

    layer.setVessels([underWay("one")]);
    assert.equal(instanced(layer.group).length, 1);

    const fleet = Array.from({ length: 200 }, (_, i) =>
      underWay(`v-${i}`, { lat: 33.6 + i * 0.001, lng: -118.3 + i * 0.0005 }),
    );
    layer.setVessels(fleet);
    assert.equal(
      instanced(layer.group).length,
      1,
      "two hundred ships must not be two hundred meshes",
    );
    assert.equal(layer.hullCount(), VESSEL_HULL_CAPACITY, "the instance cap should bind, not grow");
    layer.dispose();
  });

  it("draws every wake on the board from exactly one LineSegments", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([underWay("one")]);
    assert.equal(lines(layer.group).length, 1);

    layer.setVessels(
      Array.from({ length: 200 }, (_, i) => underWay(`v-${i}`, { lat: 33.6 + i * 0.001 })),
    );
    assert.equal(lines(layer.group).length, 1, "one wake buffer, whatever the traffic");
    assert.equal(layer.wakeCount(), WAKE_CAPACITY, "the wake cap should bind");
    layer.dispose();
  });

  it("is one hull geometry of about forty triangles", () => {
    const geometry = hullGeometry("generic");
    const triangles = geometry.getAttribute("position").count / 3;
    assert.equal(triangles, 40, `the hull is ${triangles} triangles`);
    // Position, normal AND uv, or a future merge silently drops the bucket —
    // `airports.ts:43` records that exact scar.
    for (const attribute of ["position", "normal", "uv"]) {
      assert.ok(geometry.getAttribute(attribute), `the hull has no ${attribute}`);
    }
    geometry.dispose();
  });

  it("is wound outward, which a picture found and a sign fixed", () => {
    // The first photograph of this hull had its deck wound downward: back-face
    // culled, lit from inside, and reading as "the ships came out a bit dark"
    // rather than as a hole in the ship. `computeVertexNormals` takes its answer
    // from the winding, so this is the only thing standing between a sign error
    // and a fleet of hollow boxes.
    const geometry = hullGeometry("generic");
    const position = geometry.getAttribute("position");
    const normal = geometry.getAttribute("normal");

    // The divergence theorem: for a closed surface wound outward, the sum of
    // r . n over the faces is three times the enclosed volume, and it is
    // positive. Flip any face and the sum drops by twice that face's share.
    let flux = 0;
    let checked = 0;
    for (let t = 0; t < position.count; t += 3) {
      const a = new THREE.Vector3().fromBufferAttribute(position, t);
      const b = new THREE.Vector3().fromBufferAttribute(position, t + 1);
      const c = new THREE.Vector3().fromBufferAttribute(position, t + 2);
      const face = new THREE.Vector3()
        .subVectors(b, a)
        .cross(new THREE.Vector3().subVectors(c, a))
        .multiplyScalar(0.5);
      flux += a.clone().add(b).add(c).divideScalar(3).dot(face);

      const n = new THREE.Vector3().fromBufferAttribute(normal, t);
      // The two faces whose orientation is unambiguous by inspection: the
      // funnel's cap is the highest thing on the ship and the bottom plating is
      // the lowest.
      if (a.y === b.y && b.y === c.y) {
        if (a.y > 1.8) {
          assert.ok(n.y > 0.9, "the funnel cap points down");
          checked += 1;
        }
        if (a.y === 0) {
          assert.ok(n.y < -0.9, "the bottom plating points up");
          checked += 1;
        }
      }
    }
    assert.ok(flux > 0, `the hull encloses ${(flux / 3).toFixed(3)} of signed volume`);
    assert.ok(checked >= 4, "the horizontal-face check found nothing to check");
    geometry.dispose();
  });
});

// ---- The buffer is allocated once -----------------------------------------

describe("the wake buffer", () => {
  it("is allocated at construction and does not resize across 100 ticks", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([underWay("a"), underWay("b", { lat: 33.7, course: 30, bearing: 30 })]);

    const line = lines(layer.group)[0];
    assert.ok(line);
    const positions = line.geometry.getAttribute("position") as THREE.BufferAttribute;
    const colors = line.geometry.getAttribute("color") as THREE.BufferAttribute;
    const positionLength = positions.array.length;
    const colorLength = colors.array.length;
    const positionArray = positions.array;

    for (let i = 0; i < 100; i++) {
      layer.tick(1 / 60);
      const now = line.geometry.getAttribute("position") as THREE.BufferAttribute;
      assert.equal(now.array.length, positionLength, `the wake buffer resized on tick ${i}`);
      assert.equal(now.array, positionArray, `the wake buffer was reallocated on tick ${i}`);
      assert.equal(
        (line.geometry.getAttribute("color") as THREE.BufferAttribute).array.length,
        colorLength,
      );
    }
    // And it is `setDrawRange` that decides how much of it is read.
    assert.ok(line.geometry.drawRange.count > 0);
    assert.ok(line.geometry.drawRange.count <= positionLength / 3);
    layer.dispose();
  });

  it("holds two rails per wake at the Kelvin half-angle", () => {
    // 19.47 degrees regardless of speed. It is a real constant, not a tuned one,
    // and using it means the picture is right for a reason.
    assert.ok(Math.abs(KELVIN_HALF_ANGLE_DEG - (Math.asin(1 / 3) * 180) / Math.PI) < 0.01);

    const world = board();
    const layer = createVesselLayer(world, { span: 400 });
    layer.setVessels([underWay("a", { course: 0, bearing: 0 })]);
    const line = lines(layer.group)[0];
    assert.ok(line);
    const positions = line.geometry.getAttribute("position") as THREE.BufferAttribute;
    const count = line.geometry.drawRange.count;

    // Steaming due north, so the wake trails south (+z) and opens in x. The
    // widest pair of vertices should sit at the tail, at tan(19.47) of its
    // length either side.
    let widest = 0;
    let deepest = 0;
    const shipZ = world.project(33.72, -118.24)[1];
    for (let i = 0; i < count; i++) {
      widest = Math.max(widest, Math.abs(positions.getX(i)));
      deepest = Math.max(deepest, positions.getZ(i) - shipZ);
    }
    const expected = deepest * Math.tan((KELVIN_HALF_ANGLE_DEG * Math.PI) / 180);
    assert.ok(
      Math.abs(widest - expected) < expected * 0.25 + metresAcross(world, 45),
      `wake half-width ${widest.toFixed(3)} against ${expected.toFixed(3)} at the Kelvin angle`,
    );
    layer.dispose();
  });
});

// ---- A wake is a claim about motion ---------------------------------------

describe("a wake is speed through water, so a moored ship has none", () => {
  it("draws no wake behind a berthed hull", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([moored("a"), moored("b", { lat: 33.73 })]);
    assert.equal(layer.hullCount(), 2);
    assert.equal(layer.wakeCount(), 0);
    assert.equal(lines(layer.group).length, 0, "an empty wake buffer must not be visited");
    layer.dispose();
  });

  it("scales the wake with speed and hull length, and stops at a standstill", () => {
    assert.equal(wakeLengthMetres(0, 400), 0);
    assert.equal(wakeLengthMetres(0.1, 400), 0);
    // A 400 m ship at twelve knots: about 1.5 km, which is 3.84 units on SoCal
    // and the length at which a moving vessel reads from the whole-board pose.
    const full = wakeLengthMetres(6.17, 400);
    assert.ok(Math.abs(full - 1_500) < 20, `${full.toFixed(0)} m of wake behind a ULCV`);
    // A tug is not a container ship with a shorter name.
    assert.ok(wakeLengthMetres(6.17, 30) < 150);
    // Half speed, half wake.
    assert.ok(Math.abs(wakeLengthMetres(3.085, 400) - full / 2) < 1);
  });

  it("makes the wake longer than the hull it trails, which is the design claim", () => {
    const world = board();
    const hull = metresAcross(world, 400);
    const wake = metresAcross(world, wakeLengthMetres(6.17, 400));
    assert.ok(Math.abs(hull - 1.024) < 0.01, `a 400 m hull is ${hull.toFixed(3)} units`);
    assert.ok(wake / hull > 3.5, `the wake is only ${(wake / hull).toFixed(1)}x the hull`);
  });
});

// ---- The empty state ------------------------------------------------------

describe("the harbour with no ships in it", () => {
  it("contributes zero scene children for an empty list", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    assert.equal(layer.group.children.length, 0, "a layer nothing has answered must be empty");
    layer.setVessels([]);
    assert.equal(layer.group.children.length, 0, "the feed answered and this board is empty");
    assert.equal(layer.hullCount(), 0);
    assert.equal(layer.wakeCount(), 0);
    layer.dispose();
  });

  it("treats null and [] as the same picture", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([underWay("a")]);
    assert.equal(layer.group.children.length, 2);
    layer.setVessels(null);
    assert.equal(layer.group.children.length, 0);
    layer.dispose();
  });

  it("survives a tick, a lighting change and a dispose with nothing in it", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setLighting(DAYLIGHT);
    layer.tick(1 / 60);
    assert.equal(layer.group.children.length, 0);
    layer.dispose();
    assert.equal(layer.group.children.length, 0);
  });
});

// ---- Orientation and motion, through the scene graph ----------------------

describe("where a hull is drawn and which way it faces", () => {
  it("turns the bow to the bearing, with north at -z", () => {
    // A sign error here sails the whole fleet backwards and is completely
    // plausible in a still frame, which is why it is asserted rather than seen.
    assert.ok(Math.abs(bearingRotation(0)) < 1e-12);
    assert.ok(Math.abs(bearingRotation(90) + Math.PI / 2) < 1e-12);

    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([moored("a", { bearing: 90 })]);
    const mesh = instanced(layer.group)[0];
    assert.ok(mesh);
    const matrix = new THREE.Matrix4();
    mesh.getMatrixAt(0, matrix);
    // `decompose`, not `setFromRotationMatrix`: the instance matrix carries a
    // deliberately non-uniform scale — beam, depth, length — and reading a
    // quaternion straight off it folds the ship's proportions into its heading.
    const rotation = new THREE.Quaternion();
    matrix.decompose(new THREE.Vector3(), rotation, new THREE.Vector3());
    const bow = new THREE.Vector3(0, 0, -1).applyQuaternion(rotation);
    // Bearing 090 is due east, which on this board is +x.
    assert.ok(bow.x > 0.99, `the bow points ${bow.x.toFixed(3)} east`);
    assert.ok(Math.abs(bow.z) < 0.01);
    layer.dispose();
  });

  it("advances a hull under way along its course, and only along it", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([underWay("a", { course: 90, bearing: 90, speed: 6 })]);
    const start = layer.positionOf("a");
    assert.ok(start);

    for (let i = 0; i < 60; i++) layer.tick(1);
    const after = layer.positionOf("a");
    assert.ok(after);
    // Due east for a minute at 6 m/s: 360 m, which is 0.92 units on this board.
    assert.ok(after.x - start.x > 0.8, `moved ${(after.x - start.x).toFixed(3)} units east`);
    assert.ok(Math.abs(after.z - start.z) < 0.01, "a course of 090 changed the latitude");
    layer.dispose();
  });

  it("leaves a moored hull exactly where the feed put it, for ever", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([moored("a")]);
    const start = layer.positionOf("a");
    assert.ok(start);
    for (let i = 0; i < 600; i++) layer.tick(1);
    assert.deepEqual(layer.positionOf("a")?.toArray(), start.toArray());
    layer.dispose();
  });

  it("stops reckoning once the next fix is overdue", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([underWay("a", { course: 90, bearing: 90, speed: 6 })]);
    for (let i = 0; i < 900; i++) layer.tick(1);
    const atLimit = layer.positionOf("a");
    for (let i = 0; i < 3_600; i++) layer.tick(1);
    assert.deepEqual(layer.positionOf("a")?.toArray(), atLimit?.toArray());
    layer.dispose();
  });
});

// ---- Lighting -------------------------------------------------------------

describe("the layer owns no light", () => {
  it("adds no THREE.Light, at any hour", () => {
    const layer = createVesselLayer(board(), { span: 400 });
    layer.setVessels([underWay("a"), moored("b")]);
    layer.setLighting(DAYLIGHT);
    layer.setLighting({ ...DAYLIGHT, sun: { direction: [0, -0.2, 1], color: 0x223355, intensity: 0.1 } });
    let lights = 0;
    layer.group.traverse((object) => {
      if ((object as THREE.Light).isLight) lights += 1;
    });
    assert.equal(lights, 0, "CONTRACT §4 gives the rig to atmosphere.ts and to nothing else");
    layer.dispose();
  });
});
