/**
 * Which hulls may be drawn, which way they point, and where they are *now*.
 *
 * This is the vessel feed's whole conscience and, like `server/fires.ts`, it is
 * the file with the least code in it. Everything else moves bytes; this decides
 * whether a harbour that looks alive is telling the truth.
 *
 * It is pure, it imports nothing but types, and it touches neither three.js nor
 * the DOM. That is not tidiness: every claim below is a claim about *data*, and
 * a claim about data that lives inside a mesh builder is a claim nobody can test
 * without a WebGL context. `src/test/integration/barrel.test.ts` asserts the no-
 * three half of that out loud.
 *
 * ### Three AIS sentinels, none of them NULL
 *
 * AIS encodes "not available" as perfectly valid numbers, in band, in the same
 * column as the real ones:
 *
 * | field   | sentinel | how often, in the store behind this |
 * |---------|----------|-------------------------------------|
 * | `sog`   | 102.3 kn | 7 of 1,138 rows — and the only sog >= 40 |
 * | `heading`| 511     | 450 of 1,138 rows — **40%** |
 * | `cog`   | 360.0    | 120 of 1,138 rows |
 *
 * Zero of the three columns are ever NULL, so a null check catches none of
 * them. Two of the three are actively dangerous rather than merely wrong:
 *
 * 1. **102.3 kn is 52.6 m/s.** Dead-reckoned for sixty seconds that is 3.2 km —
 *    eight SoCal scene units — so one unstripped row throws a hull across the
 *    breakwater and out to sea while every test still passes.
 * 2. **`cog % 360` is a booby trap and it will look correct.** Real course over
 *    ground reaches 358.7 (355.0, 355.4, 355.7, 356.3, 356.9, 357.0 and 358.7
 *    all occur in the store) and the sentinel is exactly 360.0. The obvious
 *    normalisation therefore turns every unknown course into *due north*: the
 *    unknown-course fleet quietly lines up facing the same way and nothing in
 *    the suite notices. `aisCourse` rejects on the exact value and passes 358.7
 *    through untouched, and `vesselGate.test.ts` asserts both halves — the
 *    second assertion is the one that matters.
 *
 * ### Gate motion on speed; label with `nav_status`
 *
 * Never the reverse. Of 197 vessels reporting `nav_status` 0, "under way using
 * engine", **83 are sitting at under half a knot** — 42% disagreement, and
 * speed over ground is the truthful one of the pair. So `nav_status` reaches the
 * renderer as a word on a card (`VesselStatus`) and never as a gate on the
 * dead-reckoner, and a fix that says "under way" at 0.2 kn is stopped.
 *
 * ### Orientation comes from the berth, because half the fleet has none
 *
 * Of 150 vessels whose latest fix is under 0.5 kn, only 75 report a real
 * heading, 126 a real course, and **21 have neither**. For `nav_status` 5
 * (moored) specifically it is 24 of 37. A berthed hull's orientation therefore
 * cannot come from the wire for at least half the fleet, and taking it from the
 * berth is the only correct answer rather than a shortcut: a ship lying
 * alongside a quay points the way the quay does, which is a fact about the
 * concrete and is known before any ship arrives.
 *
 * The ladder in `resolveBearing` is berth, then heading, then course, and the
 * berth wins outright when there is one. A hull we can neither orient from the
 * quay nor from the wire is **suppressed and counted**, not spun to an invented
 * angle — see `VesselPromotion.withoutOrientation`.
 *
 * ### Dead-reckon along the course; never spline between fixes
 *
 * Upstream listens for thirty seconds every fifteen minutes, so a hull under way
 * has moved about five kilometres between two samples. The chord between two
 * fixes is not a path anything took — a ship rounding the breakwater would be
 * drawn cutting straight across it — and this is the lesson the aircraft layer
 * already paid for: `Aircraft` sat frozen between snapshots for months precisely
 * because the wire could not express a velocity. AIS *can*: every fix carries
 * `sog` and `cog`, so `reckonVessel` advances along the reported course at the
 * reported speed and no code path in this module can produce a point between two
 * observations. `vesselGate.test.ts` asserts that as a geometric property rather
 * than as an absence of a function.
 *
 * ### The source is `modelled` this round, and it is anonymous
 *
 * `modelHarbour` builds a `VesselsBody` from a board's own authored berths and
 * channels. It exists because the cloud-1 projection for `sea.sqlite` does not:
 * `/api/fires/incidents` answers, `/api/sea`, `/api/vessels` and `/api/ships`
 * all 404, and the aisstream licence that would let real positions reach a page
 * served `Cache-Control: public` is unread. Building the seam and driving it
 * from a deterministic simulator is what lets the layer be finished and honest
 * at the same time.
 *
 * It is called `modelled` rather than `sim` deliberately, and it carries **no
 * name, no MMSI, no callsign and no destination**. The store has real ones in it
 * right now — the temptation is to hardcode them — and that would be the fire
 * layer's twenty-two orange marks in a nicer costume: plausible, specific, and a
 * claim about a named commercial vessel behind which this deployment has no
 * licensed feed. Identity arrives with a licence entry or it does not arrive.
 *
 * ### The harbour has a working day, and the day is a plan
 *
 * The first version of it was a diorama: berth occupancy was a time-invariant
 * hash and the handful of hulls in the channel looped it on a ninety-minute
 * carousel, so a port that takes fifteen ships a day never received one. It now
 * runs a schedule — a ship stands in from open water, takes a tug inside the
 * breakwater, lies alongside, works, and leaves — and the schedule is a **plan
 * evaluated at an instant** rather than a simulation that is running: nothing in
 * this file holds a clock or an accumulator, and `modelHarbour(ports, { atMs })`
 * is a closed form of that number. `server/wire.ts` makes the same argument for
 * the simulated sky and for the same reasons, and the two useful consequences
 * are identical: two people on two machines see the same ships, and `look.mjs
 * --at` shoots the same frame twice.
 *
 * One number in it is compressed and it is said out loud in both the body's
 * attribution and `vesselSummary`'s sentence: **the rate of arrivals**, at
 * roughly seven times a real day's, because a truthful rate gives a board that
 * looks identical from breakfast to bedtime. Nothing else is. The channel is the
 * charted one, the berths are the authored ones, and every speed is real —
 * which is not a nicety, because a fix's `sog` is what a consumer dead-reckons
 * along, so a speed that is not the derivative of its own position is a lie the
 * renderer will draw faithfully. `harbourDay.test.ts` asserts that derivative
 * numerically.
 */

import type { Berth, Port, Vessel, VesselKind, VesselStatus } from "../engine/types.ts";
import type { VesselsBody, VesselsSourceId, WireVessel } from "./wire.ts";

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

/**
 * Speed over ground, knots, meaning "not available".
 *
 * The raw AIS field is 1023 in tenths of a knot. It is the only value at or
 * above 40 kn anywhere in the store, which is a useful sanity check but not the
 * test: a container ship does not do forty knots, and a gate that guessed at a
 * plausible ceiling would be a gate with an opinion instead of a fact.
 */
export const AIS_SOG_UNAVAILABLE_KN = 102.3;

/** True heading, degrees, meaning "not available". 40% of fixes carry it. */
export const AIS_HEADING_UNAVAILABLE = 511;

/**
 * Course over ground, degrees, meaning "not available".
 *
 * Exactly 360.0, and exactly why `% 360` must never be applied to this field.
 */
export const AIS_COURSE_UNAVAILABLE = 360;

/** One knot in metres per second. */
export const KNOTS_TO_MPS = 0.514_444;

/**
 * The speed below which a hull is stopped, in knots.
 *
 * Half a knot is drift, moored slack and GPS noise. It is also the threshold the
 * store's own numbers are quoted against — 150 of 305 vessels are under it, 554
 * of 1,138 individual fixes are *exactly* 0.0 — so using anything else here
 * would make every figure in this file's comments unverifiable.
 */
export const VESSEL_MAKING_WAY_KN = 0.5;

/** The same threshold in the units `Vessel.speed` is expressed in. */
export const VESSEL_MAKING_WAY_MPS = VESSEL_MAKING_WAY_KN * KNOTS_TO_MPS;

/**
 * How far a hull may be from a berth and still be counted as lying alongside it,
 * in metres.
 *
 * Generous on purpose. An AIS position is reported from the antenna, which on a
 * 400 m ship is a couple of hundred metres from either end of it, and a berth is
 * one authored point rather than a line. 400 m keeps a ULCV on its own berth
 * without reaching across a slip to the next one — the container berths at San
 * Pedro are roughly 350-400 m apart along a quay.
 */
export const BERTH_REACH_METRES = 400;

