/**
 * The HTTP contract: every body that crosses between the browser build and the
 * Tera API, and nothing else.
 *
 * **This file is types only.** It compiles to nothing, which is the whole point.
 * The browser can import it without paying for a runtime module, and the server
 * can import it without the browser package becoming one of its dependencies —
 * so one declaration of each body serves both sides and there is no second
 * implementation to drift. Everything that imports from here must use
 * `import type`, which `verbatimModuleSyntax` already enforces.
 *
 * Two types here deliberately mirror engine types rather than importing them:
 * `WireSimRoute` is structurally `SimRoute` from `engine/flights.ts`. The server
 * must be able to build one and it must not pull three.js in to do it, so the
 * shape is restated. `WireMarker`, by contrast, genuinely *extends* `Marker`,
 * because a marker off the wire is handed straight to `setMarkers()` and the two
 * being the same type is what guarantees that stays true.
 *
 * Routes, all under `/api/v1`:
 *
 * | route            | body            | cacheable |
 * | ---------------- | --------------- | --------- |
 * | `GET /health`    | `HealthBody`    | no        |
 * | `GET /flights`   | `FlightsBody`   | yes       |
 * | `GET /satellites` | `SatellitesBody` | yes    |
 * | `GET /fires`     | `FiresBody`     | yes       |
 * | `GET /weather`   | `WeatherBody`   | yes       |
 * | `GET /markers`   | `MarkersBody`   | yes       |
 * | `GET /vessels`   | `VesselsBody`   | yes       |
 * | `GET /ports`     | `PortsBody`     | yes       |
 * | `GET /radar`     | `RadarBody`     | yes       |
 * | `GET /birds`     | `BirdsBody`     | yes       |
 * | `GET /offices/:id` | `OfficeDoc`   | public offices only |
 * | `GET /offices/:id/presence` | `PresenceBody` | never |
 * | `GET /offices/:id/devices` | `DevicesBody` | never |
 * | `POST /offices/:id/devices/command` | `DeviceCommandResultBody` | never |
 *
 * Note the last two. Reading device state and commanding a device are two
 * routes and two methods, and that is a **security boundary rather than REST
 * taste**: a command that rode in the read body could be replayed by any shared
 * cache that had kept a copy of the GET, and turning a microphone on by
 * replaying a cached read is precisely the outcome the fail-closed
 * `private, no-store` default in CONTRACT.md §5 exists to prevent. Neither
 * route is ever `publicCache`d, and the command route is the only body in this
 * file that goes *up* the wire.
 *
 * See CONTRACT.md §5.
 */

import type { DeviceCommand, DeviceState } from "../devices/types.ts";
import type { Marker, SatelliteGroup } from "../engine/types.ts";
import type { Office, Presence } from "../interiors/types.ts";

export type { SatelliteGroup };

/** Path prefix every route lives under. Stated here so both sides read it once. */
export type ApiBase = "/api/v1";

// ---- Errors ---------------------------------------------------------------

/**
 * The only error shape. `error` is a stable machine token; `message` is for a
 * human reading a log and may change without notice.
 *
 * Note what is absent: there is no `403`. A request for something the caller may
 * not see gets `not_found`, because an endpoint that distinguishes "does not
 * exist" from "exists, but not for you" is an enumeration oracle. See
 * CONTRACT.md §6.
 */
export interface ErrorBody {
  error: "not_found" | "bad_request" | "unauthorized" | "upstream_unavailable" | "internal";
  message: string;
}

// ---- Health ---------------------------------------------------------------

export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo";
export type FlightsSourceId = "sim" | "adsb" | "dump1090";
export type SatellitesSourceId = "none" | "celestrak";
/**
 * Where fire data comes from.
 *
 * `none` is the default and serves a real, empty body — never an invented fire.
 * The asymmetry with `FlightsBody`'s simulated plan is deliberate and is the
 * same one `SatellitesBody` draws: an invented aeroplane is a plausible
 * aeroplane, and an invented wildfire is a claim that a named place is burning.
 *
 * `cloud1` is a **projection served by another machine**, which is the whole
 * design of this feed rather than an implementation detail. The upstream store
 * is centred on a private home and carries four columns computed from the
 * distance to it — `observations.distance_km`, `bearing_deg`, `threat` and
 * `detections.distance_km`. `threat` is the subtle one: it is
 * `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`,
 * so with acreage and containment public it inverts to a circle around the
 * house and three fires give an intersection. None of those columns appears in
 * any type below, and the reason they cannot appear is not care — it is that
 * the machine holding them never sends them. See `server/src/fires/cloud1.ts`.
 */
export type FiresSourceId = "none" | "cloud1";
export type MarkersSourceId = "none" | "file";
/**
 * Where ship positions come from.
 *
 * `none` is the default and it is the honest default for longer than usual:
 * cloud-1 serves no `/api/sea` and no `/api/vessels` today, and aisstream.io's
 * terms have not been read. `flights/licence.ts` exists because this box once
 * re-served an unchecked feed under a credit line the source had never seen, and
 * that mistake is one line of config away from being repeated here.
 *
 * `modelled` is not a simulator in the `FlightsSourceId: "sim"` sense and the
 * different word is deliberate. A simulated aeroplane carries a callsign and a
 * plausible identity; a modelled vessel is an **anonymous** hull at an authored
 * berth, with no name and no MMSI, and the panel says the feed is not
 * configured. An invented named ship is a claim about a real commercial vessel.
 */
