/**
 * The vessel gate: three AIS sentinels, one berth, and the position that must
 * never exist.
 *
 * Every assertion in this file is about a defect that **typechecks, throws
 * nothing and renders a perfectly plausible harbour**. That is why they are
 * here rather than left to a picture: a fleet of ships all facing due north
 * looks like a design choice, a hull three kilometres out to sea looks like a
 * hull three kilometres out to sea, and a ship cutting the corner of a
 * breakwater looks like a ship. None of the three is visible in a still frame
 * and all three are wrong.
 *
 * The load-bearing one is `cog 358.7 survives`. `cog % 360` is the obvious
 * normalisation, it is what anybody would write, and it silently converts the
 * "not available" sentinel — exactly 360.0 — into a course of zero, due north.
 * Real course over ground reaches 358.7 in the store behind this feed, so a
 * range check cannot separate them either: the value, and only the value, can.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
  AIS_COURSE_UNAVAILABLE,
  AIS_HEADING_UNAVAILABLE,
  AIS_SOG_UNAVAILABLE_KN,
  KNOTS_TO_MPS,
  VESSEL_MAKING_WAY_MPS,
  aisCourse,
  aisHeading,
  aisSpeedMps,
  berthAnchors,
  isMakingWay,
  metresBetween,
  modelHarbour,
  MODELLED_INTERVAL_SECONDS,
  promoteVessels,
  reckonVessel,
  resolveBearing,
  vesselStatus,
  vesselSummary,
  type BerthAnchor,
  type VesselBounds,
} from "../../server/vessels.ts";
import type { Port } from "../../engine/types.ts";
import type { VesselsBody, WireVessel } from "../../server/wire.ts";

// ---- Fixtures --------------------------------------------------------------

const SAN_PEDRO: VesselBounds = {
  minLat: 33.55,
  maxLat: 33.85,
  minLng: -118.4,
  maxLng: -118.0,
};

/**
 * One berth on Pier 400, bearing 118° — a hull lying alongside it points
 * east-south-east. Hand-typed like everything else on these boards; the point of
 * it here is that the number is *authored* and therefore known before any ship
 * arrives.
 */
const BERTH: BerthAnchor = { id: "uslax-p400-a", lat: 33.72, lng: -118.24, bearing: 118 };

function wire(overrides: Partial<WireVessel> = {}): WireVessel {
  return {
    id: "w-1",
    kind: "container",
    lat: 33.72,
    lon: -118.24,
    speed: 0,
    course: null,
    heading: null,
    navStatus: 5,
    length: 300,
    beam: 45,
    ageSeconds: 0,
    ...overrides,
  };
}

function body(vessels: WireVessel[], source: VesselsBody["source"] = "cloud1"): VesselsBody {
  return {
    source,
    fetchedAt: "2026-08-22T02:00:00.000Z",
    vessels,
    intervalSeconds: 900,
    ttlSeconds: 900,
  };
}

// ---- The three sentinels ---------------------------------------------------