/**
 * The furthest a fix may be advanced by dead reckoning, in seconds.
 *
 * Fifteen minutes and no further, because that is the upstream sample interval:
 * past it, the next fix is overdue and the honest picture is a hull that has
 * stopped moving rather than one that has sailed a straight line for an hour.
 * The same argument `flights.ts` makes for aircraft, at a longer interval.
 */
export const VESSEL_MAX_RECKON_SECONDS = 900;

/**
 * How many hulls a board draws at most.
 *
 * Not a data claim: the LA/LB box holds 81 vessels on a typical latest fix and
 * SF Bay 82. This is the ceiling the renderer preallocates against, and it is
 * applied here so that the cap is a decision taken over data, in a module a test
 * can read, rather than an array length in a mesh builder.
 */
export const VESSEL_DRAW_LIMIT = 192;

const METRES_PER_DEGREE_LAT = 111_320;
const DEG = Math.PI / 180;

// ---- Shapes ---------------------------------------------------------------

/**
 * A rectangle in degrees. Structurally the `bounds` a city pack declares,
 * restated rather than imported for the same reason `server/fires.ts` restates
 * `FireBounds`: a city pack is three thousand lines of coastline that pulls in
 * three.js, and this module has to be importable by a test with no renderer.
 */
export interface VesselBounds {
  minLat: number;
  maxLat: number;
  minLng: number;
  maxLng: number;
}

/**
 * The one thing the gate needs from a `Berth`: where it is and which way a hull
 * lying on it points.
 *
 * A structural subset rather than `Berth` itself, so that a caller which has
 * berths from somewhere other than a city pack — a test, a fixture, a future
 * feed — can answer without inventing a `maxLength`.
 */
export interface BerthAnchor {
  id: string;
  lat: number;
  lng: number;
  /** TRUE bearing of the bow, degrees clockwise from north. */
  bearing: number;
}

/**
 * Everything a board needs to draw ships, and everything it needs to explain a
 * harbour with none in it.
 *
 * The four counts after `drawn` are what make an empty harbour a *finding*
 * rather than a blank: "the feed is not configured", "the feed answered and
 * nothing is on this board", "eleven hulls answered and every one of them is
 * outside the frame" and "nine hulls answered and none of them would say which
 * way it was pointing" are four different sentences, and a layer that cannot
 * tell them apart is a layer that gets guessed at. This is the same argument
 * `FirePromotion.suppressed` makes, and it is the reason the quiet day is the
 * case that gets designed first.
 */
export interface VesselPromotion {
  source: VesselsSourceId;
  /** ISO-8601 of the last successful upstream fetch. Epoch zero when never. */
  fetchedAt: string;
  /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
  ageMs: number | null;
  /** Seconds between upstream samples. What licenses the dead reckoning. */
  intervalSeconds: number;
  /** Hulls inside `bounds`, longest first. At most `VESSEL_DRAW_LIMIT`. */
  drawn: Vessel[];
  /** Hulls that passed the gate but fall outside `bounds`. */
  offBoard: number;
  /** Rows the gate refused for a bad position, a bad size or a sentinel. */
  suppressed: number;
  /** Rows refused because neither the quay nor the wire would orient them. */
  withoutOrientation: number;
  /** How many of `drawn` are making way. The number the wakes are drawn from. */
  makingWay: number;
  /** How many of `drawn` are lying on an authored berth. */
  alongside: number;
}

/** The answer for a board with no feed behind it at all. */
export function emptyVesselPromotion(): VesselPromotion {
  return {
    source: "none",
    fetchedAt: new Date(0).toISOString(),
    ageMs: null,
    intervalSeconds: 0,
    drawn: [],
    offBoard: 0,
    suppressed: 0,
    withoutOrientation: 0,
    makingWay: 0,
    alongside: 0,
  };
}

// ---- The three readers ----------------------------------------------------
//
// One function per AIS field, each total, each returning `null` for "the source
// did not know". They are exported individually because they are the three
// assertions the whole feed rests on and a test that has to reach them through a
// promotion is a test that is really about something else.

/**
 * Speed over ground in metres per second, or `null`.
 *
 * `0` is a real answer and a common one — 554 of 1,138 fixes are exactly zero —
 * so this must never conflate "stopped" with "unknown". A stopped ship is drawn;
 * an unknown one is not dead-reckoned.
 */
export function aisSpeedMps(sogKnots: number | null | undefined): number | null {
  if (typeof sogKnots !== "number" || !Number.isFinite(sogKnots)) return null;
  if (sogKnots < 0) return null;
  if (sogKnots === AIS_SOG_UNAVAILABLE_KN) return null;
  return sogKnots * KNOTS_TO_MPS;
}

/**
 * True heading in degrees, or `null`.
 *
 * Rejects 511 on the exact value. The range check that follows is a second,
 * independent condition rather than a restatement of it: 511 is out of range,
 * but so is a corrupted 720, and neither is a heading.
 */
export function aisHeading(degrees: number | null | undefined): number | null {
  if (typeof degrees !== "number" || !Number.isFinite(degrees)) return null;
  if (degrees === AIS_HEADING_UNAVAILABLE) return null;
  if (degrees < 0 || degrees >= 360) return null;
  return degrees;
}

/**
 * Course over ground in degrees, or `null`.
 *
 * **The exact-value rejection is the whole point and it is not interchangeable
 * with a range check plus a modulo.** 358.7 is a real course and survives here;
 * 360.0 is "not available" and does not. Writing this as `cog % 360` returns 0
 * for the sentinel, which is a course — due north — and every unknown-course
 * hull on the board then points the same way, correctly, forever, with nothing
 * to notice it. `vesselGate.test.ts` asserts 358.7 survives for exactly this
 * reason.
 */
export function aisCourse(degrees: number | null | undefined): number | null {
  if (typeof degrees !== "number" || !Number.isFinite(degrees)) return null;
  if (degrees === AIS_COURSE_UNAVAILABLE) return null;
  if (degrees < 0 || degrees >= 360) return null;
  return degrees;
}

/** Is this speed motion, or is it slack? Gate on this and never on `nav_status`. */
export function isMakingWay(speedMps: number | null): boolean {
  return typeof speedMps === "number" && Number.isFinite(speedMps) && speedMps >= VESSEL_MAKING_WAY_MPS;
}

/**
 * `nav_status` as a word for a card.
 *
 * Sixteen AIS codes collapse to four, because fifteen of the sixteen are
 * distinctions no renderer can draw: "constrained by her draught" and "engaged
 * in fishing" are the same hull at the same angle from four kilometres up.
 * Anything not 0, 1 or 5 is `unknown`, which is honest — including the codes
 * that do mean something, because meaning something is not the same as being
 * drawable.
 */
export function vesselStatus(navStatus: number | null | undefined): VesselStatus {
  switch (navStatus) {
    case 0:
      return "under-way";
    case 1:
      return "at-anchor";
    case 5:
      return "moored";
    default:
      return "unknown";
  }
}

// ---- Orientation ----------------------------------------------------------

/**
 * Which way the bow points, or `null` when nothing knows.
 *
 * The ladder, in order, and the order is the finding:
 *
 * 1. **The berth**, whenever the hull is lying on one. It wins outright — over a
 *    reported heading, and over a course — because a ship alongside a quay
 *    points the way the quay points, and because half the fleet at rest reports
 *    no heading at all. The wire heading is allowed a small perturbation on top
 *    (`BERTH_HEADING_PERTURBATION_DEG`) so that a hull whose antenna *does*
 *    report can sit a degree or two off square, which is what a real berth looks
 *    like; it can never swing the hull off the quay.
 * 2. **The reported heading**, for a hull at rest that is not on a berth — an
 *    anchored ship swinging on its cable is genuinely pointing where it says.
 * 3. **The course over ground**, for a hull making way. A ship crabbing across a
 *    tide is not pointing exactly where it is going, but the difference is a
 *    couple of degrees and the course is the field that is present.
 *
 * `null` is a real outcome and is not padded out with a default. Fourteen
 * percent of stopped hulls have neither heading nor course, and drawing them at
 * an invented angle would be an invented fact in a medium that reads as truthful.
 */