export type VesselsSourceId = "none" | "modelled" | "cloud1";
/** Where the reflectivity raster comes from. `none` draws no sheet at all. */
export type RadarSourceId = "none" | "cloud1";
/** Where nocturnal migration comes from. `none` draws no motes and says so. */
export type BirdsSourceId = "none" | "cloud1";
/**
 * Where device readings come from.
 *
 * `none` is the default and serves an empty array — a box nobody has told about
 * any hardware has no hardware, which renders as a studio whose panels say so
 * rather than as an error. `sim` is the deterministic state machine in
 * `src/devices/sim.ts`, the same module the arena wraps, and it is what this
 * build ships.
 *
 * `first-party` is an operator's own bridge to their own hardware, reached over
 * their own network — `server/src/devices/firstParty.ts`. It is deliberately
 * **not** called `la-studio`: the first one of these happens to read Lumbridge's
 * studio, but a self-hoster pointing `TERA_STUDIO_URL` at their own box is the
 * same source and should not have to name somebody else's room to use it.
 *
 * `homeassistant` is named here and implemented nowhere, and stays that way: it
 * is one layer further out than `first-party` (a first-party bridge may itself
 * talk to Home Assistant), and naming it in the union is what stops the next
 * person from adding a second, differently shaped source field when they build
 * it. `config.ts` demotes it loudly.
 */
export type DevicesSourceId = "none" | "sim" | "homeassistant" | "first-party";
export type AuthMode = "none" | "sso" | "jwt";

/**
 * One place this box will answer about. Structurally `Region` in
 * `server/src/regions.ts`, restated here for the same reason `WireSimRoute` is:
 * this file is the contract and the server's own module is an implementation of
 * it, and the browser must not have to import server code to read a body.
 */
export interface WireRegion {
  /** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
  id: string;
  lat: number;
  lng: number;
  /** Kilometres. */
  radiusKm: number;
}

/**
 * What this deployment turned out to be, once the environment had its say.
 *
 * `degraded` is the load-bearing field. A source configured without what it
 * needs is demoted rather than fatal (CONTRACT.md §5.1), and this is where the
 * demotion is visible to anybody who did not read the boot log — without it, a
 * misconfigured contact string looks exactly like a clear day.
 */
export interface HealthBody {
  ok: true;
  service: "tera-api";
  version: string;
  uptimeSeconds: number;
  sources: {
    weather: WeatherSourceId;
    flights: FlightsSourceId;
    satellites: SatellitesSourceId;
    markers: MarkersSourceId;
    /**
     * Newer than the four above it, and read defensively by
     * `feedsFrom()` in `src/access.ts` for exactly that reason: a browser
     * meeting a server one version behind this one sees `undefined` and
     * concludes the box has no devices, which is the safe direction for a
     * missing field to fall. Its job is to let a client know that asking is
     * pointless before it opens a watch that will 404 forever.
     */
    devices: DevicesSourceId;
    /**
     * Newer again, and read the same defensive way `devices` is: a browser
     * meeting a server one version behind this one sees `undefined` and must
     * conclude the box serves no fires, which is the safe direction. A board
     * that draws nothing because nobody answered and a board that draws nothing
     * because nothing is burning are the same picture, and only the fetch age
     * beside it tells them apart — which is why `FiresBody.fetchedAt` is not
     * optional.
     */
    fires?: FiresSourceId;
    /**
     * The three feeds this build added, all optional and all read the same
     * defensive way `devices` and `fires` are: a browser meeting a server one
     * version behind sees `undefined` and must conclude the box serves none of
     * them, which is the safe direction for a missing field to fall.
     *
     * All three default to `none`, and for all three an empty answer is the
     * commonest correct one. California is under rain a mean 0.596% of the time
     * and birds are absent about fourteen hours in every twenty-four by
     * construction, so a board with nothing on it is the normal picture and only
     * the age beside it separates that from a dead feed.
     */
    vessels?: VesselsSourceId;
    radar?: RadarSourceId;
    birds?: BirdsSourceId;
  };
  auth: {
    mode: AuthMode;
    /** Where a browser sends someone to sign in. `null` unless mode is `sso`. */
    entryUrl: string | null;
  };
  /**
   * Every place this box will answer about, in the config's order, so the first
   * entry is what a request with no query gets.
   *
   * Here rather than in a `HealthBodyWithRegions` alias next to the route.
   * Weather and flights refuse a place this box does not serve, so a client
   * that guesses `?city=` earns a 400 it cannot explain; publishing the
   * allowlist turns that into one question asked once. It gives nothing away —
   * knowing what is served is not the same as widening it, and the ids are the
   * names of cities the map already draws.
   */
  regions: WireRegion[];
  /** One human sentence per demotion. Empty on a fully-configured box. */
  degraded: string[];
}

// ---- Flights --------------------------------------------------------------

/**
 * A leg the simulator flies. Structurally identical to `SimRoute` in
 * `engine/flights.ts`; see the note at the top of this file for why it is
 * restated rather than imported.
 */