describe("the AIS sentinels, none of which is ever NULL", () => {
  it("rejects sog 102.3, which is 'not available' and 52.6 m/s", () => {
    assert.equal(aisSpeedMps(AIS_SOG_UNAVAILABLE_KN), null);
    assert.equal(aisSpeedMps(102.3), null);
    // Sixty seconds of it is 3.2 km — eight SoCal scene units — which is the
    // whole reason this is a rejection and not a clamp.
    assert.ok(AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS * 60 > 3_000);
  });

  it("keeps a real speed, including the zero half the fleet reports", () => {
    assert.equal(aisSpeedMps(0), 0);
    assert.ok(Math.abs((aisSpeedMps(12) ?? 0) - 12 * KNOTS_TO_MPS) < 1e-9);
    // Zero is an answer and not an absence: 554 of 1,138 fixes in the store are
    // exactly 0.0, and conflating them with "unknown" would stop every moored
    // ship in the product being drawn.
    assert.notEqual(aisSpeedMps(0), null);
  });

  it("rejects heading 511, which 40% of fixes carry", () => {
    assert.equal(aisHeading(AIS_HEADING_UNAVAILABLE), null);
    assert.equal(aisHeading(511), null);
    assert.equal(aisHeading(0), 0);
    assert.equal(aisHeading(359.9), 359.9);
  });

  it("rejects cog exactly 360.0", () => {
    assert.equal(aisCourse(AIS_COURSE_UNAVAILABLE), null);
    assert.equal(aisCourse(360), null);
    assert.equal(aisCourse(360.0), null);
  });

  it("ACCEPTS cog 358.7 — the assertion this whole file exists for", () => {
    // Real course over ground reaches 358.7, and 355.0, 355.4, 355.7, 356.3,
    // 356.9 and 357.0 all occur in the same store. A naive range check near
    // north, or a `% 360`, eats every one of them or turns the sentinel into a
    // course. Neither may happen.
    assert.equal(aisCourse(358.7), 358.7);
    for (const cog of [355.0, 355.4, 355.7, 356.3, 356.9, 357.0, 358.7, 359.99]) {
      assert.equal(aisCourse(cog), cog, `real course ${cog} was eaten by the gate`);
    }
    // And the trap itself, stated so nobody reintroduces it: `% 360` maps the
    // sentinel onto a perfectly good course.
    assert.equal(AIS_COURSE_UNAVAILABLE % 360, 0);
    assert.notEqual(aisCourse(AIS_COURSE_UNAVAILABLE), 0);
  });

  it("survives garbage without throwing", () => {
    for (const bad of [null, undefined, Number.NaN, Infinity, -1]) {
      assert.equal(aisSpeedMps(bad as number), null);
      assert.equal(aisHeading(bad as number), null);
      assert.equal(aisCourse(bad as number), null);
    }
  });
});

// ---- Motion is gated on speed, never on nav_status -------------------------

describe("speed gates the motion; nav_status only labels it", () => {
  it("treats nav_status 0 at 0.2 kn as stopped", () => {
    // 83 of 197 vessels reporting "under way using engine" are under half a
    // knot. The status is a word on a card; the speed is the fact.
    const speed = aisSpeedMps(0.2);
    assert.notEqual(speed, null);
    assert.equal(isMakingWay(speed), false);
    assert.equal(vesselStatus(0), "under-way");

    const promotion = promoteVessels(
      body([wire({ lat: 33.7, lon: -118.2, speed: 0.2 * KNOTS_TO_MPS, navStatus: 0, course: 90 })]),
      SAN_PEDRO,
      [],
    );
    const drawn = promotion.drawn[0];
    assert.ok(drawn, "the hull was dropped rather than drawn stopped");
    assert.equal(drawn.status, "under-way", "the label must survive");
    assert.equal(drawn.speed, 0, "the motion must not");
    assert.equal(promotion.makingWay, 0);
  });

  it("lets a hull at 6 kn make way", () => {
    const promotion = promoteVessels(
      body([wire({ lat: 33.7, lon: -118.2, speed: 6 * KNOTS_TO_MPS, navStatus: 0, course: 210 })]),
      SAN_PEDRO,
      [],
    );
    assert.equal(promotion.makingWay, 1);
    assert.ok((promotion.drawn[0]?.speed ?? 0) >= VESSEL_MAKING_WAY_MPS);
  });

  it("refuses a wire speed that is the sentinel in metres per second", () => {
    // The upstream half of this feed is in another repo on another box and does
    // not exist yet, so "already stripped" is a promise nobody can keep today.
    const promotion = promoteVessels(
      body([wire({ speed: AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS })]),
      SAN_PEDRO,
      [BERTH],
    );
    assert.equal(promotion.drawn.length, 0);
    assert.equal(promotion.suppressed, 1);
  });
});

// ---- Orientation comes from the berth --------------------------------------