export function resolveBearing(input: {
  heading: number | null;
  course: number | null;
  speedMps: number | null;
  berthBearing?: number | null;
}): number | null {
  const berth = input.berthBearing;
  if (typeof berth === "number" && Number.isFinite(berth)) {
    const heading = input.heading;
    if (heading === null) return normaliseDegrees(berth);
    // The perturbation, clamped: a reported heading nudges the hull off square
    // and can never turn it round. A ship reported 180 degrees from its berth is
    // a ship whose AIS is wrong about which end is the bow, not a ship moored
    // backwards, and the quay is the thing that cannot be wrong.
    const delta = signedDelta(heading, berth);
    const nudge = Math.max(
      -BERTH_HEADING_PERTURBATION_DEG,
      Math.min(BERTH_HEADING_PERTURBATION_DEG, delta),
    );
    return normaliseDegrees(berth + nudge);
  }
  if (!isMakingWay(input.speedMps)) {
    if (input.heading !== null) return normaliseDegrees(input.heading);
    if (input.course !== null) return normaliseDegrees(input.course);
    return null;
  }
  if (input.course !== null) return normaliseDegrees(input.course);
  if (input.heading !== null) return normaliseDegrees(input.heading);
  return null;
}

/**
 * How far a reported heading may pull a berthed hull off the bearing its quay
 * says it has, in degrees.
 *
 * Three, which is about the width of a fender pack plus the angle a ship sits at
 * when it is warped forward for a crane. Big enough that a row of berthed hulls
 * is not suspiciously parallel; small enough that a garbage heading cannot put a
 * ship across its own quay.
 */
export const BERTH_HEADING_PERTURBATION_DEG = 3;

/** The berth a fix is lying on, or `null`. Nearest inside `BERTH_REACH_METRES`. */
export function nearestBerth(
  lat: number,
  lng: number,
  berths: readonly BerthAnchor[],
  reachMetres: number = BERTH_REACH_METRES,
): BerthAnchor | null {
  let best: BerthAnchor | null = null;
  let bestMetres = reachMetres;
  for (const berth of berths) {
    if (!Number.isFinite(berth.lat) || !Number.isFinite(berth.lng)) continue;
    const metres = metresBetween(lat, lng, berth.lat, berth.lng);
    if (metres <= bestMetres) {
      best = berth;
      bestMetres = metres;
    }
  }
  return best;
}

/** Flatten a board's ports into the anchors the gate reads. */
export function berthAnchors(ports: readonly Port[] | undefined): BerthAnchor[] {
  const anchors: BerthAnchor[] = [];
  for (const port of ports ?? []) {
    for (const berth of port.berths ?? []) {
      anchors.push({ id: berth.id, lat: berth.lat, lng: berth.lng, bearing: berth.bearing });
    }
  }
  return anchors;
}

// ---- Dead reckoning -------------------------------------------------------

/**
 * Where a fix has got to after `seconds`, along its **reported course** at its
 * **reported speed**.
 *
 * Pure, closed form, and the only function in this repo permitted to move a
 * ship. What it cannot do is the point of it: it takes one fix, so there is no
 * second fix for it to interpolate toward, and therefore no code path anywhere
 * downstream can produce a point on the chord between two observations. That is
 * a property of the signature rather than of the discipline of the caller, which
 * is why the signature is this and not `(from, to, t)`.
 *
 * A hull with no course, or one that is not making way, does not move. Neither
 * does one whose fix is older than `VESSEL_MAX_RECKON_SECONDS`: past the sample
 * interval the next fix is overdue, and a ship drawn sailing a perfectly
 * straight line for an hour is a ship the feed has lost.
 */
export function reckonVessel(
  fix: { lat: number; lng: number; speed: number; course: number | null },
  seconds: number,
): { lat: number; lng: number } {
  const here = { lat: fix.lat, lng: fix.lng };
  if (!Number.isFinite(seconds) || seconds <= 0) return here;
  if (fix.course === null || !Number.isFinite(fix.course)) return here;
  if (!isMakingWay(fix.speed)) return here;
  const dt = Math.min(seconds, VESSEL_MAX_RECKON_SECONDS);
  const distance = fix.speed * dt;
  const radians = fix.course * DEG;
  const lat = fix.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT;
  // The cosine is taken at the starting latitude rather than the mean of the
  // two, exactly as `flights.ts` does: fifteen minutes of steaming is under five
  // kilometres, over which the correction differs in the seventh decimal place,
  // and using the start keeps this a closed form rather than an iteration.
  const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(fix.lat * DEG);
  const lng =
    metresPerDegreeLng > 1 ? fix.lng + (Math.sin(radians) * distance) / metresPerDegreeLng : fix.lng;
  return { lat, lng };
}

// ---- The gate -------------------------------------------------------------

/**
 * Default hull dimensions, in metres, for a source that did not send any.
 *
 * The static AIS message carries length and beam and is absent for most hulls
 * most of the time, so this is the difference between drawing a plausible ship
 * and drawing nothing. It is a **display default and not an observation**: it is
 * per-kind, it is stated here where it can be read, and nothing downstream may
 * present it as a measurement. There is no draught in this table for the reason
 * `Vessel` gives at length — draught is a hull dimension we author, never a
 * cargo claim, and `engine/vessels.ts` derives it from the length.
 */
export const DEFAULT_HULL: Readonly<Record<VesselKind, { length: number; beam: number }>> = {
  container: { length: 300, beam: 45 },
  tanker: { length: 250, beam: 44 },
  bulk: { length: 225, beam: 32 },
  "vehicle-carrier": { length: 200, beam: 32 },
  tug: { length: 30, beam: 11 },
  ferry: { length: 60, beam: 14 },
  fishing: { length: 25, beam: 7 },
  other: { length: 90, beam: 16 },
};

const KINDS = new Set<string>(Object.keys(DEFAULT_HULL));

/** A wire `kind` narrowed to the union, falling back to `other`. */
export function vesselKind(kind: string | null | undefined): VesselKind {
  return typeof kind === "string" && KINDS.has(kind) ? (kind as VesselKind) : "other";
}

/**
 * One wire row to one drawable hull, or `null`.
 *
 * Total: a malformed row returns `null` rather than throwing, because the
 * consumer is a render loop and a body one server version behind is a normal
 * thing to be handed.
 */
export function readVessel(
  row: WireVessel | null | undefined,
  berths: readonly BerthAnchor[],
): { vessel: Vessel; berthed: boolean } | { vessel: null; reason: "invalid" | "unoriented" } {
  if (!row || typeof row !== "object") return { vessel: null, reason: "invalid" };
  const lat = row.lat;
  const lng = row.lon;
  if (typeof lat !== "number" || !Number.isFinite(lat) || lat < -90 || lat > 90) {
    return { vessel: null, reason: "invalid" };
  }
  if (typeof lng !== "number" || !Number.isFinite(lng) || lng < -180 || lng > 180) {
    return { vessel: null, reason: "invalid" };
  }
  const id = typeof row.id === "string" && row.id.length > 0 ? row.id : null;
  if (id === null) return { vessel: null, reason: "invalid" };

  /**
   * The wire speed is already metres per second and already stripped upstream —
   * and it is re-checked here anyway, in the units it arrives in.
   *
   * Not belt and braces: the upstream half of this feed lives in a different
   * repo on a different box and does not yet exist, so "already stripped" is a
   * promise nobody can currently keep. A sentinel that gets through moves a hull
   * eight scene units a minute. The check is one comparison.
   */
  const speed = readWireSpeed(row.speed);
  if (speed === null) return { vessel: null, reason: "invalid" };
  const heading = aisHeading(row.heading);
  const course = aisCourse(row.course);

  /**
   * A hull is bound to a berth only when it is **not making way**.
   *
   * `nearestBerth` reaches four hundred metres, and `resolveBearing` lets the
   * quay win outright over a reported course — which is right for a ship lying
   * alongside and wrong for one steaming past. Long Beach's Pier T berths sit
   * 190 m off their own channel centreline, so before this line a ship doing ten
   * knots up the channel was silently swung to the quay's bearing and drawn
   * crabbing sideways with her wake off the beam. It only became visible once
   * ships started arriving; the picture found it in the first frame.
   *
   * "Alongside" therefore now means what the word means, and
   * `VesselPromotion.alongside` counts hulls that are actually stopped at a
   * berth. A ship creeping the last fifty metres in at under half a knot is not
   * making way by `isMakingWay`, so she binds and lies the way the quay does —
   * which is the moment she should.
   */
  const berth = isMakingWay(speed) ? null : nearestBerth(lat, lng, berths);
  const bearing = resolveBearing({
    heading,
    course,
    speedMps: speed,
    berthBearing: berth ? berth.bearing : null,
  });
  if (bearing === null) return { vessel: null, reason: "unoriented" };

  const kind = vesselKind(row.kind);
  const fallback = DEFAULT_HULL[kind];
  const length = positive(row.length) ?? fallback.length;
  const beam = positive(row.beam) ?? fallback.beam;
  const ageSeconds =
    typeof row.ageSeconds === "number" && Number.isFinite(row.ageSeconds) && row.ageSeconds >= 0
      ? row.ageSeconds
      : 0;

  const vessel: Vessel = {
    id,
    kind,
    lat,
    lng,
    bearing,
    length,
    beam,
    speed: isMakingWay(speed) ? speed : 0,
    course,
    status: vesselStatus(row.navStatus),
    ...(berth ? { berthId: berth.id } : {}),
    ageSeconds,
  };
  return { vessel, berthed: berth !== null };
}