export interface WireSimRoute {
  callsign: string;
  from: [number, number];
  to: [number, number];
  /** Metres at the start and end of the leg. */
  fromAlt: number;
  toAlt: number;
  /** Seconds for a full traversal. */
  duration: number;
}

/** One aircraft, as `engine/types.ts` `Aircraft` wants it. */
export interface WireAircraft {
  id: string;
  lat: number;
  lng: number;
  /** Metres. The wire never carries feet, whatever the upstream feed used. */
  altitude: number;
  /** Degrees clockwise from true north. */
  heading: number;
  callsign?: string;
  /**
   * Metres per second over the ground, when the feed reported a real one.
   *
   * SI, like every other quantity on this wire — the feed's own `gs` is knots
   * and the conversion happens once, in the adapter, for the same reason
   * `altitude` is not feet. See the note there.
   *
   * **This is the field that makes the sky move.** A client that is handed only
   * positions can interpolate between the last two of them and nothing more, so
   * every aircraft replays a leg it has already flown, arrives at the newest
   * known point and then sits perfectly still until the next snapshot lands
   * five to fifteen seconds later. Carrying the velocity lets the client
   * dead-reckon — advance each track along its own track angle at its own
   * speed, every frame — and an aeroplane that is flying stops being a
   * screenshot of one.
   *
   * Absent, never zeroed, and absent covers three different things that must
   * all behave the same way downstream: a feed that did not report it, a
   * ground vehicle or parked aircraft reporting `gs: 0.0`, and a
   * position-only record with a null `track` — dead-reckoning any of those
   * along an invented heading would be inventing motion, which is worse than
   * showing none. `engine/flights.ts` holds a track still when this is missing.
   */
  groundSpeed?: number;
  /**
   * Metres per second, positive climbing, when the feed reported a rate.
   *
   * Barometric where the feed has it and geometric otherwise; the two disagree
   * by a few percent in real air and by nothing this renders. Absent rather
   * than zero for the same reason as `groundSpeed`: level flight and no
   * information are different facts, and only one of them may be drawn.
   */
  verticalRate?: number;
  /**
   * How old the position fix already was when this snapshot was taken, in
   * seconds — the feed's `seen_pos`.
   *
   * Carried because the client is the thing that has to place the aircraft and
   * it cannot do that without knowing what instant the coordinates describe.
   * Between the receiver's last message, this box's cache TTL and the browser's
   * own hold, a fix reaches a viewer several seconds stale, and a dead-reckoner
   * handed a stale position as if it were current draws the whole sky lagging
   * by that much. `observedAt` on the body says when the *snapshot* was taken;
   * this says how far behind that the row already was.
   *
   * Small — a fraction of a second on a healthy feed — and worth carrying
   * anyway, because it is the difference between a client that can reason about
   * time and one that assumes.
   */
  ageSeconds?: number;
  /**
   * The tail number the feed published for this airframe, e.g. `"N68834"`.
   *
   * ODbL data off the same record as the position, and publishable on exactly
   * the same terms — it is the enrichment a commercial feed was once wanted
   * for, obtained legitimately. Absent, never invented: a registration is what
   * somebody types into a registry lookup, and a wrong one names another
   * aircraft altogether.
   */
  registration?: string;
  /**
   * ICAO aircraft type designator, e.g. `"B739"`, `"A321"`.
   *
   * A four-character code and not a marketing name: the feed publishes the
   * designator, and expanding it to "Boeing 737-900" would mean shipping a
   * table this repo would then have to keep true. The card shows the code
   * beside the registration, which is how a spotter reads it anyway.
   */
  type?: string;
  /**
   * The transponder's 24-bit ICAO address, lowercase hex, when the feed gave a
   * real one.
   *
   * Carried explicitly even though `id` is usually the same string, because
   * "usually" is the problem: `id` falls back to the callsign for a record with
   * no hex, and both community feeds emit `~`-prefixed anonymous addresses for
   * TIS-B and MLAT targets, which are *not* ICAO addresses. Somebody pastes
   * this into a registry lookup, so a wrong one names another aircraft
   * altogether — and `Aircraft` in `engine/types.ts` has nowhere to put it,
   * which is why the adapter keeps this record beside the position rather than
   * inferring the address back out of the id.
   *
   * Absent, never invented. `aircraftDetail()` in `engine/flights.ts` is where
   * it becomes a card.
   */
  icao24?: string;
}

/**
 * The simulated sky, sent as a *plan* rather than as positions.
 *
 * The server hands over the routes, a phase origin and a seed, and every browser
 * evaluates the same closed-form function of wall-clock time. That means one
 * cheap cacheable request instead of a poll every second, and — more usefully —
 * two people looking at the map from different machines see the same aircraft in
 * the same places, which a per-client simulation cannot promise.
 *
 * `t0` is a fixed epoch and emphatically **not** the server's start time: if it
 * moved on restart, every aircraft would teleport.
 */
export interface FlightsPlanBody {
  mode: "plan";
  source: "sim";
  /** Epoch milliseconds. The instant at which every route's phase is zero. */
  t0: number;
  /** Seed for the per-route phase offsets, so all viewers agree. */
  seed: number;
  routes: WireSimRoute[];
  /** How long the plan may be cached, in seconds. */
  ttlSeconds: number;
}