describe("a berthed hull points the way its quay does", () => {
  it("gives a stopped hull with no heading and no cog its berth's bearing", () => {
    // 21 of 150 stopped vessels have neither heading nor cog, and only 75 of the
    // 150 have a heading at all. This is the case the layer must not be spun by.
    const promotion = promoteVessels(
      body([wire({ lat: BERTH.lat, lon: BERTH.lng, heading: null, course: null, speed: 0 })]),
      SAN_PEDRO,
      [BERTH],
    );
    const drawn = promotion.drawn[0];
    assert.ok(drawn, "a hull with no orientation from the wire was dropped");
    assert.equal(drawn.bearing, BERTH.bearing);
    assert.equal(drawn.berthId, BERTH.id);
    assert.equal(promotion.alongside, 1);
  });

  it("lets the wire nudge a berthed hull, and never swing it", () => {
    const nudged = resolveBearing({ heading: 121, course: null, speedMps: 0, berthBearing: 118 });
    assert.equal(nudged, 121);
    // A heading 180 degrees off the quay is an AIS unit that is wrong about
    // which end is the bow, not a ship moored backwards. The concrete wins.
    const absurd = resolveBearing({ heading: 298, course: null, speedMps: 0, berthBearing: 118 });
    assert.ok(absurd !== null && Math.abs(absurd - 118) <= 3, `berth bearing was swung to ${absurd}`);
  });

  it("uses the course for a hull making way, and the heading for one at anchor", () => {
    assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 5, berthBearing: null }), 210);
    assert.equal(resolveBearing({ heading: 30, course: 210, speedMps: 0, berthBearing: null }), 30);
  });

  it("withholds a hull nothing will orient, rather than inventing an angle", () => {
    const promotion = promoteVessels(
      body([wire({ lat: 33.6, lon: -118.1, heading: null, course: null, speed: 0 })]),
      SAN_PEDRO,
      [BERTH],
    );
    assert.equal(promotion.drawn.length, 0);
    assert.equal(promotion.withoutOrientation, 1);
    assert.equal(promotion.suppressed, 0, "an unoriented hull is not the same as an unreadable one");
    assert.match(vesselSummary(promotion), /which way/);
  });

  it("does not reach across the harbour for a berth", () => {
    // A berth is one authored point and the reach is 400 m; a hull a kilometre
    // away is not lying on it.
    assert.ok(metresBetween(BERTH.lat, BERTH.lng, 33.73, -118.24) > 400);
    const promotion = promoteVessels(
      body([wire({ lat: 33.73, lon: -118.24, heading: 44, course: null, speed: 0 })]),
      SAN_PEDRO,
      [BERTH],
    );
    assert.equal(promotion.drawn[0]?.bearing, 44, "the wire heading should stand off the berth");
    assert.equal(promotion.drawn[0]?.berthId, undefined);
  });
});

// ---- Dead reckoning, and the position that must not exist ------------------

describe("motion is along the reported course and never between two fixes", () => {
  /**
   * Two fixes fifteen minutes apart, on a hull that turned.
   *
   * `A` reports a course of 090 — due east — and eight knots. Fifteen minutes
   * later it is reported at `B`, which is to the *south* east, because it came
   * round the breakwater in between. The chord from A to B is therefore a line
   * nothing sailed, and every point on it except the ends is a place the ship
   * never was.
   */
  const A = { lat: 33.72, lng: -118.28, speed: 8 * KNOTS_TO_MPS, course: 90 };
  const B = { lat: 33.69, lng: -118.2 };

  it("advances along the course at the speed", () => {
    const after = reckonVessel(A, 450);
    const metres = metresBetween(A.lat, A.lng, after.lat, after.lng);
    assert.ok(
      Math.abs(metres - A.speed * 450) < 1,
      `advanced ${metres.toFixed(1)} m instead of ${(A.speed * 450).toFixed(1)}`,
    );
    // Due east means the latitude does not move.
    assert.ok(Math.abs(after.lat - A.lat) < 1e-9, "a course of 090 changed the latitude");
    assert.ok(after.lng > A.lng, "a course of 090 went west");
  });

  it("does not arrive at the second fix, which is what an interpolator does", () => {
    // The crispest statement of the rule. An interpolator hands back exactly `B`
    // at the end of the interval; a dead-reckoner hands back wherever the
    // reported course took the ship, which here is five kilometres away because
    // the ship turned and the course did not say so until the next fix.
    const atInterval = reckonVessel(A, 900);
    const missBy = metresBetween(atInterval.lat, atInterval.lng, B.lat, B.lng);
    assert.ok(missBy > 3_000, `reckoning landed ${missBy.toFixed(0)} m from the second fix`);
  });

  it("never lands on the chord between two fixes", () => {
    // The geometric statement of "no interpolation": walk the interval and
    // assert every reckoned point stays clear of the segment A->B. It cannot be
    // otherwise, because `reckonVessel` is handed one fix and has no second
    // point to reach toward — but that is the property being pinned.
    //
    // From 150 s rather than from zero, because the first fix *is* an endpoint
    // of the chord and the ship genuinely was there: a clearance test that
    // started at t=0 would be asserting the ship was never at its own reported
    // position.
    let closest = Infinity;
    for (let t = 150; t <= 900; t += 30) {
      const at = reckonVessel(A, t);
      closest = Math.min(closest, metresToSegment(at, A, B));
    }
    assert.ok(
      closest > 200,
      `a reckoned position came within ${closest.toFixed(0)} m of the chord between two fixes`,
    );
  });

  it("stops rather than sailing on for ever once the next fix is overdue", () => {
    const atLimit = reckonVessel(A, 900);
    const wayPast = reckonVessel(A, 4_000);
    assert.deepEqual(wayPast, atLimit);
  });

  it("does not move a hull with no course, whatever its speed says", () => {
    const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 6, course: null }, 600);
    assert.deepEqual(still, { lat: 33.72, lng: -118.28 });
  });

  it("does not move a stopped hull", () => {
    const still = reckonVessel({ lat: 33.72, lng: -118.28, speed: 0, course: 90 }, 600);
    assert.deepEqual(still, { lat: 33.72, lng: -118.28 });
  });
});