/**
 * Apply the gate to one body, for one board.
 *
 * Pure and total: a malformed body, a body from a server one version behind, or
 * `null` all produce an empty promotion rather than an exception — the same
 * posture `server/fires.ts` takes, and for the same reason.
 *
 * `nowMs` is injected so a test can assert on `ageMs` without owning the clock.
 */
export function promoteVessels(
  body: VesselsBody | null | undefined,
  bounds: VesselBounds,
  berths: readonly BerthAnchor[] = [],
  nowMs: number = Date.now(),
): VesselPromotion {
  const empty = emptyVesselPromotion();
  if (body === null || body === undefined || typeof body !== "object") return empty;

  const fetchedAt = typeof body.fetchedAt === "string" ? body.fetchedAt : empty.fetchedAt;
  const fetchedMs = Date.parse(fetchedAt);
  const ageMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? Math.max(0, nowMs - fetchedMs) : null;
  const intervalSeconds =
    typeof body.intervalSeconds === "number" && body.intervalSeconds > 0 ? body.intervalSeconds : 0;
  const source: VesselsSourceId =
    body.source === "cloud1" || body.source === "modelled" ? body.source : "none";

  const rows = Array.isArray(body.vessels) ? body.vessels : [];
  const drawn: Vessel[] = [];
  let suppressed = 0;
  let withoutOrientation = 0;
  let offBoard = 0;

  for (const row of rows) {
    const read = readVessel(row, berths);
    if (read.vessel === null) {
      if (read.reason === "unoriented") withoutOrientation += 1;
      else suppressed += 1;
      continue;
    }
    const { vessel } = read;
    if (!inBounds(vessel.lat, vessel.lng, bounds)) {
      offBoard += 1;
      continue;
    }
    drawn.push(vessel);
  }

  // Longest first, so that a board over the draw limit loses the hulls least
  // able to carry a pixel rather than whichever the feed happened to list last.
  drawn.sort((a, b) => b.length - a.length);
  const kept = drawn.slice(0, VESSEL_DRAW_LIMIT);
  suppressed += drawn.length - kept.length;

  return {
    source,
    fetchedAt,
    ageMs,
    intervalSeconds,
    drawn: kept,
    offBoard,
    suppressed,
    withoutOrientation,
    makingWay: kept.filter((v) => isMakingWay(v.speed)).length,
    alongside: kept.filter((v) => v.berthId !== undefined).length,
  };
}

/**
 * The sentence a panel writes when a harbour is empty.
 *
 * Designed before the field was, which is the rule this repo arrived at the hard
 * way: a layer with a beautiful full state and a blank empty one is a layer that
 * looks broken on most days. Every branch below names both what is being shown
 * and what is being withheld, because "there are no ships here" and "I have not
 * heard from the feed since Tuesday" are the same picture and different facts.
 */
export function vesselSummary(promotion: VesselPromotion): string {
  const { drawn, source } = promotion;
  if (source === "none") {
    return "No vessel feed is configured for this deployment, so no ships are drawn.";
  }
  const modelled = source === "modelled";
  /**
   * The modelled clause names **both halves**, and the second half is new.
   *
   * The harbour now has a working day in it: ships stand in from open water,
   * take a tug, lie alongside and leave. A reader watching a berth change hands
   * twice in an afternoon would reasonably conclude that is how often San Pedro
   * changes hands, and it is not — the rate is the one thing here that is a
   * modelling choice rather than the board's own geometry. Every other number in
   * the picture is true: the channel is the charted one, the berths are the
   * authored ones, and a ship comes up the channel at thirteen knots because
   * that is how fast she comes up the channel.
   *
   * Saying so costs a clause. Not saying so is the fire layer's twenty-two
   * orange marks again, in a slower costume.
   */
  const provenance = modelled
    ? "Modelled from this board's own berths and channels — anonymous hulls, no names and no MMSIs, because the live AIS feed is not configured. Ship speeds, the channel and the berths are true; arrivals run at about seven times a real day's rate, so the harbour changes while you watch."
    : "Live AIS.";
  if (drawn.length === 0) {
    const parts: string[] = ["The feed answered and no ship is on this board."];
    if (promotion.offBoard > 0) parts.push(`${promotion.offBoard} outside the frame.`);
    if (promotion.withoutOrientation > 0) {
      parts.push(`${promotion.withoutOrientation} would not say which way they were pointing.`);
    }
    if (promotion.suppressed > 0) parts.push(`${promotion.suppressed} unreadable.`);
    parts.push(provenance);
    return parts.join(" ");
  }
  const still = drawn.length - promotion.makingWay;
  const parts = [
    `${drawn.length} ${drawn.length === 1 ? "hull" : "hulls"}: ${promotion.makingWay} making way, ${still} at rest, ${promotion.alongside} alongside a berth.`,
  ];
  if (promotion.withoutOrientation > 0) {
    parts.push(
      `${promotion.withoutOrientation} withheld — neither the quay nor the wire would orient them.`,
    );
  }
  parts.push("No hull is labelled laden or in ballast: that is a port figure, not a ship one.");
  parts.push(provenance);
  return parts.join(" ");
}

// ---- The modelled harbour, and its working day ----------------------------

/** What `modelHarbour` needs to be reproducible. */
export interface ModelledHarbourOptions {
  /**
   * The seed, so two people see the same harbour and a capture script shoots the
   * same frame twice. Everything below is a hash of this and a stable string
   * (a berth id, a port id, a call number), never a call to `Math.random`.
   */
  seed?: number;
  /**
   * Wall clock for the body's `fetchedAt`, **and the instant the working day is
   * evaluated at**.
   *
   * This is the whole of the simulator's state. `harbourCalls` is a plan — a
   * berth, a route and a repeating slot — and `modelHarbour` is that plan
   * evaluated as a closed form of this number, which is exactly the trick
   * `FlightsPlanBody` plays with `t0` and for exactly the same reason: two
   * people on two machines looking at the same instant see the same ships, and a
   * capture script that shoots the Southland twice gets the same photograph.
   * There is no accumulator anywhere in this file, so there is nothing that can
   * drift, and nothing that has to be replayed to reach a given moment.
   */
  atMs?: number;
  /** Sample interval to declare. See `MODELLED_INTERVAL_SECONDS`. */
  intervalSeconds?: number;
  /**
   * Roughly how many hulls are under way per port at any instant.
   *
   * A *target*, not a guarantee, and the difference is the honest one: berth
   * slots are laid out evenly around the cycle so the channel carries a steady
   * stream rather than a convoy, but the legs differ in length — the West Basin
   * is eight kilometres up the Main Channel and Pier 400 is two — so the count
   * breathes by one either side. `harbourDay.test.ts` asserts the band rather
   * than a number.
   */
  underWayPerPort?: number;
  /** What proportion of a port's berths are occupied, 0..1. */
  occupancy?: number;
}

/**
 * The interval the modelled source declares, in seconds.
 *
 * **A minute, and not the nine hundred seconds a real AIS listener would
 * declare** — that number moved, and the reason it moved is the most useful
 * thing in this file.
 *
 * `intervalSeconds` is a property *of the source*. It says how long a consumer
 * may dead-reckon a fix before the fix is stale, and for the store this stands
 * in for it is fifteen minutes because upstream listens for thirty seconds every
 * fifteen minutes. This source is not that: it is a closed-form function of the
 * clock, it can be asked for any instant at any time, and declaring a quarter of
 * an hour was mimicry rather than description.
 *
 * It also drew a ship over the land. `engine/vessels.ts` dead-reckons **along a
 * straight reported course** — correctly, because a course is all a fix carries
 * — so a hull at thirteen knots reckoned for nine hundred seconds runs six
 * kilometres in a straight line. The Main Channel bends about twenty degrees in
 * that distance, and the arriving ships this working day added therefore sailed
 * up over Terminal Island for the last third of every interval, dead-reckoned
 * exactly as instructed. A minute is 400 m, which is under a scene unit.
 *
 * None of that softens when a real feed lands. `/api/sea` will hand over a body
 * carrying its own `intervalSeconds`, main.ts already reads it off the body, and
 * a fifteen-minute AIS feed will dead-reckon for fifteen minutes and overshoot
 * the bends — which is a true fact about a fifteen-minute feed and belongs in
 * the panel's sentence rather than in a smoothing filter. What must never happen
 * is the other repair: splining between two fixes to hide it.
 */