/** Real traffic, as positions, because there is no closed form for the sky. */
export interface FlightsLiveBody {
  mode: "live";
  source: "adsb" | "dump1090";
  /** Epoch milliseconds at which this snapshot was taken. */
  observedAt: number;
  aircraft: WireAircraft[];
  ttlSeconds: number;
  /**
   * Attribution the consumer is expected to display, if the feed asks for it.
   *
   * **Derived from the host that answered**, in `server/src/flights/licence.ts`,
   * and never authored next to the request. It said `adsb.lol` unconditionally
   * once, whatever `TERA_ADSB_ENDPOINT` pointed at, which is how a credit line
   * and a source come to disagree.
   */
  attribution?: string[];
  /**
   * May a shared cache — or the consumer — hand these bytes to a third party?
   *
   * The licence the positions arrived under, reduced to the one bit that
   * changes behaviour. `false` keeps the route on the fail-closed
   * `private, no-store` default from CONTRACT.md §5, so a feed this deployment
   * may *use* but not *redistribute* stops at the browser that asked. Required
   * rather than optional: a body with no answer to this question is a body
   * somebody will assume `true` for.
   */
  redistributable: boolean;
  /**
   * The licence id the source publishes under, e.g. `"ODbL-1.0"`, or
   * `"first-party"` for an operator's own receiver. For display and for a
   * human reading `/api/v1/flights` directly; the machine-readable half of the
   * same fact `attribution` states in prose.
   */
  licence?: string;
}

export type FlightsBody = FlightsPlanBody | FlightsLiveBody;

// ---- Satellites -----------------------------------------------------------

/**
 * One satellite, sent as its **element set** rather than as a position.
 *
 * The same trick `FlightsPlanBody` plays, for the same reason and with better
 * justification: a TLE is already a closed-form description of an orbit, valid
 * for days either side of its epoch, and SGP4 is the function that evaluates it.
 * Sending positions would mean polling — a satellite crosses the sky in ten
 * minutes — and would mean two people looking at the same overhead pass from
 * different machines disagreeing about where it is. Sending the elements means
 * one cacheable request every few hours and universal agreement, which is
 * exactly the property the flight plan exists to buy.
 *
 * It is also the *honest* shape. CelesTrak publishes element sets; positions are
 * something a consumer computes. A server that computed them would be inserting
 * itself into a calculation it adds nothing to.
 *
 * `line1` and `line2` are the two 69-character TLE lines, verbatim. They are
 * carried as strings rather than parsed into fields because SGP4 implementations
 * take exactly this and every parse in between is a chance to lose a digit.
 */
export interface WireSatellite {
  /** NORAD catalogue number, from columns 3–7 of line 1. Stable for the object's life. */
  noradId: number;
  name: string;
  group: SatelliteGroup;
  /** The first TLE line, 69 characters, unmodified. */
  line1: string;
  /** The second TLE line, 69 characters, unmodified. */
  line2: string;
}

/**
 * The catalogue this box is serving, and when it last managed to fetch one.
 *
 * There is no `mode` discriminant here, unlike `FlightsBody`, because there is
 * only ever one mode: elements. A box with no satellite source configured serves
 * `source: "none"` and an **empty array** rather than a synthetic constellation,
 * and that asymmetry with the flight plan is deliberate. An invented aeroplane is
 * a plausible aeroplane; an invented Starlink is a lie about a specific object
 * with a catalogue number, and somebody standing in a field with a telescope
 * would be entitled to be annoyed about it. The sky either has the real thing in
 * it or it has nothing.
 */
export interface SatellitesBody {
  source: SatellitesSourceId;
  /**
   * ISO-8601, the last time a fetch **succeeded**. Not the time of this response:
   * a body served from a six-hour-old snapshot must say so, because the client
   * has no other way to tell a fresh catalogue from a stale one and SGP4 accuracy
   * degrades with distance from the element epoch.
   */
  fetchedAt: string;
  satellites: WireSatellite[];
  ttlSeconds: number;
  attribution?: string[];
}

// ---- Fires ----------------------------------------------------------------

/**
 * One active wildfire incident, as CAL FIRE and WFIGS describe it — and as a
 * projection served by cloud-1 is willing to say.
 *
 * ### What is not here, and why that is structural
 *
 * The upstream store carries `distance_km`, `bearing_deg` and `threat` on every
 * observation, all three measured from a private home. They are absent from this
 * type, but the type is not what keeps them off the wire: the machine that holds
 * them never puts them in a response. `server/src/fires/cloud1.ts` reads a
 * projection over HTTP and has no database to be careless with. That ordering is
 * the point — a filter downstream of a copy is a filter somebody can forget.
 *
 * ### Identity is split from observation upstream, and rejoined here
 *
 * `incidents` holds the name and the coordinate; `observations` holds acreage
 * and containment, one row per ten-minute poll. The projection joins each
 * incident to its **latest** observation, so `acres` and `pctContained` describe
 * the same instant `observedAt` names. Both are `null` where the agency has not
 * said, and null acreage is common: on the SoCal board today every live incident
 * has it, because fifteen of them are LA County dispatch numbers that will never
 * become fires. `promote()` in `src/server/fires.ts` is what refuses to draw
 * them, and it is the reason this type exposes the raw nulls rather than
 * defaulting them to zero — a zero would pass a `>= 0` test somewhere.
 */
