/**
 * The harbour's working day: ships arriving, berthing, working and leaving.
 *
 * The owner's ask was "simulate the boats coming in", and the thing that makes
 * that hard is not the animation — it is that this board is supposed to be an
 * instrument. A harbour that is *alive* and a harbour that is *honest* pull in
 * opposite directions, and every assertion below is on the seam between them.
 *
 * Four properties carry the file:
 *
 * 1. **It is a plan, not a state.** `harbourCalls` never sees a clock;
 *    `modelHarbour` is a closed form of `atMs`. So two people on two machines
 *    see the same ships, `look.mjs --at` shoots the same frame twice, and — the
 *    part that is easy to lose — asking for a later instant and then an earlier
 *    one gives the earlier one back unchanged, because there is no accumulator
 *    anywhere to have moved. `server/wire.ts` makes exactly this argument for
 *    the simulated sky, and the sea is the same shape.
 *
 * 2. **`sog` is the derivative of the position it arrives with.** This is the
 *    seam that lets a real AIS feed replace the simulator without the renderer
 *    changing a line. A consumer is licensed to dead-reckon along a reported
 *    course at a reported speed, so a fix whose speed is not the derivative of
 *    its own track is a lie the renderer will draw faithfully. It is asserted
 *    numerically, against the simulator's own next position.
 *
 * 3. **A moored ship has no wake, and loses it the moment she is tied up.**
 *    A wake is a function of speed through water; at 391 m to the scene unit it
 *    is also most of what a ship *is* on this board. An arriving ship that kept
 *    her V alongside would be the layer claiming motion it has no evidence for.
 *
 * 4. **Nothing is drawn where a ship cannot float.** A hull lies half a beam off
 *    the wall rather than centred on it, and a berth the channel cannot reach
 *    gets no route at all rather than a straight line across a container yard.
 */

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

import {
  BERTH_APPROACH_REACH_METRES,
  MODELLED_INTERVAL_SECONDS,
  approachRun,
  berthAnchors,
  harbourCalls,
  harbourMoment,
  metresBetween,
  modelHarbour,
  promoteVessels,
  reckonVessel,
  vesselSummary,
} from "../../server/vessels.ts";
import {
  HULL_SHAPES,
  hullGeometry,
  hullShape,
  wakeLengthMetres,
} from "../../engine/vessels.ts";
import SOCAL from "../../cities/socal.ts";
import type { Port } from "../../engine/types.ts";

const PORTS = SOCAL.ports ?? [];
const ANCHORS = berthAnchors(PORTS);
const SEED = 115;
/** An arbitrary but fixed instant. Every sweep below is relative to it. */
const EPOCH = Date.parse("2026-08-23T20:00:00Z");

function harbourAt(atMs: number) {
  const body = modelHarbour(PORTS, { seed: SEED, atMs });
  return { body, promotion: promoteVessels(body, SOCAL.bounds, ANCHORS, atMs) };
}

/** Every call the two SoCal ports schedule, flattened. */
function allCalls() {
  return PORTS.flatMap((port: Port) => harbourCalls(port, { seed: SEED }));
}