export const MODELLED_INTERVAL_SECONDS = 60;

/**
 * How far seaward of the charted channel a ship is picked up, in metres.
 *
 * The pack's channel is the dredged water and stops where the dredging does, a
 * kilometre or so outside the breakwater. A ship that appeared exactly there
 * would pop into being at the gate; extending the first leg back out to sea by
 * two and a half kilometres means an arrival is first seen against open water,
 * standing in toward the entrance, which is what an arrival looks like.
 *
 * Extended **in the simulator and not in the pack**, because `engine/ports.ts`
 * draws `Port.channel` as dredged water and this is not dredged water. The
 * picture would gain a dark strip two miles out to sea that no chart has.
 */
export const APPROACH_SEAWARD_METRES = 2500;

/**
 * How far off the charted channel a berth may be and still be given a route, in
 * metres.
 *
 * **This is a land check standing in for the land check this file cannot do.**
 * A berth's route is the channel as far as the point nearest the berth and then
 * a straight run in, and a straight run of three kilometres from the Main
 * Channel to the East Basin crosses Terminal Island — a container ship driven
 * over a container yard, at ten knots, with a wake. There is no water mask in
 * `Port`, so the honest gate is distance: a berth the channel reaches keeps a
 * working day, and a berth it does not reach keeps a hull lying alongside and
 * takes no calls.
 *
 * At Los Angeles that admits Pier 400 and the West Basin and holds back Pier 300
 * and the East Basin; at Long Beach it admits all five. The fix is not a bigger
 * number — it is per-berth approach geometry in the pack, which wants a field on
 * `Berth` that does not exist yet.
 */
export const BERTH_APPROACH_REACH_METRES = 1100;

/**
 * The shape of the run in, as an exponent.
 *
 * Distance made good is `L * (1 - (1-x)^k)` and speed is its derivative, so a
 * ship enters at `k * L / T` and arrives at nothing. That is not a fade for
 * looks: it is how a ship berths, and it is also what keeps the dead reckoner
 * honest, because the hull whose fix could be extrapolated furthest — the one
 * closest to a quay it must not be drawn on top of — is the one moving slowest.
 *
 * 1.6 rather than 2 because a square root of a decay spends too much of the leg
 * crawling; at 1.6 a ship holds better than half her entry speed for the first
 * two thirds of the channel and is down to two knots at the berth.
 */
export const APPROACH_EASE = 1.6;

/** Entry speed at the seaward end of the run in, m/s. Thirteen knots. */
export const INBOUND_PEAK_MPS = 6.7;
/** Speed at the seaward end of the run out, m/s. Fourteen knots. */
export const OUTBOUND_PEAK_MPS = 7.2;

/** The least clear water between one ship leaving a berth and the next arriving. */
const MIN_BERTH_GAP_SECONDS = 600;

/**
 * When the tug joins an arriving ship, and when it lets a departing one go, as a
 * fraction of the leg.
 *
 * The escort is the second half of the run in and the first third of the run
 * out, which is inside the breakwater in both cases — a harbour tug meets a ship
 * in sheltered water, not at sea.
 */
const TUG_MEETS_AT = 0.55;
const TUG_LEAVES_AT = 0.94;
const TUG_DEPARTURE_UNTIL = 0.3;
/** How long before the meeting the tug is seen running seaward to make it. */
const TUG_RUN_OUT = 0.13;
/** Where the tug lies between jobs, as a fraction along the berth's own route. */
const TUG_STATION = 0.88;
/** Where the tug sits relative to the ship it is attending, in metres. */
const TUG_STATION_ASTERN = 220;
const TUG_STATION_ABEAM = 110;

/** One berth's endlessly repeating port call: a route, and a slot in the day. */
export interface HarbourCall {
  portId: string;
  berthId: string;
  /** The berth's own bearing, so a ship alongside lies the way the quay does. */
  berthBearing: number;
  maxLength: number;
  /**
   * Open water, then the charted channel, then **where the ship lies** — which
   * is not the berth's own coordinate. See `lyingPosition`.
   */
  approach: readonly [number, number][];
  approachMetres: number;
  inboundSeconds: number;
  dwellSeconds: number;
  outboundSeconds: number;
  /** Inbound, alongside, outbound, and the empty berth before the next ship. */
  cycleSeconds: number;
  /** Seconds after the epoch at which this berth's call zero starts inbound. */
  offsetSeconds: number;
}

/** Which leg of her call a berth's ship is on. `empty` is a berth with no ship. */
export type HarbourPhase = "inbound" | "alongside" | "outbound" | "empty";

export interface HarbourMoment {
  phase: HarbourPhase;
  /** Which call this is, counting from the epoch. Half of the ship's identity. */
  index: number;
  /** 0..1 through whichever leg `phase` names. */
  progress: number;
}

/**
 * The plan: one repeating call per berth the channel can reach.
 *
 * Pure, and a function of the port's own geometry and the seed alone — no clock
 * reaches this function, which is what makes it a *plan* rather than a state.
 * `harbourMoment` is the evaluator.
 *
 * ### The slots are even, and that is a claim about ports rather than a shortcut
 *
 * Every berth in a port shares one cycle and the offsets are laid out evenly
 * around it, so arrivals come at a steady drumbeat instead of in clumps. Real
 * ports do allocate berth windows this way — a ship books a slot and is charged
 * for missing it — and the alternative here, a random offset per berth, gives a
 * harbour that is deserted for an hour and then has five ships in one channel.
 * The variety is in the ships instead: kind, length, dwell and tug all come out
 * of the hash of the berth id and the call number.
 *
 * ### The rate is compressed, and the panel says so
 *
 * Los Angeles and Long Beach between them take on the order of fourteen deep-sea
 * calls a day and a box ship lies alongside for one to three days. Drawn
 * truthfully, this board would show one arrival every hour and a half and a quay
 * that looked identical from breakfast to bedtime. `underWayPerPort` sets the
 * compression and the default runs roughly seven times a real day's call rate,
 * which is a stated modelling choice and is written into the body's own
 * attribution — not a claim about how busy San Pedro is.
 *
 * **What is not compressed is the motion.** Every speed below is a real speed, so
 * a ship takes twenty-five to forty-five minutes to come up the channel because
 * that is how long it takes, and — the part that matters downstream — the sog a
 * fix reports is the derivative of the position that fix reports. A simulator
 * that sped the hulls up while reporting truthful knots would have the dead
 * reckoner and the schedule disagree, and the disagreement would land as a jump
 * on every fix.
 */
export function harbourCalls(
  port: Port,
  options: ModelledHarbourOptions = {},
): HarbourCall[] {
  const seed = options.seed ?? 115;
  const occupancy = clamp01(options.occupancy ?? 0.72);
  /**
   * The target, clamped to half the berths.
   *
   * A berth is under way for `inbound + outbound` of every cycle, so asking for
   * three of a four-berth port in the channel at once leaves at most a quarter of
   * the cycle to lie alongside in — and once the minimum clear water between one
   * ship leaving and the next arriving is taken out, none. A port where most of
   * the fleet is steaming and the quays are bare is not a busy port, it is a
   * parade. Half is the ceiling; a caller asking for more gets a working harbour
   * instead of the number it asked for.
   */
  const asked = Math.max(0, Math.floor(options.underWayPerPort ?? 2));
  const target = Math.min(asked, Math.max(1, Math.floor((port.berths ?? []).length / 2)));
  const channel = port.channel ?? [];
  const berths = port.berths ?? [];
  if (channel.length < 2 || berths.length === 0 || target === 0) return [];

  const fairway = seawardApproach(channel);

  /** Berth, route and the two transit times, before the slots are laid out. */
  const routed: {
    berth: Berth;
    approach: [number, number][];
    metres: number;
    inbound: number;
    outbound: number;
  }[] = [];
  for (const berth of berths) {
    const approach = berthApproach(fairway, berth);
    if (approach === null) continue;
    const metres = pathMetres(approach);
    if (metres <= 0) continue;
    routed.push({
      berth,
      approach,
      metres,
      inbound: (APPROACH_EASE * metres) / INBOUND_PEAK_MPS,
      outbound: (APPROACH_EASE * metres) / OUTBOUND_PEAK_MPS,
    });
  }
  if (routed.length === 0) return [];

  /**
   * One cycle for the whole port, sized so that the transits add up to the
   * target.
   *
   * A berth is under way for `inbound + outbound` of every cycle, so the number
   * of hulls moving at any instant is the sum of those over the cycle. Solving
   * that for the cycle is the one line that turns "about two ships in the
   * channel" into a schedule.
   */
  const transit = routed.reduce((total, r) => total + r.inbound + r.outbound, 0);
  const cycleSeconds = Math.max(transit / routed.length, transit / target);

  const calls: HarbourCall[] = [];
  routed.forEach((route, index) => {
    const legs = route.inbound + route.outbound;
    // The dwell the occupancy asks for, or the longest one that still leaves the
    // berth clear water before the next ship — whichever is shorter. A long leg
    // eats its own berth's dwell rather than overrunning the slot behind it.
    const dwellSeconds = Math.max(
      0,
      Math.min(occupancy * cycleSeconds, cycleSeconds - legs - MIN_BERTH_GAP_SECONDS),
    );
    calls.push({
      portId: port.id,
      berthId: route.berth.id,
      berthBearing: route.berth.bearing,
      maxLength: route.berth.maxLength > 0 ? route.berth.maxLength : 0,
      approach: route.approach,
      approachMetres: route.metres,
      inboundSeconds: route.inbound,
      dwellSeconds,
      outboundSeconds: route.outbound,
      cycleSeconds,
      // Evenly spaced, and rotated by a hash of the port so that Los Angeles and
      // Long Beach are not in step with each other.
      offsetSeconds:
        ((index / routed.length) + hash01(seed, `${port.id}:rotation`)) * cycleSeconds,
    });
  });
  return calls;
}