export interface FireIncident {
  /** IrwinID (WFIGS) or UniqueId (CAL FIRE). Stable for the incident's life. */
  id: string;
  /** `calfire`, `wfigs`. Which agency's record this row came from. */
  source: string;
  /**
   * The incident name, trimmed, or `null` where the agency published none.
   *
   * Not defaulted to the id. Fifteen of today's live SoCal rows are named
   * `LAC-297933` and similar — a dispatch number, not a fire — and a renderer
   * that printed one under a flame glyph would be inventing an event.
   */
  name: string | null;
  lat: number;
  lon: number;
  /** Where the coordinate came from. `us-gov` for both agencies. CONTRACT.md §8. */
  provenance: CoordinateProvenance;
  county: string | null;
  /**
   * `WF` for a wildfire, `RX` for a **prescribed burn**, `""` where unstated.
   *
   * Carried rather than filtered upstream because it is the client's tier gate
   * that must refuse it, and refusing it silently at the endpoint would leave no
   * way to say how many were refused. An RX is deliberate, scheduled and
   * frequently adjacent to a real fire; under a distance filter it is
   * indistinguishable from one, and drawing it as a wildfire is a false alarm
   * about a planned event.
   */
  type: string;
  url: string | null;
  /** ISO-8601. When this incident was first written to the store. */
  firstSeen: string;
  /**
   * ISO-8601. The last poll that saw this incident in an agency feed.
   *
   * **Load-bearing, and the reason a body-level `latestSeen` sits beside it.**
   * The collector writes every row it is handed and never deletes, and it
   * de-duplicates two agencies' copies of one fire only in the list it returns —
   * so the loser of a de-duplication keeps its old `lastSeen` forever. Twenty-one
   * of ninety-five rows were ghosts when this was designed, including a second
   * Timber Fire 850 m away with different acreage, which means a naive reader
   * both double-counts and under-reports the same fire at once. Anything drawn
   * must have `lastSeen === FiresBody.latestSeen`.
   */
  lastSeen: string;
  /** ISO-8601 of the joined observation, or `null` when there is none at all. */
  observedAt: string | null;
  /** Acres burned, as last reported. `null` is "the agency has not said". */
  acres: number | null;
  /** Percent contained, 0–100. `null` is "not said", which is not zero. */
  pctContained: number | null;
}

/**
 * One satellite hot pixel. **Evidence, never an incident.**
 *
 * A thermal anomaly in a NASA FIRMS product is a pixel that was hotter than its
 * neighbours on one overpass. Most of them are not fires: there is a permanent
 * industrial heat source 4.7 km from the upstream operator's house that appears
 * on every pass at FRP ~1.0 with no matching incident, and it will be there
 * tomorrow. Anything that renders this layer must make it visually weaker than
 * and separate from the incident layer, and must never promote one to a fire
 * client-side. `promote()` drops `persistent` pixels from the drawn set for
 * exactly that reason.
 */
export interface FireDetection {
  /** `MODIS`, `VIIRS-NOAA20`, `VIIRS-SNPP` — the instrument, verbatim. */
  sat: string;
  /** ISO-8601, normalised from the archive's `YYYY-MM-DDTHHMMZ`. */
  acquiredAt: string;
  lat: number;
  lon: number;
  /** Fire radiative power, megawatts. `null` where the product did not report. */
  frp: number | null;
  /**
   * The product's own confidence string, **unconverted**.
   *
   * MODIS reports an integer 0–100 and VIIRS reports `low`/`nominal`/`high`, in
   * the same column, and a consumer that maps this to an opacity without
   * branching on `sat` is wrong for one of the two on every frame. Use
   * `detectionConfidence()` in `src/server/fires.ts`, which does the branch once.
   */
  confidence: string | null;
  /**
   * Has a low-power pixel appeared in this cell on more than one day?
   *
   * The learned ignore-list for industrial heat, computed at the endpoint over a
   * fourteen-day window: distinct days on which a sub-5-MW pixel landed within
   * 0.02° of here. `true` means furniture — a flare stack, a kiln, a landfill —
   * not news. It is deliberately computed from the store rather than from a
   * hand-written exclusion list, because the list nobody maintains is the list
   * that gets a real fire suppressed.
   */
  persistent: boolean;
  /** How many distinct days fed `persistent`. Carried so a caption can say it. */
  persistentDays: number;
}

/**
 * Every fire this deployment knows about, and when it last managed to ask.
 *
 * One body for the whole state and one cache key, exactly as `SatellitesBody`
 * argues: the boards this build draws are rectangles inside California, the
 * incident set is small (five drawable fires state-wide on the day this was
 * written), and a server that filtered by board would be doing a worse job of a
 * clip the client has to do anyway. `promote()` is the clip.
 *
 * `fetchedAt` is **not optional and not the response time**. A board with
 * nothing on it is the commonest correct answer this feed will ever give, and a
 * silent empty board is indistinguishable from a dead feed without an age beside
 * it. That is the same argument `HealthBody.degraded` makes, applied to a
 * picture instead of a log.
 */