// ---- The empty state, designed first ---------------------------------------

describe("the harbour with nothing in it says which kind of nothing it is", () => {
  it("distinguishes an unconfigured feed from an empty board", () => {
    const unconfigured = promoteVessels(null, SAN_PEDRO, []);
    assert.equal(unconfigured.source, "none");
    assert.match(vesselSummary(unconfigured), /No vessel feed is configured/i);

    const answered = promoteVessels(body([]), SAN_PEDRO, []);
    assert.equal(answered.source, "cloud1");
    assert.match(vesselSummary(answered), /answered/i);
  });

  it("counts what it withheld rather than going blank", () => {
    const promotion = promoteVessels(
      body([
        wire({ id: "off", lat: 30.0, lon: -118.2, heading: 10 }),
        wire({ id: "bad", lat: Number.NaN, heading: 10 }),
        wire({ id: "blind", lat: 33.6, lon: -118.1, heading: null, course: null }),
      ]),
      SAN_PEDRO,
      [],
    );
    assert.equal(promotion.drawn.length, 0);
    assert.equal(promotion.offBoard, 1);
    assert.equal(promotion.suppressed, 1);
    assert.equal(promotion.withoutOrientation, 1);
    const summary = vesselSummary(promotion);
    assert.match(summary, /outside the frame/);
    assert.match(summary, /unreadable/);
  });

  it("never labels a hull laden or in ballast", () => {
    const promotion = promoteVessels(
      body([wire({ lat: BERTH.lat, lon: BERTH.lng })]),
      SAN_PEDRO,
      [BERTH],
    );
    // The owner asked "whether they are empty or full". The honest answer is a
    // port figure — 348,691 of 460,467 boxes left Los Angeles empty in July
    // 2026 — and it is never attached to a hull, because `vessels` carries no
    // draught column and the static AIS message is absent for most ships.
    assert.match(vesselSummary(promotion), /not a ship one/);
    assert.equal("draught" in (promotion.drawn[0] ?? {}), false);
  });
});

// ---- The modelled harbour --------------------------------------------------

const PORT: Port = {
  id: "USLAX",
  name: "Port of Los Angeles",
  lat: 33.73,
  lng: -118.26,
  harborType: "CB",
  channel: [
    [33.705, -118.26],
    [33.72, -118.255],
    [33.74, -118.25],
  ],
  berths: [
    { id: "a", lat: 33.735, lng: -118.262, bearing: 118, maxLength: 400 },
    { id: "b", lat: 33.737, lng: -118.259, bearing: 118, maxLength: 400 },
    { id: "c", lat: 33.739, lng: -118.256, bearing: 118, maxLength: 340 },
    { id: "d", lat: 33.741, lng: -118.253, bearing: 296, maxLength: 120 },
  ],
};