/** Where a berth's call has got to at `atSeconds` after the epoch. */
export function harbourMoment(call: HarbourCall, atSeconds: number): HarbourMoment {
  const cycle = call.cycleSeconds;
  if (!(cycle > 0)) return { phase: "empty", index: 0, progress: 0 };
  const since = atSeconds - call.offsetSeconds;
  const index = Math.floor(since / cycle);
  const u = since - index * cycle;
  if (u < call.inboundSeconds) {
    return { phase: "inbound", index, progress: u / call.inboundSeconds };
  }
  const afterDwell = call.inboundSeconds + call.dwellSeconds;
  if (u < afterDwell) {
    return {
      phase: "alongside",
      index,
      progress: call.dwellSeconds > 0 ? (u - call.inboundSeconds) / call.dwellSeconds : 0,
    };
  }
  const afterOut = afterDwell + call.outboundSeconds;
  if (u < afterOut) {
    return { phase: "outbound", index, progress: (u - afterDwell) / call.outboundSeconds };
  }
  return { phase: "empty", index, progress: (u - afterOut) / Math.max(1, cycle - afterOut) };
}

/**
 * Distance made good along the route, and the speed that is making it.
 *
 * The speed is the analytic derivative of the distance rather than a plausible
 * number written beside it, which is the property the whole seam rests on: a fix
 * carries `sog` and a consumer is licensed to dead-reckon along it, so a `sog`
 * that is not the derivative of the position it arrives with is a lie that the
 * renderer will faithfully draw.
 */
export function approachRun(
  metres: number,
  seconds: number,
  progress: number,
  leg: "inbound" | "outbound",
): { arcMetres: number; speedMps: number } {
  if (!(metres > 0) || !(seconds > 0)) return { arcMetres: 0, speedMps: 0 };
  const x = clamp01(progress);
  const k = APPROACH_EASE;
  if (leg === "inbound") {
    const left = 1 - x;
    return {
      arcMetres: metres * (1 - left ** k),
      speedMps: (metres * k * left ** (k - 1)) / seconds,
    };
  }
  return {
    arcMetres: metres * (1 - x ** k),
    speedMps: (metres * k * x ** (k - 1)) / seconds,
  };
}

/**
 * A harbour built from a board's own authored geometry, at one instant.
 *
 * Berths carry hulls; the channel carries the ones arriving and leaving; a tug
 * comes out to meet each of them. All of it is a closed form of `atMs`, so the
 * same instant gives the same harbour on every machine and at every replay.
 *
 * Deliberately absent, and the absences are the design: no name, no MMSI, no
 * callsign, no destination, and no laden state. The output is a `VesselsBody`
 * with `source: "modelled"` and it goes through `promoteVessels` exactly like a
 * live one, so the seam is exercised rather than bypassed.
 */
export function modelHarbour(
  ports: readonly Port[] | undefined,
  options: ModelledHarbourOptions = {},
): VesselsBody {
  const seed = options.seed ?? 115;
  const atMs = options.atMs ?? 0;
  const intervalSeconds = options.intervalSeconds ?? MODELLED_INTERVAL_SECONDS;
  const occupancy = clamp01(options.occupancy ?? 0.72);
  const atSeconds = atMs / 1000;
  const vessels: WireVessel[] = [];

  for (const port of ports ?? []) {
    const calls = harbourCalls(port, { ...options, seed, occupancy });
    const scheduled = new Set(calls.map((call) => call.berthId));

    /**
     * A berth the channel does not reach keeps a hull alongside.
     *
     * The same static occupancy this file drew before there was a working day,
     * kept for exactly the berths a route cannot honestly be drawn to — see
     * `BERTH_APPROACH_REACH_METRES`. It is the difference between a quay that is
     * quiet and a quay that is empty, and the empty one would read as a bug.
     */
    for (const berth of port.berths ?? []) {
      if (scheduled.has(berth.id)) continue;
      const key = `${port.id}:${berth.id}`;
      if (hash01(seed, `${key}:occupied`) > occupancy) continue;
      const kind = berthKind(seed, key, berth);
      const length = berthLength(seed, key, berth, kind);
      // Off the wall, exactly as a scheduled one is. The water side comes from
      // the channel rather than from a route, because this berth has none — the
      // fairway is the one thing on a port that is certainly afloat.
      const afloat = nearestOnPath(port.channel ?? [], berth.lat, berth.lng);
      const lying = afloat
        ? lyingPosition(berth, afloat.lat, afloat.lng)
        : { lat: berth.lat, lng: berth.lng };
      vessels.push(alongsideFix(`m-${key}`, kind, lying.lat, lying.lng, length));
    }

    for (const call of calls) {
      const moment = harbourMoment(call, atSeconds);
      if (moment.phase === "empty") continue;
      const key = `${call.portId}:${call.berthId}:${moment.index}`;
      const kind = callKind(seed, key, call);
      const length = callLength(seed, key, call, kind);

      if (moment.phase === "alongside") {
        const lying = call.approach[call.approach.length - 1];
        if (!lying) continue;
        vessels.push(alongsideFix(`m-${key}`, kind, lying[0], lying[1], length));
        continue;
      }

      const leg = moment.phase;
      const seconds = leg === "inbound" ? call.inboundSeconds : call.outboundSeconds;
      const run = approachRun(call.approachMetres, seconds, moment.progress, leg);
      const at = alongPath(call.approach, run.arcMetres / call.approachMetres);
      if (!at) continue;
      // The route is authored seaward-end-first, so its bearing at any point is
      // the inbound course and a departure is the reciprocal of it.
      const course = leg === "inbound" ? at.bearing : normaliseDegrees(at.bearing + 180);
      vessels.push({
        id: `m-${key}`,
        kind,
        lat: at.lat,
        lon: at.lng,
        speed: run.speedMps,
        course,
        // `null`, always, and this is the most deliberate line in the simulator.
        // Half the fleet at rest reports no heading, so a modelled harbour whose
        // every hull volunteered one would exercise the easy path and leave the
        // berth-supplied orientation — the thing this workstream exists to get
        // right — permanently untested by the picture.
        heading: null,
        navStatus: 0,
        length,
        beam: Math.round(beamFor(kind, length)),
        ageSeconds: 0,
      });

      const tug = attendingTug(call, moment, run, seed, key);
      if (tug) vessels.push(tug);
    }
  }

  return {
    source: "modelled",
    fetchedAt: new Date(atMs).toISOString(),
    vessels,
    intervalSeconds,
    ttlSeconds: intervalSeconds,
    attribution: [
      "Modelled from this board's authored berths and channels. Not an observation of any vessel.",
      "Ship speeds, the channel and the berths are true; the rate of arrivals is compressed to about seven times a real day's so the harbour changes while you watch.",
    ],
  };
}

/**
 * The tug attending one arriving or departing ship, or `null`.
 *
 * A tug is the smallest hull on the board and almost always the one that is
 * moving, which at 391 m to the scene unit makes it the hull that reads: a
 * thirty-metre hull is a twelfth of a unit and invisible, and the V behind it is
 * four hundred metres of foam and is not. So the tug is here for the wake as
 * much as for the ship it is attending.
 *
 * Three sub-legs, and the first is the one worth having: for a short window
 * before the meeting the tug is drawn **running seaward**, out from its station
 * up-harbour and down past the incoming ship, on a reciprocal course at ten
 * knots. Two wakes crossing in opposite directions in a channel is a harbour
 * doing something, where a tug that simply materialised alongside is a decal.
 */