export interface FiresBody {
  source: FiresSourceId;
  /** ISO-8601, the last time a fetch **succeeded**. Epoch zero when never. */
  fetchedAt: string;
  /**
   * The newest `lastSeen` in the upstream incident table, or `null` when it is
   * empty.
   *
   * The de-duplication watermark. Every drawn incident must match it exactly;
   * see `FireIncident.lastSeen` for what happens to a reader that does not
   * check.
   */
  latestSeen: string | null;
  incidents: FireIncident[];
  detections: FireDetection[];
  /** How many hours of overpasses `detections` covers. */
  detectionWindowHours: number;
  ttlSeconds: number;
  attribution?: string[];
}

// ---- Weather --------------------------------------------------------------

/**
 * Sky conditions, reduced to what a light rig can actually use.
 *
 * Not a met report: no dew point, no pressure, no station id. `Atmosphere`
 * consumes cloud cover, precipitation and visibility and nothing else, and a
 * field that no renderer reads is a field that gets wrong without anyone
 * noticing.
 */
export type WeatherCondition =
  | "clear"
  | "partly-cloudy"
  | "cloudy"
  | "overcast"
  | "fog"
  | "rain"
  | "snow"
  | "thunderstorm";

export interface WeatherBody {
  /** ISO-8601. The observation time, not the fetch time. */
  observedAt: string;
  source: WeatherSourceId;
  /**
   * True when nobody was asked and this is the fallback clear day.
   *
   * A zero-config box serves `synthetic: true` forever and that is a supported
   * state, not an error — which is why this is a field and not a 503.
   */
  synthetic: boolean;
  location: { lat: number; lng: number };
  /** `null` where the source did not report it. Never silently zeroed. */
  temperatureC: number | null;
  windKph: number | null;
  /** Degrees clockwise from true north, the direction the wind blows *from*. */
  windDirDeg: number | null;
  /** 0..1. */
  cloudCover: number;
  /** 0..1, an intensity rather than a rate — the renderer wants a dial. */
  precipitation: number;
  visibilityKm: number | null;
  condition: WeatherCondition;
  /** Attribution the consumer must display for this source, where one is owed. */
  attribution?: string[];
}

// ---- Markers --------------------------------------------------------------

/**
 * Where a coordinate came from, and the only thing standing between this repo
 * and an ODbL share-alike obligation.
 *
 * Publishing a snapshot of geocoded coordinates is Public Use of a Derivative
 * Database. If those coordinates came out of Nominatim, ODbL §4.3 and §4.4
 * attach to everything served here — no matter that the rows live in a private
 * database rather than in the repo. Containment was never the discharge.
 * CONTRACT.md §8.
 *
 * The type is a plain string on purpose. The entire point of the gate is that a
 * value nobody anticipated can arrive and must be *refused at serve time*, which
 * a closed union would quietly turn into a compile error somewhere upstream
 * instead.
 */
export type CoordinateProvenance = KnownProvenance | (string & {});

/**
 * The values the public gate accepts by default.
 *
 * - `us-census` — geocoding.geo.census.gov, a US Government work in the public
 *   domain. The sanctioned geocoder.
 * - `hand-placed` — typed by a human from a published address. Original.
 * - `synthetic` — invented for a demo. Owes nobody anything.
 *
 * Deliberately absent: `nominatim`, `osm`, `google`, `mapbox`, `here`. The first
 * two are share-alike; the rest restrict storing and redistributing what they
 * return, which is exactly what a public snapshot does.
 */
export type KnownProvenance = "us-census" | "hand-placed" | "synthetic";

/**
 * A marker as it crosses the wire: an engine `Marker` plus where its coordinate
 * came from.
 *
 * It extends `Marker` rather than restating it so that the adapter is a cast and
 * not a copy — the engine still knows nothing about provenance, and the day
 * `Marker` gains a field, this follows it.
 */
export interface WireMarker extends Marker {
  provenance: CoordinateProvenance;
}

export interface MarkersBody {
  markers: WireMarker[];
  /** ISO-8601 timestamp of the snapshot these rows came from. */
  generatedAt: string;
  /**
   * Rows the public-shape gate refused, by count and reason. Served rather than
   * only logged: a silent drop looks identical to an empty database.
   */
  refused: { reason: string; count: number }[];
  attribution?: string[];
}

// ---- Offices --------------------------------------------------------------

/**
 * One office pack, addressed and wrapped for delivery.
 *
 * `floor` is the authored `Office` exactly as `src/interiors/types.ts` defines
 * it — the pack a self-hoster writes by hand and the pack that arrives over HTTP
 * are the same bytes, which is the rule that keeps the format from forking.
 * Everything outside `floor` is deployment metadata the renderer never reads.
 */
export interface OfficeDoc {
  id: string;
  name: string;
  floor: Office;
  visibility: OfficeVisibility;
  /** ISO-8601. */
  updated?: string;
}

/**
 * Who is in one office, right now.
 *
 * Separate from `OfficeDoc` on purpose, and the separation is load-bearing
 * rather than tidy. A `Presence` carries a `seatId` and no coordinate precisely
 * so that the geometry can be published while the people are not; putting the
 * roster inside the pack would mean an operator who wants a public floorplan has
 * to strip the people out of it by hand, and the first time they forget, the
 * leak is permanent. Two documents means the safe thing is the default thing.
 *
 * It is also the honest shape for the data: a floorplan changes when somebody
 * moves a wall and a roster changes when somebody sits down, which is why this
 * one carries `observedAt` and the pack does not.
 */