describe("the harbour is a plan evaluated at an instant, not a thing that runs", () => {
  it("gives the same harbour twice for the same instant", () => {
    assert.deepEqual(harbourAt(EPOCH).body, harbourAt(EPOCH).body);
  });

  it("has no memory: going forward and coming back lands on the same harbour", () => {
    // The property a `setInterval` and an accumulator cannot have, and the one
    // that makes a scrubbed clock and a capture script agree. Ask for an hour
    // later, then two hours earlier, then the original instant again.
    const first = harbourAt(EPOCH).body;
    harbourAt(EPOCH + 3_600_000);
    harbourAt(EPOCH - 7_200_000);
    assert.deepEqual(harbourAt(EPOCH).body, first);
  });

  it("is seeded, so a different seed is a different harbour", () => {
    const a = modelHarbour(PORTS, { seed: 115, atMs: EPOCH });
    const b = modelHarbour(PORTS, { seed: 116, atMs: EPOCH });
    assert.notDeepEqual(a.vessels, b.vessels);
  });

  it("says out loud that the rate of arrivals is modelled and the speeds are not", () => {
    // The SEA panel's standard: name what is drawn from the board's own geometry
    // and name the one thing that is a modelling choice, in the same breath.
    const { body, promotion } = harbourAt(EPOCH);
    const attribution = (body.attribution ?? []).join(" ");
    assert.match(attribution, /Not an observation of any vessel/);
    assert.match(attribution, /compressed/);
    assert.match(vesselSummary(promotion), /Modelled from this board's own berths and channels/);
    assert.match(vesselSummary(promotion), /no names and no MMSIs/);
    // The panel, not just the licence sheet: a reader watching a berth change
    // hands twice in an afternoon must be told that rate is the modelled part.
    assert.match(vesselSummary(promotion), /arrivals run at about seven times a real day's rate/);
    assert.match(vesselSummary(promotion), /Ship speeds, the channel and the berths are true/);
  });
});

describe("a working day: in through the channel, alongside, and out again", () => {
  it("walks every berth through inbound, alongside, outbound and empty, in that order", () => {
    const call = allCalls()[0];
    assert.ok(call, "no berth was given a call at all");
    const seen: string[] = [];
    for (let i = 0; i < 600; i++) {
      const phase = harbourMoment(call, call.offsetSeconds + (i * call.cycleSeconds) / 600).phase;
      if (seen[seen.length - 1] !== phase) seen.push(phase);
    }
    assert.deepEqual(seen, ["inbound", "alongside", "outbound", "empty"]);
  });

  it("brings an arriving ship steadily closer to her berth and never past it", () => {
    const call = allCalls()[0];
    assert.ok(call);
    const berth = call.approach[call.approach.length - 1];
    assert.ok(berth);
    let previous = Infinity;
    for (let i = 0; i <= 50; i++) {
      const at = call.offsetSeconds + (i / 50) * call.inboundSeconds * 0.999;
      const moment = harbourMoment(call, at);
      assert.equal(moment.phase, "inbound");
      const ship = shipOf(call, at);
      assert.ok(ship, "an inbound leg with no ship on it");
      const metres = metresBetween(ship.lat, ship.lon, berth[0], berth[1]);
      assert.ok(metres <= previous + 1, `the ship went backwards at ${i}`);
      previous = metres;
    }
    // And arrives: within a ship's length of the quay by the end of the leg.
    assert.ok(previous < 200, `she stopped ${previous.toFixed(0)} m short`);
  });

  it("gives a berth a fresh ship each cycle rather than the same one for ever", () => {
    const call = allCalls()[0];
    assert.ok(call);
    const first = harbourMoment(call, call.offsetSeconds + 60);
    const next = harbourMoment(call, call.offsetSeconds + call.cycleSeconds + 60);
    assert.equal(next.index, first.index + 1);
    assert.notEqual(
      shipOf(call, call.offsetSeconds + 60)?.id,
      shipOf(call, call.offsetSeconds + call.cycleSeconds + 60)?.id,
    );
  });

  it("keeps the channel busy without turning it into a parade", () => {
    // Swept rather than sampled once: the interesting failure is a schedule that
    // is right at one instant and has nine ships in one channel at another.
    let most = 0;
    let fewest = Infinity;
    for (let minute = 0; minute < 240; minute += 5) {
      const { promotion } = harbourAt(EPOCH + minute * 60_000);
      most = Math.max(most, promotion.makingWay);
      fewest = Math.min(fewest, promotion.makingWay);
      assert.ok(promotion.drawn.length <= 32, `${promotion.drawn.length} hulls on the board`);
      assert.equal(promotion.offBoard, 0, "a modelled hull fell off its own board");
      assert.equal(promotion.withoutOrientation, 0, "a modelled hull would not say which way it faced");
    }
    assert.ok(fewest >= 1, "the harbour went completely still");
    assert.ok(most <= 12, `${most} hulls under way at once is a parade`);
  });
});

describe("the seam a real AIS feed has to slot into", () => {
  it("reports a speed that is the derivative of the position it reports", () => {
    /**
     * The load-bearing assertion in this file.
     *
     * `reckonVessel` advances a fix along its own reported course at its own
     * reported speed, and that is the only motion the renderer is licensed to
     * draw. So for every moving hull the simulator emits, dead-reckoning it one
     * declared interval forward has to land near where the simulator itself puts
     * it an interval later. The tolerance is a scene unit — 391 m on SoCal —
     * because the ship is also *turning* over that interval and a straight
     * course cannot follow a bend. That residual is a true property of a
     * course-and-speed feed and is exactly why the declared interval is a minute
     * and not the fifteen a real listener will send.
     */
    const step = MODELLED_INTERVAL_SECONDS;
    const before = harbourAt(EPOCH).body.vessels;
    const after = new Map(harbourAt(EPOCH + step * 1000).body.vessels.map((v) => [v.id, v]));
    let checked = 0;
    for (const fix of before) {
      if (fix.speed < 0.257 || fix.course === null) continue;
      const later = after.get(fix.id);
      if (!later) continue;
      const reckoned = reckonVessel(
        { lat: fix.lat, lng: fix.lon, speed: fix.speed, course: fix.course },
        step,
      );
      const error = metresBetween(reckoned.lat, reckoned.lng, later.lat, later.lon);
      assert.ok(error < 391, `${fix.id} dead-reckoned ${error.toFixed(0)} m off its own next fix`);
      checked += 1;
    }
    assert.ok(checked >= 2, `only ${checked} moving hulls were checkable`);
  });

  it("declares a minute, because a straight course cannot follow a bent channel", () => {
    assert.equal(harbourAt(EPOCH).body.intervalSeconds, MODELLED_INTERVAL_SECONDS);
    assert.ok(MODELLED_INTERVAL_SECONDS <= 60);
  });

  it("never volunteers a heading, so the berths keep doing the orienting", () => {
    for (const fix of harbourAt(EPOCH).body.vessels) assert.equal(fix.heading, null);
    const { promotion } = harbourAt(EPOCH);
    assert.ok(promotion.alongside > 0, "no modelled hull found its berth");
  });

  it("carries no name, no MMSI, no callsign and no destination", () => {
    for (const fix of harbourAt(EPOCH).body.vessels) {
      for (const forbidden of ["name", "mmsi", "callsign", "destination", "laden"]) {
        assert.equal(forbidden in fix, false, `a modelled vessel carried ${forbidden}`);
      }
    }
  });
});

describe("the wake is the ship, so a berthed ship must not have one", () => {
  it("drops the wake to nothing in the same fix that puts her alongside", () => {
    const call = allCalls()[0];
    assert.ok(call);
    // The last minute of the run in, then the first of the lie alongside.
    const arriving = shipOf(call, call.offsetSeconds + call.inboundSeconds * 0.98);
    const berthed = shipOf(call, call.offsetSeconds + call.inboundSeconds + 60);
    assert.ok(arriving && berthed);
    assert.ok(wakeLengthMetres(arriving.speed, arriving.length ?? 300) >= 0);
    assert.equal(berthed.speed, 0);
    assert.equal(wakeLengthMetres(berthed.speed, berthed.length ?? 300), 0);
  });

  it("leaves no hull at rest with a wake anywhere on the board, at any hour", () => {
    for (let minute = 0; minute < 240; minute += 7) {
      for (const drawn of harbourAt(EPOCH + minute * 60_000).promotion.drawn) {
        if (drawn.berthId === undefined) continue;
        assert.equal(drawn.speed, 0, `${drawn.id} is alongside and moving`);
        assert.equal(wakeLengthMetres(drawn.speed, drawn.length), 0);
      }
    }
  });

  it("gives an arriving ship a wake long enough to read at board scale", () => {
    // 391 m to the unit: a 300 m hull is 0.77 units and a fleck, and its wake at
    // full speed is over three units and is the thing that says "moving".
    const call = allCalls().find((c) => c.approachMetres > 6000);
    assert.ok(call);
    const early = shipOf(call, call.offsetSeconds + call.inboundSeconds * 0.1);
    assert.ok(early);
    const wake = wakeLengthMetres(early.speed, early.length ?? 300);
    assert.ok(wake > 3 * 391, `an entering ship's wake is only ${wake.toFixed(0)} m`);
  });
});

describe("nothing is drawn where a ship cannot float", () => {
  it("lies a berthed hull off the wall rather than through it", () => {
    let checked = 0;
    for (let minute = 0; minute < 180; minute += 11) {
      for (const drawn of harbourAt(EPOCH + minute * 60_000).promotion.drawn) {
        if (drawn.berthId === undefined) continue;
        const anchor = ANCHORS.find((a) => a.id === drawn.berthId);
        if (!anchor) continue;
        const call = allCalls().find((c) => c.berthId === drawn.berthId);
        if (!call) continue; // an unscheduled berth still lies on its own point
        const off = metresBetween(drawn.lat, drawn.lng, anchor.lat, anchor.lng);
        assert.ok(off >= drawn.beam / 2, `${drawn.id} is ${off.toFixed(0)} m off a ${drawn.beam} m beam`);
        checked += 1;
      }
    }
    assert.ok(checked > 0, "no scheduled berth was ever occupied");
  });

  it("refuses to route to a berth the channel does not reach", () => {
    // Los Angeles' East Basin is three kilometres off the Main Channel, and the
    // straight line between them crosses Terminal Island. A berth like that
    // keeps a hull alongside and takes no calls — see the constant's own note.
    const lax = PORTS.find((p: Port) => p.id === "USLAX");
    assert.ok(lax);
    const scheduled = new Set(harbourCalls(lax, { seed: SEED }).map((c) => c.berthId));
    assert.ok(scheduled.has("lax-401"), "Pier 400 is on the channel and should be worked");
    assert.equal(scheduled.has("lax-232"), false, "the East Basin was routed to across the island");
    // And the quay is not left bare: the unreachable berths still carry hulls.
    const alongside = harbourAt(EPOCH).promotion.drawn.filter((v) => v.berthId?.startsWith("lax-2"));
    assert.ok(alongside.length > 0, "the East Basin came out empty");
  });

  it("keeps every routed berth inside the reach the routing promises", () => {
    for (const call of allCalls()) {
      const berth = call.approach[call.approach.length - 1];
      const from = call.approach[call.approach.length - 2];
      assert.ok(berth && from);
      const cross = metresBetween(berth[0], berth[1], from[0], from[1]);
      assert.ok(
        cross <= BERTH_APPROACH_REACH_METRES + 1,
        `${call.berthId} runs ${cross.toFixed(0)} m off the channel`,
      );
    }
  });
});

describe("the tug, which is the hull that is usually moving", () => {
  it("comes out to meet an arriving ship and is gone once she is tied up", () => {
    const call = allCalls()[0];
    assert.ok(call);
    const tugAt = (x: number) =>
      modelHarbour(PORTS, { seed: SEED, atMs: (call.offsetSeconds + x * call.inboundSeconds) * 1000 })
        .vessels.find((v) => v.id === `m-${call.portId}:${call.berthId}:0:tug`);
    assert.equal(tugAt(0.2), undefined, "a tug was out before there was anything to meet");
    assert.ok(tugAt(0.7), "no tug met the arriving ship");
    assert.equal(tugAt(0.99), undefined, "the tug was still under way after she berthed");
  });

  it("runs seaward to make the meeting, against the ship it is meeting", () => {
    // The sub-leg worth having: two wakes crossing in opposite directions is a
    // harbour doing something, where a tug that materialises alongside is a decal.
    const call = allCalls()[0];
    assert.ok(call);
    const at = (call.offsetSeconds + 0.47 * call.inboundSeconds) * 1000;
    const fleet = modelHarbour(PORTS, { seed: SEED, atMs: at }).vessels;
    const ship = fleet.find((v) => v.id === `m-${call.portId}:${call.berthId}:0`);
    const tug = fleet.find((v) => v.id === `m-${call.portId}:${call.berthId}:0:tug`);
    assert.ok(ship && tug, "no ship-and-tug pair at the meeting");
    assert.ok(ship.course !== null && tug.course !== null);
    const between = Math.abs(((((tug.course - ship.course) % 360) + 540) % 360) - 180);
    assert.ok(between > 120, `the tug is running with her, ${between.toFixed(0)} apart`);
    assert.ok(tug.speed > ship.speed * 0.5, "the tug is loitering rather than running");
  });

  it("is drawn from its own solid, which is one more draw call and only when it floats", () => {
    assert.equal(hullShape("tug"), "tug");
    // Three shapes now: `container` left `generic` for its own solid, for the
    // reason `containerHull` documents — a stack is what makes a box ship read
    // on a board that multiplies heights by 3.4 and lengths by one. Every other
    // kind is still the merchant hull, which is the whole point of the seam.
    assert.equal(hullShape("container"), "container");
    assert.equal(hullShape("tanker"), "generic");
    assert.equal(hullShape("bulk"), "generic");
    assert.equal(HULL_SHAPES.length, 3, "a fourth hull shape is a fourth draw call");
    const tug = hullGeometry("tug");
    const triangles = tug.getAttribute("position").count / 3;
    assert.equal(triangles, 64, `the tug is ${triangles} triangles`);
    for (const attribute of ["position", "normal", "uv"]) {
      assert.ok(tug.getAttribute(attribute), `the tug hull has no ${attribute}`);
    }
    tug.dispose();
  });

  it("gives the box ship a stack, and takes not one metre of length for it", () => {
    /*
     * The defect this shape exists for is that a hull is flat while everything
     * around it is stretched 3.4x, so the fix has to buy height and nothing
     * else. A solid that grew in x or z would overhang the berth it lies in —
     * which is the failure `vesselScale.test.ts` guards the *scale* against, and
     * this is the same guard on the *geometry*.
     */
    const box = hullGeometry("container");
    const generic = hullGeometry("generic");
    const bounds = (g: THREE.BufferGeometry) => {
      g.computeBoundingBox();
      const b = g.boundingBox;
      assert.ok(b);
      return b;
    };
    const a = bounds(box);
    const b = bounds(generic);
    assert.ok(a.max.x <= b.max.x + 1e-9, `the stack is ${a.max.x} wide against ${b.max.x}`);
    assert.ok(a.min.x >= b.min.x - 1e-9, "the stack hangs over the port side");
    assert.ok(a.max.z <= b.max.z + 1e-9 && a.min.z >= b.min.z - 1e-9, "the solid grew fore-aft");
    // And it does buy height: eight tiers of high-cube over the hatches, which
    // is what triples the above-water mass the exaggeration then multiplies.
    assert.ok(a.max.y > 1.85, `only ${a.max.y} of height, so the stack is missing`);

    const triangles = box.getAttribute("position").count / 3;
    assert.equal(triangles, 76, `the box ship is ${triangles} triangles`);
    for (const attribute of ["position", "normal", "uv"]) {
      assert.ok(box.getAttribute(attribute), `the box hull has no ${attribute}`);
    }
    box.dispose();
    generic.dispose();
  });

  it("winds the box ship outward too, by the merchant hull's own flux test", () => {
    /*
     * Three more boxes is thirty-six more chances to wind a face inward, and an
     * inward face reads as "the ships are a bit dark" rather than as a hole —
     * which is how the first version of the merchant deck shipped.
     */
    const geometry = hullGeometry("container");
    const position = geometry.getAttribute("position");
    let flux = 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);
    }
    assert.ok(flux > 0, `the box hull encloses ${flux.toFixed(3)} — a face is inside out`);
    geometry.dispose();
  });

  it("is wound outward, by the same flux test the merchant hull is held to", () => {
    const geometry = hullGeometry("tug");
    const position = geometry.getAttribute("position");
    let flux = 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);
    }
    assert.ok(flux > 0, `the tug hull encloses ${flux.toFixed(3)} — a face is inside out`);
    geometry.dispose();
  });

  it("carries the mass forward, which is the whole of the silhouette", () => {
    /**
     * The one measurement that separates a tug from a small freighter, asserted
     * because "tidy up the hull" is exactly the change that would undo it. The
     * bow is at -z, so the superstructure's centre of area must sit *forward* of
     * amidships — the opposite of the merchant hull, whose house and funnel are
     * aft over the screw.
     */
    const centroid = (shape: "generic" | "tug") => {
      const position = hullGeometry(shape).getAttribute("position");
      let sum = 0;
      let n = 0;
      for (let i = 0; i < position.count; i++) {
        if (position.getY(i) <= 1.05) continue; // hull only below the main deck
        sum += position.getZ(i);
        n += 1;
      }
      return n > 0 ? sum / n : 0;
    };
    assert.ok(centroid("tug") < 0, "the tug's house is aft, which makes it a small ship");
    assert.ok(centroid("generic") > 0, "the merchant house moved forward");
  });
});