function attendingTug(
  call: HarbourCall,
  moment: HarbourMoment,
  run: { arcMetres: number; speedMps: number },
  seed: number,
  key: string,
): WireVessel | null {
  const inbound = moment.phase === "inbound";
  const x = moment.progress;
  const station = TUG_STATION * call.approachMetres;
  const length = Math.round(26 + 12 * hash01(seed, `${key}:tug`));
  const escortArc = Math.max(0, run.arcMetres - TUG_STATION_ASTERN);

  let arcMetres: number;
  let speedMps: number;
  let outbound: boolean;

  if (inbound && x >= TUG_MEETS_AT - TUG_RUN_OUT && x < TUG_MEETS_AT) {
    // Running out to meet her: from the station down-channel to the rendezvous,
    // over the window, at whatever speed that distance and that window imply.
    const meeting = approachRun(call.approachMetres, call.inboundSeconds, TUG_MEETS_AT, "inbound");
    const target = Math.max(0, meeting.arcMetres - TUG_STATION_ASTERN);
    const t = (x - (TUG_MEETS_AT - TUG_RUN_OUT)) / TUG_RUN_OUT;
    arcMetres = station + (target - station) * t;
    speedMps = Math.abs(station - target) / (TUG_RUN_OUT * call.inboundSeconds);
    outbound = station > target;
  } else if (inbound && x >= TUG_MEETS_AT && x < TUG_LEAVES_AT) {
    arcMetres = escortArc;
    speedMps = run.speedMps;
    outbound = false;
  } else if (!inbound && x <= TUG_DEPARTURE_UNTIL) {
    arcMetres = Math.min(call.approachMetres, run.arcMetres + TUG_STATION_ASTERN);
    speedMps = run.speedMps;
    outbound = true;
  } else if (!inbound && x <= TUG_DEPARTURE_UNTIL + TUG_RUN_OUT) {
    // Letting her go and running home, back up the channel toward the station.
    const release = approachRun(
      call.approachMetres,
      call.outboundSeconds,
      TUG_DEPARTURE_UNTIL,
      "outbound",
    );
    const from = Math.min(call.approachMetres, release.arcMetres + TUG_STATION_ASTERN);
    const t = (x - TUG_DEPARTURE_UNTIL) / TUG_RUN_OUT;
    arcMetres = from + (station - from) * t;
    speedMps = Math.abs(station - from) / (TUG_RUN_OUT * call.outboundSeconds);
    outbound = station < from;
  } else {
    return null;
  }

  if (!isMakingWay(speedMps)) return null;
  const at = alongPath(call.approach, arcMetres / call.approachMetres);
  if (!at) return null;
  const course = outbound ? normaliseDegrees(at.bearing + 180) : at.bearing;
  // Off the ship's quarter rather than in her wake, so both Vs are drawn rather
  // than one on top of the other.
  const abeam = offsetMetres(at.lat, at.lng, normaliseDegrees(course + 90), TUG_STATION_ABEAM);
  return {
    id: `m-${key}:tug`,
    kind: "tug",
    lat: abeam.lat,
    lon: abeam.lng,
    speed: speedMps,
    course,
    heading: null,
    navStatus: 0,
    length,
    beam: Math.round(beamFor("tug", length)),
    ageSeconds: 0,
  };
}

/** A hull lying alongside: no speed, no course, and therefore no wake. */
function alongsideFix(
  id: string,
  kind: VesselKind,
  lat: number,
  lng: number,
  length: number,
): WireVessel {
  return {
    id,
    kind,
    lat,
    lon: lng,
    /**
     * Zero, and it is the load-bearing zero in this file.
     *
     * A wake is a function of speed through water, so a ship that has just tied
     * up must lose hers in the same fix that puts her on the berth — a quay
     * lined with wakeless hulls and one long V curving in past the breakwater is
     * a picture of a working harbour, and a moored ship trailing foam is a
     * picture of a bug. `engine/vessels.ts` gates the wake on this number and
     * nothing else.
     */
    speed: 0,
    course: null,
    heading: null,
    navStatus: 5,
    length,
    beam: Math.round(beamFor(kind, length)),
    ageSeconds: 0,
  };
}

/**
 * The channel with a seaward leg on the front of it.
 *
 * The extension runs back along the reciprocal of the first charted leg, so a
 * ship stands in on the course the channel is already pointing at rather than
 * arriving from an invented direction.
 */
function seawardApproach(channel: readonly [number, number][]): [number, number][] {
  const first = channel[0];
  const second = channel[1];
  if (!first || !second) return channel.map(([lat, lng]) => [lat, lng]);
  const inbound = bearingBetween(first[0], first[1], second[0], second[1]);
  const out = offsetMetres(
    first[0],
    first[1],
    normaliseDegrees(inbound + 180),
    APPROACH_SEAWARD_METRES,
  );
  return [[out.lat, out.lng], ...channel.map(([lat, lng]): [number, number] => [lat, lng])];
}

/**
 * One berth's route in: the fairway as far as the point nearest the berth, then
 * a straight run alongside. `null` when the channel does not reach it.
 */
function berthApproach(
  fairway: readonly [number, number][],
  berth: Berth,
): [number, number][] | null {
  const best = nearestOnPath(fairway, berth.lat, berth.lng);
  if (best === null || best.metres > BERTH_APPROACH_REACH_METRES) return null;
  const leave: [number, number] = [best.lat, best.lng];
  const path: [number, number][] = [];
  for (let i = 0; i < best.index; i++) {
    const point = fairway[i];
    if (point) path.push([point[0], point[1]]);
  }
  const tail = path[path.length - 1];
  if (!tail || metresBetween(leave[0], leave[1], tail[0], tail[1]) > 1) path.push(leave);
  // The route ends where the ship lies, not on the wall she lies against, so
  // that the last minute of the run in and the hours alongside are the same
  // point and she does not step sideways the moment she is reported moored.
  const lying = lyingPosition(berth, leave[0], leave[1]);
  path.push([lying.lat, lying.lng]);
  return path.length >= 2 ? path : null;
}

/**
 * Where a hull lying at a berth actually floats: half a beam off the quay.
 *
 * `Berth.lat/lng` is a point **on the wall**, which is what a berth is — the
 * quay's own coordinate, authored with the concrete. A hull centred on it has
 * half its width inside the terminal, and the first photograph of the working
 * day showed exactly that: eleven ships reported alongside and barely a hull
 * visible, because each was buried to the centreline in its own quay and roofed
 * by a crane rail.
 *
 * Which way is water is not in `Berth` and is not guessed. It is taken from a
 * point that is definitely afloat — the place the ship left the fairway, or for
 * a berth with no route the nearest point on the channel — and then squared up:
 * the offset runs along whichever perpendicular to the **quay's own bearing**
 * agrees with that direction, so a ship lies parallel to the wall however
 * oblique her approach was.
 *
 * The offset is sized from the berth rather than from the ship, and that is
 * deliberate: where a hull lies is a property of the fender line, so every ship
 * on a 400 m berth lies on the same line whether she is 280 m or 350 m long. The
 * beam used is the widest hull the berth can take — a full-length container ship
 * is the broadest thing in `beamFor` that a deep-sea berth ever sees — so no
 * ship's plating ever reaches back over the coping.
 */
function lyingPosition(
  berth: Pick<Berth, "lat" | "lng" | "bearing" | "maxLength">,
  towardLat: number,
  towardLng: number,
): { lat: number; lng: number } {
  const seaward = bearingBetween(berth.lat, berth.lng, towardLat, towardLng);
  const side = normaliseDegrees(berth.bearing + 90);
  const water = Math.abs(signedDelta(side, seaward)) <= 90 ? side : normaliseDegrees(side + 180);
  const widest = berth.maxLength > 0 ? berth.maxLength : DEFAULT_HULL.container.length;
  const beam = beamFor("container", widest * 0.88);
  return offsetMetres(berth.lat, berth.lng, water, beam / 2 + BERTH_STANDOFF_METRES);
}

/** Fenders, camels and the gap a ship actually lies off a wall at, in metres. */
const BERTH_STANDOFF_METRES = 6;