describe("the modelled harbour, which is what runs this round", () => {
  it("is deterministic — two people see the same ships", () => {
    const a = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 });
    const b = modelHarbour([PORT], { seed: 115, atMs: 1_000_000 });
    assert.deepEqual(a, b);
    const other = modelHarbour([PORT], { seed: 116, atMs: 1_000_000 });
    assert.notDeepEqual(other.vessels.map((v) => v.id), []);
  });

  it("carries no name, no MMSI, no callsign and no destination", () => {
    // The store has CSCL INDIAN OCEAN and EVER LOVELY in it right now, and
    // hardcoding them would be the fire layer's twenty-two orange marks in a
    // nicer costume. Identity arrives with a licence entry or not at all.
    for (const vessel of modelHarbour([PORT], { seed: 115 }).vessels) {
      for (const forbidden of ["name", "mmsi", "callsign", "destination", "draught", "laden"]) {
        assert.equal(forbidden in vessel, false, `a modelled vessel carried ${forbidden}`);
      }
    }
  });

  it("says it is modelled, and the panel says so too", () => {
    const modelled = modelHarbour([PORT], { seed: 115 });
    assert.equal(modelled.source, "modelled");
    /**
     * A minute, not the fifteen a real AIS listener declares.
     *
     * `intervalSeconds` is a property of the *source*, and this source is a
     * closed form of the clock that can be asked for any instant — see
     * `MODELLED_INTERVAL_SECONDS`. Fifteen minutes was mimicry, and it drew
     * arriving ships over Terminal Island, because a consumer dead-reckons along
     * a straight course and the Main Channel bends. A real feed still arrives
     * declaring its own 900 and is still dead-reckoned for 900.
     */
    assert.equal(modelled.intervalSeconds, MODELLED_INTERVAL_SECONDS);
    const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
    assert.match(vesselSummary(promotion), /Modelled/);
    assert.match(vesselSummary(promotion), /no names and no MMSIs/);
  });

  it("volunteers no heading, so the berths have to do the work", () => {
    const modelled = modelHarbour([PORT], { seed: 115 });
    assert.ok(modelled.vessels.length > 0);
    for (const vessel of modelled.vessels) assert.equal(vessel.heading, null);

    const promotion = promoteVessels(modelled, SAN_PEDRO, berthAnchors([PORT]));
    assert.ok(promotion.alongside > 0, "no modelled hull found its berth");
    for (const drawn of promotion.drawn) {
      if (!drawn.berthId) continue;
      const berth = PORT.berths?.find((b) => b.id === drawn.berthId);
      assert.equal(drawn.bearing, berth?.bearing);
    }
  });

  it("puts a handful under way on the channel and the rest alongside", () => {
    const promotion = promoteVessels(
      modelHarbour([PORT], { seed: 115, underWayPerPort: 3 }),
      SAN_PEDRO,
      berthAnchors([PORT]),
    );
    /**
     * A band, not a number, and the band is the honest assertion.
     *
     * `underWayPerPort` sizes the berth cycle so that the transits add up to the
     * target, but the legs differ in length and each arriving or departing ship
     * may have a tug attending her, so the count breathes. What must hold is
     * that the channel is neither empty nor a traffic jam.
     */
    assert.ok(promotion.makingWay >= 2, `only ${promotion.makingWay} under way`);
    assert.ok(promotion.makingWay <= 8, `${promotion.makingWay} under way is a jam`);
    assert.ok(promotion.alongside >= 1, "the quays came out empty");
    // Every moving hull has a course, or the layer could not reckon it and would
    // not draw a wake — which is the one thing that reads at board scale.
    for (const drawn of promotion.drawn) {
      if (drawn.speed > 0) assert.notEqual(drawn.course, null);
    }
  });

  it("draws nothing at all for a board with no ports", () => {
    const modelled = modelHarbour(undefined, { seed: 115 });
    assert.deepEqual(modelled.vessels, []);
    const promotion = promoteVessels(modelled, SAN_PEDRO, []);
    assert.deepEqual(promotion.drawn, []);
  });
});

// ---- Geometry helper -------------------------------------------------------

/** Metres from a point to the segment `a`-`b`, in the flat local approximation. */
function metresToSegment(
  p: { lat: number; lng: number },
  a: { lat: number; lng: number },
  b: { lat: number; lng: number },
): number {
  const scale = Math.cos((a.lat * Math.PI) / 180);
  const px = (p.lng - a.lng) * scale;
  const py = p.lat - a.lat;
  const bx = (b.lng - a.lng) * scale;
  const by = b.lat - a.lat;
  const denominator = bx * bx + by * by;
  const t = denominator > 0 ? Math.max(0, Math.min(1, (px * bx + py * by) / denominator)) : 0;
  const dx = px - bx * t;
  const dy = py - by * t;
  return Math.hypot(dx, dy) * 111_320;
}