export interface PresenceBody {
  officeId: string;
  people: Presence[];
  /** ISO-8601. Absent when the source did not say, which is not an error. */
  observedAt?: string;
}

/**
 * `private` means the endpoint answers 404 to anyone who may not see it — see
 * `ErrorBody`. `unlisted` is served to anybody with the id but never appears in
 * an index and is never publicly cached.
 */
export type OfficeVisibility = "public" | "unlisted" | "private";

// ---- Devices --------------------------------------------------------------

/**
 * What the hardware in one office is doing, right now.
 *
 * The runtime half of the split `src/devices/types.ts` opens with, and the
 * reason this body exists at all: a `DeviceDeclaration` is authored into the
 * office pack and is therefore public by construction, while a `DeviceState`
 * never appears in a file anybody can download. It arrives here, from a route
 * that can refuse it, and it is the same line `PresenceBody` draws between a
 * floorplan and the people standing on it.
 *
 * **Never publicly cached, in any configuration.** Two reasons and they are
 * different: the body took a credential to obtain, so a shared cache holding it
 * would hand one viewer's copy to the next; and the state is mutable by a
 * command, so a cached copy is a stale claim about a room somebody is standing
 * in. `routes/devices.ts` therefore never calls `publicCache`, and says so out
 * loud rather than merely omitting the call.
 *
 * An office with no authored devices is `devices: []` and a 200 — not a 404.
 * "This office does not exist" and "nobody has declared any hardware in it" are
 * different facts with different fixes, exactly as `presence/store.ts` argues
 * for a missing roster.
 */
export interface DevicesBody {
  officeId: string;
  devices: DeviceState[];
  /** Epoch milliseconds at which this snapshot was taken. */
  observedAt: number;
  source: DevicesSourceId;
  /**
   * Did anybody observe any of this?
   *
   * `true` for everything this build ships, because the only implemented source
   * is a state machine. It is the body-level statement of the same fact
   * `DeviceState.synthetic` makes per device, and it is carried separately so
   * that an empty array still says where it came from — an empty `devices` with
   * `synthetic: false` is a box with a real bridge and nothing plugged into it,
   * which is a different picture from a box that is making it all up.
   */
  synthetic: boolean;
  ttlSeconds: number;
  attribution?: string[];
}

/**
 * A command, going up.
 *
 * The only body in this file that travels from the browser to the server, and
 * deliberately the narrowest one: exactly one command, for exactly one device,
 * with no batching. A batch would need partial-failure semantics, and the first
 * write surface in this product is not the place to invent those.
 *
 * The command is validated **server-side against the resolved office plan** —
 * that the device id names an authored declaration, that the declaration's
 * asset really is hardware of the kind it claims, and that the op is one that
 * declaration declared. `normalizeDeviceCommand()` is the shared validator and
 * both ends run it, which is not redundancy: the browser runs it so a slider
 * cannot send nonsense, and the server runs it because a browser is not a
 * boundary. The same move `officeHasMediaBinding()` makes for screens.
 */
export interface DeviceCommandBody {
  command: DeviceCommand;
}

/**
 * What a command did, as the state that resulted from it.
 *
 * The new state rather than an `ok: true`, so the panel has something to draw
 * without a follow-up GET — and so the answer to "did that work" is the reading
 * itself rather than an acknowledgement that a request was received. A command
 * that was accepted and clamped (a gain of 40 dB on a device whose range stops
 * at 36) reports the clamped value here, and the slider snaps to what the
 * hardware actually did.
 */
export interface DeviceCommandResultBody {
  officeId: string;
  /** The device as it stands after the command was applied. */
  device: DeviceState;
  /** Epoch milliseconds. */
  observedAt: number;
}

// ---- Vessels --------------------------------------------------------------

/**
 * One ship position, with **every AIS sentinel already stripped**.
 *
 * This is the shape after the gate, not the shape off the wire from a receiver,
 * and the difference is the whole point of the type. AIS encodes "not available"
 * as in-band values that are perfectly valid numbers: speed over ground 102.3
 * knots, heading 511, course 360.0. None of the three is ever NULL, so a
 * null-check catches nothing, and `cog % 360` turns the course sentinel into due
 * north — which looks entirely correct until you notice the whole fleet is
 * facing the same way.
 *
 * So absence is expressed as absence here: `heading` and `course` are
 * `number | null`, `speed` is a real speed or zero, and anything that could not
 * be believed was dropped upstream where one hand-written SELECT can be read.
 *
 * **No name, no MMSI, no callsign, no destination.** See `Vessel` in
 * `engine/types.ts`: identity arrives with a licensed feed and a written licence
 * entry, or it does not arrive.
 */