/** The point on a polyline nearest a place, and how far off it is, in metres. */
function nearestOnPath(
  path: readonly [number, number][],
  lat: number,
  lng: number,
): { lat: number; lng: number; index: number; t: number; metres: number } | null {
  let best: { index: number; t: number; metres: number } | null = null;
  for (let i = 1; i < path.length; i++) {
    const a = path[i - 1];
    const b = path[i];
    if (!a || !b) continue;
    const scale = Math.cos(((a[0] + b[0]) / 2) * DEG);
    const bx = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * scale;
    const by = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
    const px = (lng - a[1]) * METRES_PER_DEGREE_LAT * scale;
    const py = (lat - a[0]) * METRES_PER_DEGREE_LAT;
    const square = bx * bx + by * by;
    const t = square > 0 ? clamp01((px * bx + py * by) / square) : 0;
    const metres = Math.hypot(px - bx * t, py - by * t);
    if (best === null || metres < best.metres) best = { index: i, t, metres };
  }
  if (best === null) return null;
  const a = path[best.index - 1];
  const b = path[best.index];
  if (!a || !b) return null;
  return {
    lat: a[0] + (b[0] - a[0]) * best.t,
    lng: a[1] + (b[1] - a[1]) * best.t,
    index: best.index,
    t: best.t,
    metres: best.metres,
  };
}

/** Total length of a polyline, in metres. */
export function pathMetres(path: readonly [number, number][]): number {
  let total = 0;
  for (let i = 1; i < path.length; i++) {
    const a = path[i - 1];
    const b = path[i];
    if (!a || !b) continue;
    total += metresBetween(a[0], a[1], b[0], b[1]);
  }
  return total;
}

/** A point `metres` away on a true bearing. Flat-earth, over a few kilometres. */
export function offsetMetres(
  lat: number,
  lng: number,
  bearingDegrees: number,
  metres: number,
): { lat: number; lng: number } {
  const radians = bearingDegrees * DEG;
  const north = (Math.cos(radians) * metres) / METRES_PER_DEGREE_LAT;
  const perDegreeLng = METRES_PER_DEGREE_LAT * Math.cos(lat * DEG);
  const east = perDegreeLng > 1 ? (Math.sin(radians) * metres) / perDegreeLng : 0;
  return { lat: lat + north, lng: lng + east };
}

/** A berthed hull's length: most of the berth, never all of it. */
function berthLength(seed: number, key: string, berth: Berth, kind: VesselKind): number {
  const fallback = DEFAULT_HULL[kind];
  const maxLength = berth.maxLength > 0 ? berth.maxLength : fallback.length;
  /**
   * 70-88% of the berth, and the ceiling is what stops a terminal reading as one
   * continuous wall of steel.
   *
   * Photographed: Pier 400's authored berths are 356 m apart and it fills to
   * 400 m, so at 97% two consecutive hulls touched stem to stern and the two
   * vehicle carriers alongside read as one 700 m object.
   */
  return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}

/** The kind of ship this call brought, from the berth it is for. */
function callKind(seed: number, key: string, call: HarbourCall): VesselKind {
  return berthKind(seed, key, { maxLength: call.maxLength } as Berth);
}

function callLength(seed: number, key: string, call: HarbourCall, kind: VesselKind): number {
  const fallback = DEFAULT_HULL[kind];
  const maxLength = call.maxLength > 0 ? call.maxLength : fallback.length;
  return Math.round(maxLength * (0.7 + 0.18 * hash01(seed, `${key}:length`)));
}

// ---- Arithmetic -----------------------------------------------------------

function readWireSpeed(speed: number | null | undefined): number | null {
  if (typeof speed !== "number" || !Number.isFinite(speed) || speed < 0) return null;
  // The sentinel in the units the wire uses. Compared with a tolerance because
  // 102.3 * 0.514444 does not round-trip exactly through a float.
  if (Math.abs(speed - AIS_SOG_UNAVAILABLE_KN * KNOTS_TO_MPS) < 1e-6) return null;
  return speed;
}

function positive(value: number | null | undefined): number | null {
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
}

function inBounds(lat: number, lng: number, bounds: VesselBounds): boolean {
  return (
    lat >= bounds.minLat && lat <= bounds.maxLat && lng >= bounds.minLng && lng <= bounds.maxLng
  );
}

/** Great-circle-enough distance for a few hundred metres of harbour. */
export function metresBetween(
  aLat: number,
  aLng: number,
  bLat: number,
  bLng: number,
): number {
  const dLat = (bLat - aLat) * METRES_PER_DEGREE_LAT;
  const dLng = (bLng - aLng) * METRES_PER_DEGREE_LAT * Math.cos(((aLat + bLat) / 2) * DEG);
  return Math.hypot(dLat, dLng);
}

/** Bearing from one point to another, degrees clockwise from true north. */
export function bearingBetween(
  aLat: number,
  aLng: number,
  bLat: number,
  bLng: number,
): number {
  const north = (bLat - aLat) * METRES_PER_DEGREE_LAT;
  const east = (bLng - aLng) * METRES_PER_DEGREE_LAT * Math.cos(((aLat + bLat) / 2) * DEG);
  return normaliseDegrees((Math.atan2(east, north) / DEG));
}

export function normaliseDegrees(degrees: number): number {
  const wrapped = degrees % 360;
  return wrapped < 0 ? wrapped + 360 : wrapped;
}

/** `a - b` folded into -180..180, so a clamp on it is a clamp on a turn. */
function signedDelta(a: number, b: number): number {
  return ((((a - b) % 360) + 540) % 360) - 180;
}

function clamp01(value: number): number {
  return value < 0 ? 0 : value > 1 ? 1 : value;
}

/**
 * A point a fraction of the way along a polyline, with the path's bearing there.
 *
 * By segment length rather than by index, so a channel authored with a long
 * outer leg and three short dogleg points does not park every modelled ship in
 * the dogleg.
 */
export function alongPath(
  path: readonly [number, number][],
  fraction: number,
): { lat: number; lng: number; bearing: number } | null {
  if (path.length === 0) return null;
  const first = path[0];
  if (!first) return null;
  if (path.length === 1) return { lat: first[0], lng: first[1], bearing: 0 };
  const legs: number[] = [];
  let total = 0;
  for (let i = 1; i < path.length; i++) {
    const a = path[i - 1];
    const b = path[i];
    if (!a || !b) continue;
    const metres = metresBetween(a[0], a[1], b[0], b[1]);
    legs.push(metres);
    total += metres;
  }
  if (total <= 0) return { lat: first[0], lng: first[1], bearing: 0 };
  let want = clamp01(fraction) * total;
  for (let i = 0; i < legs.length; i++) {
    const leg = legs[i] ?? 0;
    const a = path[i];
    const b = path[i + 1];
    if (!a || !b) continue;
    if (want <= leg || i === legs.length - 1) {
      const t = leg > 0 ? clamp01(want / leg) : 0;
      return {
        lat: a[0] + (b[0] - a[0]) * t,
        lng: a[1] + (b[1] - a[1]) * t,
        bearing: bearingBetween(a[0], a[1], b[0], b[1]),
      };
    }
    want -= leg;
  }
  return null;
}

/**
 * A stable 0..1 from a seed and a string.
 *
 * FNV-1a, the same shape every other deterministic thing in this repo uses. It
 * is here rather than imported because the alternative is a dependency from a
 * pure data module on a renderer helper, and the whole of this file's value is
 * that it depends on nothing.
 */
export function hash01(seed: number, key: string): number {
  let h = (2_166_136_261 ^ Math.trunc(seed)) >>> 0;
  for (let i = 0; i < key.length; i++) {
    h ^= key.charCodeAt(i);
    h = Math.imul(h, 16_777_619) >>> 0;
  }
  return h / 4_294_967_296;
}

/**
 * Beam from length, per kind.
 *
 * A display default like `DEFAULT_HULL`, and stated as a ratio because that is
 * what it is: a container ship is about 6.6 times as long as it is wide, a tug
 * under 3. The one number worth knowing is that a Panamax-plus box ship is 400 x
 * 61 m, which this returns to within a metre.
 */
export function beamFor(kind: VesselKind, length: number): number {
  const ratio =
    kind === "tug"
      ? 2.8
      : kind === "fishing"
        ? 3.6
        : kind === "ferry"
          ? 4.4
          : kind === "bulk"
            ? 7.0
            : kind === "tanker"
              ? 5.7
              : 6.6;
  return length / ratio;
}

function berthKind(seed: number, key: string, berth: Berth | BerthAnchor): VesselKind {
  const maxLength = "maxLength" in berth ? berth.maxLength : 0;
  const roll = hash01(seed, `${key}:kind`);
  // The berth's own length is the strongest signal there is: a 120 m berth is
  // not a container terminal and a 400 m one is not a fishing dock.
  if (maxLength > 0 && maxLength < 80) return roll < 0.6 ? "fishing" : "tug";
  if (maxLength > 0 && maxLength < 180) return roll < 0.5 ? "ferry" : "tug";
  if (roll < 0.62) return "container";
  if (roll < 0.78) return "tanker";
  if (roll < 0.9) return "bulk";
  return "vehicle-carrier";
}