// ---- Helpers ---------------------------------------------------------------

/** The ship this call has on the board at `atSeconds`, from the whole body. */
function shipOf(call: ReturnType<typeof allCalls>[number], atSeconds: number) {
  const moment = harbourMoment(call, atSeconds);
  return modelHarbour(PORTS, { seed: SEED, atMs: atSeconds * 1000 }).vessels.find(
    (v) => v.id === `m-${call.portId}:${call.berthId}:${moment.index}`,
  );
}

describe("the arithmetic under the run in", () => {
  it("enters at speed and arrives at nothing", () => {
    const fast = approachRun(9000, 2150, 0.02, "inbound");
    const slow = approachRun(9000, 2150, 0.98, "inbound");
    assert.ok(fast.speedMps > 6, `entering at ${fast.speedMps.toFixed(1)} m/s`);
    assert.ok(slow.speedMps < 1, `berthing at ${slow.speedMps.toFixed(1)} m/s`);
    assert.ok(fast.arcMetres < slow.arcMetres);
  });

  it("leaves at nothing and departs at speed, which is the run in backwards", () => {
    const slipping = approachRun(9000, 2000, 0.02, "outbound");
    const away = approachRun(9000, 2000, 0.98, "outbound");
    assert.ok(slipping.speedMps < 1);
    assert.ok(away.speedMps > 6);
    // Arc is measured from the seaward end, so a departure counts down.
    assert.ok(slipping.arcMetres > away.arcMetres);
  });

  it("is total: a zero-length or zero-time leg is still a number", () => {
    assert.deepEqual(approachRun(0, 100, 0.5, "inbound"), { arcMetres: 0, speedMps: 0 });
    assert.deepEqual(approachRun(100, 0, 0.5, "outbound"), { arcMetres: 0, speedMps: 0 });
  });
});