export interface WireVessel {
  /** Opaque and stable for the life of a fix set. Never an MMSI. */
  id: string;
  /** AIS ship-type reduced to a display bucket. Structurally `VesselKind`. */
  kind: string;
  lat: number;
  lon: number;
  /** Metres per second over the ground. Never 102.3 knots. */
  speed: number;
  /** Course over ground, degrees true, or `null`. Never exactly 360. */
  course: number | null;
  /** True heading, degrees, or `null`. Never 511. */
  heading: number | null;
  /** `0` under way, `1` at anchor, `5` moored. Labels; never gates motion. */
  navStatus: number | null;
  /** Overall length and beam in metres, where the static message gave them. */
  length: number | null;
  beam: number | null;
  /** Seconds between the fix and `fetchedAt`. */
  ageSeconds: number;
}

/**
 * Everything afloat this deployment knows about, and when it last asked.
 *
 * `fetchedAt` is not optional for the same reason `FiresBody.fetchedAt` is not:
 * an empty harbour and a dead feed are the same picture.
 *
 * `interval` is stated rather than implied because it changes what a client is
 * allowed to do with the positions. Upstream listens for thirty seconds every
 * fifteen minutes, so a hull under way has moved about five kilometres between
 * two fixes. A client may dead-reckon **along the reported course at the
 * reported speed**; it may never spline between two fixes, because the chord
 * between them is not a path anything took.
 */
export interface VesselsBody {
  source: VesselsSourceId;
  /** ISO-8601, the last time a fetch **succeeded**. Epoch zero when never. */
  fetchedAt: string;
  vessels: WireVessel[];
  /** Seconds between upstream samples. 900 for the store behind this. */
  intervalSeconds: number;
  ttlSeconds: number;
  attribution?: string[];
}

// ---- Ports ----------------------------------------------------------------

/**
 * A month of boxes across one quay, and the freight rates that explain it.
 *
 * Static in this build and served with the month it describes, because that is
 * what the honest version of this looks like: `teu_obs` is monthly and the
 * newest row is July 2026. A figure with no month on it is a claim about now.
 */
export interface WirePortThroughput {
  /** UN/LOCODE. */
  portId: string;
  /** `YYYY-MM`. */
  asOf: string;
  loadedExport: number;
  emptyExport: number;
  loadedImport: number;
  emptyImport: number;
}

/** One published freight index, in dollars per forty-foot equivalent. */
export interface WireFreightRate {
  /** The index code: `FBX01`. */
  id: string;
  lane: string;
  usdPerFeu: number;
}

/**
 * Port throughput, as the card reads it.
 *
 * There is deliberately no observation timestamp on a rate. The store's
 * `observed_at` is our own read clock — Freightos publishes none — and a card
 * that renders it as "as of" is lying about precision.
 */
export interface PortsBody {
  source: FiresSourceId;
  fetchedAt: string;
  throughput: WirePortThroughput[];
  rates: WireFreightRate[];
  ttlSeconds: number;
  attribution?: string[];
}

// ---- The sky --------------------------------------------------------------

/**
 * The statewide reflectivity lattice.
 *
 * Structurally `RadarField` in `engine/types.ts` minus the two panel counters,
 * restated here for the reason the head of this file gives: the browser hands
 * the promoted field straight to the layer and the server must be able to build
 * one without importing three.js.
 *
 * **`null` in `dbz` is unknown, not dry.** A cell inside the coverage radius of
 * a radar whose RDA is inoperable is a hole, and a hole drawn as clear sky is a
 * claim nobody made. It is also why this is a nullable array and not a packed
 * buffer.
 *
 * `distance_km` from the upstream store is **not** in this type and must never
 * be requested. It is measured from a private home, and the protection is that
 * the projection never emits the column — not that a client politely avoids
 * selecting it. That is the whole reason `fires/cloud1.ts` has no database.
 */
export interface RadarBody {
  source: RadarSourceId;
  fetchedAt: string;
  /** `null` when nothing has been promoted — an honest empty sky. */
  field: {
    minLat: number;
    minLng: number;
    cellLat: number;
    cellLng: number;
    rows: number;
    cols: number;
    dbz: (number | null)[];
    observedAt: string;
    wetFraction: number;
    stations: number;
    stationsDown: number;
  } | null;
  ttlSeconds: number;
  attribution?: string[];
}

/** One county's ten-minute migration granule. Structurally `MigrationCounty`. */
export interface WireBirdCounty {
  id: string;
  name: string;
  lat: number;
  lon: number;
  areaKm2: number;
  aloft: number;
  /** Metres above ground. */
  altitude: number;
  /** Bearing they are heading toward, degrees true. */
  direction: number;
  /** Metres per second. */
  speed: number;
}

/**
 * Nocturnal migration, including — and especially — the empty case.
 *
 * `quiet` carries the reason and it is not optional. 168 of 297 granules are
 * daytime with 77 rows in them against 7,498 at night, so the empty sky is what
 * most visitors see and the sentence beside it is the layer.
 *
 * `statewide` comes from the **state row** and never from a sum over counties.
 * Summing all 58 gives 2,360,086 against an authoritative 393,290, because a
 * bird crossing four counties is counted in four of them.
 */
export interface BirdsBody {
  source: BirdsSourceId;
  fetchedAt: string;
  observedAt: string | null;
  counties: WireBirdCounty[];
  statewide: {
    crossed: number;
    peakAloft: number;
    peakAt: string;
    meanAltitude: number;
    heading: string;
  } | null;
  quiet: { reason: "daylight" | "off-season" | "no-data"; message: string } | null;
  ttlSeconds: number;
  attribution?: string[];
}
