/**
 * Which reflectivity may be drawn — and, on nine days in ten, that none may.
 *
 * The radar layer has the fire layer's failure mode waiting for it, and the
 * evidence is already in the repo: DATA-CATALOG.md:84 records a **54.5 dBZ cell
 * between Long Beach and Catalina on a clear day**. Tonight's own frame carries
 * 70.5 dBZ over the Catalina channel and thirteen cells at or above 40 dBZ
 * strung down the Pacific between 32.6 N and 35.6 N, none of them touching land.
 * A thunderstorm painted over the ocean on a blue afternoon spends the board's
 * credibility exactly the way twenty-two orange marks over Los Angeles would
 * have, and there is no polish that buys it back.
 *
 * So this file decides three things before any mesh builder sees a cell, and it
 * decides them where they can be tested without a GL context — the argument
 * `src/engine/fires.ts:16-23` makes, applied to the sky.
 *
 * ### 1. Anomalous propagation is dropped
 *
 * A superrefracting marine inversion bends the beam down into the sea surface
 * and the radar reports the swell as rain. It is the classic Southern
 * California artefact, the collector applies no filter to it, and it shows up
 * as *strong* returns — which is what makes it dangerous rather than merely
 * untidy.
 *
 * The rule is deliberately narrow, and it works on **connected blobs rather than
 * on single cells**, which is the one thing about it worth arguing over. An
 * eight-connected run of echo is refused only when *no cell in the whole run*
 * sits over land, its strongest cell reaches `AP_MIN_DBZ`, and it is no larger
 * than `AP_MAX_CELLS`. Rain coming ashore is one blob with cells on both sides
 * of the shoreline and survives entire; a 47.5 dBZ patch sitting alone off Big
 * Sur does not. Weak marine echo is left alone completely, because drizzle under
 * a stratus deck offshore is real and is the commonest thing the Pacific has to
 * say.
 *
 * **A per-cell version of this rule was written first and measured wrong.** On
 * tonight's frame it refused twelve cells, and three of them — 34.375,-121.125
 * at 55 dBZ among them — were the seaward fringe of a fifty-four-cell system
 * whose other end is over Santa Barbara. Deleting them punches holes in the
 * middle of a real storm, which is a different lie from the one this file
 * exists to prevent but is still a lie. Decomposed into blobs, tonight's frame
 * has twenty-three of them and exactly **one** is offshore-only: nine cells
 * peaking at 38.5 dBZ, which is under the threshold and is therefore kept, and
 * which is the right answer for marine drizzle west of Point Arguello.
 *
 * ### 2. "Not California" is not "the sea", and this nearly went wrong
 *
 * The obvious land test is "inside the pack's coastline polygon". Measured
 * against tonight's frame on the **extended** board, that test calls
 * `41.375,-117.125` at 53 dBZ sea clutter. It is in Nevada. The board now
 * reaches -114.0 and 42.05 N, so a third of it is land the California polygon
 * has never traced, and a rule written for the cropped board would have deleted
 * a Great Basin thunderstorm as an ocean artefact.
 *
 * Hence three classes, not two: `land` (inside the trace), `sea` (outside it
 * *and west of the coast at that latitude*), and `unmapped` — Nevada, Oregon,
 * Arizona, and the inside of San Francisco Bay. Only `sea` is ever refused.
 *
 * ### 3. A radar that is down leaves a hole, and a hole is not dry weather
 *
 * `null` in `RadarField.dbz` means *the radar that should be looking there is
 * off the air*. A cell inside a dead station's ring that no working station
 * covers is `null`; everything else is `RADAR_DRY_DBZ`. Those are different
 * claims and the type is nullable so they can stay different.
 *
 * It is claimed **narrowly**, and the first draft claimed it too widely. Marking
 * every cell outside the range of all sixteen known stations put a pale wash
 * over 201 of 1,596 cells — the far Pacific corner and the Nevada line — which
 * looked like weather in the frame and was a claim made from a partial list: the
 * table is a regional subset of a national composite that every WSR-88D in the
 * country feeds. The picture found that; the tests could not have.
 *
 * **"Not on-line" is not "down", and this is the trap in the operability
 * column.** Of sixteen stations right now, six read `RDA - Maintenance Action
 * Mandatory` — KVTX (Los Angeles), KNKX (San Diego), KDAX (Sacramento), KBBX,
 * KYUX and KLRX. Every one of them is transmitting; the flag is a work order.
 * Treating "anything but on-line" as down would blank the southern half of the
 * state as unknown on a night with a monsoon over it. Only `Inoperable`,
 * `Off-line` and `Shutdown` mean no beam, and `stationIsDown` says so in one
 * regular expression rather than in a policy spread over a service.
 *
 * ### Promotion is decided from coverage, not from cell count
 *
 * A cell count is a fact about how much of the lattice the board happens to
 * contain, and the board's bounds changed *this round*: a threshold tuned
 * against the cropped board would have silently changed meaning under it.
 * `wetFraction` — the share of California at or above the rain threshold, as
 * measured upstream over the whole state's pixels — is scale-free and survives
 * the next board too.
 *
 * Everything here is pure and imports nothing but types. It is imported by the
 * browser *and* by `server/src/radar/index.ts`, which is how one statement about
 * the data serves both ends of the wire.
 */

import type { RadarField } from "../engine/types.ts";
import type { RadarBody, RadarSourceId } from "./wire.ts";

// ---- The ladder -----------------------------------------------------------

/**
 * At and above this, an isolated marine cell is treated as anomalous
 * propagation rather than as weather.
 *
 * Forty dBZ is heavy rain — 12 mm an hour and up. Real convection that strong
 * over open water does happen, and when it does it is almost never detached
 * from everything else on the map, which is why the isolation test carries as
 * much weight here as the threshold does. Below forty nothing is refused at
 * all: marine stratus drizzle is genuine and is most of what the Pacific ever
 * shows.
 */
export const AP_MIN_DBZ = 40;

/**
 * The largest offshore-only blob this will refuse.
 *
 * **An untested judgement, and it is worth saying so out loud** — the same
 * posture `FIRE_TIER_MIN_ACRES` takes, for the same reason. Anomalous
 * propagation is a near-field artefact of one radar's clutter ring, so it comes
 * in patches; a winter frontal band arriving off the Pacific is enormous and is
 * in any case joined to the coastal echo long before its core reaches 40 dBZ.
 * Twenty-four cells is about 18,000 km², roughly one thunderstorm's rain shield,
 * and it is a cap on how much weather one bad night can delete rather than a
 * claim about clutter. The store has never yet held an offshore-only blob above
 * the threshold at all, so this boundary has never been exercised against the
 * case it exists for. If it bites, it is one constant — and `RadarFieldBuild`
 * carries every refused cell so the number is visible rather than silent.
 */
export const AP_MAX_CELLS = 24;

/**
 * The lowest reflectivity this build treats as precipitation, in dBZ.
 *
 * 20 dBZ is the conventional rain threshold and it is also where the upstream
 * collector cuts — every one of the 8,782 rows in the store is at or above it —
 * so it is a fact about the data rather than a judgement made here. It lives in
 * the gate rather than beside the colour ramp because it is a statement about
 * water, not about pixels; `assets/radarRamp.ts` and `engine/precip.ts` both
 * read it from here so that the threshold cannot disagree with itself.
 */
export const RADAR_RAIN_DBZ = 20;

/**
 * The share of California that must be raining before the sheet is drawn.
 *
 * A tenth of a percent is about 420 km² — one thunderstorm's rain shield.
 * Tonight's quietest frame measured 0.472% and the twenty-six stored frames run
 * 0.472% to 1.95%, so this floor is five times below anything real that has been
 * seen; what it refuses is a frame whose entire content was clutter the rule
 * above has already taken out. Below it the honest picture is an empty sky and a
 * sentence, not sixteen hundred texels at two percent opacity.
 */
export const RADAR_MIN_WET_FRACTION = 0.001;

/**
 * What a working radar reports for a cell it can see nothing in.
 *
 * Not `null`, which is reserved for "nobody is looking". The upstream collector
 * thresholds at 20 dBZ and writes no row below it, so the honest statement about
 * a covered, empty cell is "under twenty", and this is the number that stands
 * for it. Anything below `RADAR_RAIN_DBZ` draws at zero alpha, so the value
 * itself is never seen — only the difference between it and `null` is.
 */
export const RADAR_DRY_DBZ = 0;

/**
 * Unambiguous reach of one radar, in kilometres, by station type.
 *
 * 230 km is the WSR-88D's base reflectivity range; the TDWR is a terminal radar
 * and reaches about 90. Used only to answer "is anybody looking at this cell",
 * so an error here costs a hole in the wrong place rather than a wrong echo.
 */
export const RADAR_RANGE_KM: Readonly<Record<string, number>> = {
  "WSR-88D": 230,
  TDWR: 90,
};
const DEFAULT_RANGE_KM = 230;

/** The lattice pitch of the NEXRAD composite the collector reduces to. Degrees. */
export const RADAR_CELL_DEG = 0.25;

/**
 * The operability strings that mean the beam is off.
 *
 * Read the header before widening this. `Maintenance Action Mandatory` and
 * `Maintenance Action Required` are work orders on a radar that is transmitting,
 * and six of sixteen stations carry one right now.
 */
const RDA_DOWN = /\b(inoperable|off-?line|shut-?down|shutdown)\b/i;

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

/** A rectangle in degrees. Restated rather than imported; see `fires.ts`. */
export interface RadarBounds {
  minLat: number;
  maxLat: number;
  minLng: number;
  maxLng: number;
}

/**
 * A vertex of a coastline trace: `[lat, lng]`.
 *
 * Structurally `LatLng` from `engine/types.ts`, restated as a tuple rather than
 * imported so this module stays importable by a test with no renderer in it.
 * The tuple, and not an object, because a city pack's `landmasses` is passed
 * straight in and a shape mismatch here reads as "every cell is inland" — which
 * is silent, and which cost the first draft of this file an hour.
 */
export type RadarPoint = readonly [number, number];

/** One reduced cell as the collector writes it: a centre and a maximum. */
export interface RadarCell {
  lat: number;
  lon: number;
  dbz: number;
}

/**
 * One radar and whether it is producing.
 *
 * `operability` is carried verbatim rather than pre-judged upstream, for the
 * same reason `FireDetection.confidence` is: the branch belongs in one place
 * where the argument for it can be read, and MODIS/VIIRS taught this repo what
 * happens when it is not.
 */
export interface RadarStation {
  id: string;
  lat: number;
  lon: number;
  /** `WSR-88D`, `TDWR`, or whatever the upstream said. */
  type?: string | null;
  operability?: string | null;
}

/** Where a cell centre sits, as far as the coastline trace can tell. */
export type CellGround = "land" | "sea" | "unmapped";

/** What `buildRadarField` decided, including what it refused. */
export interface RadarFieldBuild {
  /** The lattice, or `null` when nothing was promoted. */
  field: RadarField | null;
  /** Cells the anomalous-propagation rule refused. Kept, never silently dropped. */
  suppressed: RadarCell[];
  /** Lattice cells no operating radar covers. */
  unknown: number;
  /** Lattice cells at or above `RADAR_RAIN_DBZ` after the gate. */
  wetCells: number;
  /** The strongest surviving cell, and where it was. */
  peak: { dbz: number; lat: number; lng: number } | null;
  /** Why nothing was promoted, when nothing was. `null` when the field is drawn. */
  quiet: "no-coverage" | "below-threshold" | "nothing-survived" | null;
}

/** Everything a board needs to draw rain, and to explain an empty sky. */
export interface RadarPromotion {
  source: RadarSourceId;
  /** 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;
  /** Handed straight to `PrecipLayer.setField`. `null` draws nothing at all. */
  field: RadarField | null;
  /** Statewide rain coverage, 0..1, as measured upstream over the whole state. */
  wetFraction: number;
  stations: number;
  stationsDown: number;
  /** One true, specific sentence. Never blank, and never "no data". */
  message: string;
}

/** The answer for a board with no feed behind it at all. */
export function emptyRadarPromotion(): RadarPromotion {
  return {
    source: "none",
    fetchedAt: new Date(0).toISOString(),
    ageMs: null,
    field: null,
    wetFraction: 0,
    stations: 0,
    stationsDown: 0,
    message:
      "No radar feed is configured, so this board draws no weather. " +
      "That is a fact about this box, not about the sky.",
  };
}

// ---- Land, sea, and the third thing ---------------------------------------

/**
 * Even-odd ray cast. A copy of `World.pointInPolygon`, because this module must
 * be importable by a test with no renderer in it and by the server, and the
 * engine's copy arrives with three.js attached.
 */
export function insidePolygon(lat: number, lng: number, poly: readonly RadarPoint[]): boolean {
  let inside = false;
  for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
    const a = poly[i];
    const b = poly[j];
    if (a === undefined || b === undefined) continue;
    const [latI, lngI] = a;
    const [latJ, lngJ] = b;
    if (latI > lat === latJ > lat) continue;
    const crossing = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
    if (lng < crossing) inside = !inside;
  }
  return inside;
}

/**
 * Where the Pacific shore sits at one latitude: the westernmost point at which
 * the coastline trace crosses that parallel.
 *
 * **Interpolated across the crossing edge, not taken from the nearest vertex.**
 * The first draft took the westernmost *vertex* within half a degree, and on a
 * coast that runs diagonally that is wrong by the whole diagonal: between 35.1 N
 * and 36.1 N the shore moves from -120.7 to -121.9, so a band rule put the sea
 * boundary a degree too far west and quietly declared the Big Sur clutter to be
 * inland. Crossings are exact and cost the same.
 *
 * `null` where the trace says nothing about that latitude at all, which is the
 * conservative answer: with no coast to be west of, nothing is called sea and
 * nothing is refused.
 */
export function coastLongitudeAt(
  lat: number,
  coast: readonly (readonly RadarPoint[])[],
): number | null {
  let west: number | null = null;
  for (const poly of coast) {
    for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
      const a = poly[i];
      const b = poly[j];
      if (a === undefined || b === undefined) continue;
      const [latI, lngI] = a;
      const [latJ, lngJ] = b;
      if (latI > lat === latJ > lat) continue;
      const crossing = ((lngJ - lngI) * (lat - latI)) / (latJ - latI) + lngI;
      if (west === null || crossing < west) west = crossing;
    }
  }
  return west;
}

/**
 * Land, open sea, or somewhere this board's coastline has no opinion about.
 *
 * The third class is the whole point — see the header. Nevada is not the
 * Pacific, and on the extended board there is a great deal of Nevada.
 */
export function classifyGround(
  lat: number,
  lng: number,
  coast: readonly (readonly RadarPoint[])[],
): CellGround {
  for (const poly of coast) {
    if (insidePolygon(lat, lng, poly)) return "land";
  }
  const west = coastLongitudeAt(lat, coast);
  if (west !== null && lng < west) return "sea";
  return "unmapped";
}

// ---- Stations -------------------------------------------------------------

/**
 * Whether this radar's beam is off.
 *
 * See the header: six of sixteen stations read `Maintenance Action Mandatory`
 * right now and every one of them is transmitting. An absent or empty string is
 * *unknown*, and unknown is not down — a station this box has never heard a
 * status for is assumed to be working, because the alternative is a board that
 * greys out whenever the status feed hiccups.
 */
export function stationIsDown(operability: string | null | undefined): boolean {
  if (typeof operability !== "string") return false;
  return RDA_DOWN.test(operability);
}

/** Unambiguous reach of one station, in kilometres. */
export function stationRangeKm(station: RadarStation): number {
  const type = typeof station.type === "string" ? station.type.trim() : "";
  return RADAR_RANGE_KM[type] ?? DEFAULT_RANGE_KM;
}

/**
 * Great-circle distance in kilometres. Haversine, on a spherical earth.
 *
 * Good to a few parts in a thousand at these ranges, against a 230 km radius
 * that is itself a round number, so the error is three orders of magnitude below
 * the thing being decided.
 */
export function distanceKm(
  aLat: number,
  aLng: number,
  bLat: number,
  bLng: number,
): number {
  const R = 6371.0088;
  const dLat = ((bLat - aLat) * Math.PI) / 180;
  const dLng = ((bLng - aLng) * Math.PI) / 180;
  const s =
    Math.sin(dLat / 2) ** 2 +
    Math.cos((aLat * Math.PI) / 180) *
      Math.cos((bLat * Math.PI) / 180) *
      Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}

// ---- Building the lattice -------------------------------------------------

export interface RadarFieldInput {
  cells: readonly RadarCell[];
  stations: readonly RadarStation[];
  bounds: RadarBounds;
  /** The board's own coastline traces. `city.landmasses`, passed rather than reached for. */
  coast: readonly (readonly RadarPoint[])[];
  /** ISO-8601 of the volume scan. */
  observedAt: string;
  /** Statewide rain coverage 0..1, measured upstream over the whole state's pixels. */
  wetFraction: number;
  cellDeg?: number;
}

/**
 * Turn a bag of cells into the lattice the layer draws, or into nothing.
 *
 * Pure and total. Called on **both** sides of the wire — by
 * `server/src/radar/index.ts` when it builds a `RadarBody`, and by a test — so
 * that the statement about the data is made once.
 */
export function buildRadarField(input: RadarFieldInput): RadarFieldBuild {
  const cellDeg = input.cellDeg ?? RADAR_CELL_DEG;
  const bounds = input.bounds;
  const wetFraction = finite(input.wetFraction) ?? 0;

  const stations = input.stations.filter(
    (s) => finite(s.lat) !== null && finite(s.lon) !== null,
  );
  const down = stations.filter((s) => stationIsDown(s.operability));
  const up = stations.filter((s) => !stationIsDown(s.operability));

  // The lattice is the product's own grid clipped to the board, so a texel is a
  // cell and never a resampling of one. Centres sit on the half-cell.
  const minLat = Math.ceil((bounds.minLat - cellDeg / 2) / cellDeg) * cellDeg + cellDeg / 2;
  const minLng = Math.ceil((bounds.minLng - cellDeg / 2) / cellDeg) * cellDeg + cellDeg / 2;
  const rows = Math.floor((bounds.maxLat - minLat) / cellDeg) + 1;
  const cols = Math.floor((bounds.maxLng - minLng) / cellDeg) + 1;
  if (rows <= 0 || cols <= 0) {
    return { field: null, suppressed: [], unknown: 0, wetCells: 0, peak: null, quiet: "no-coverage" };
  }

  const index = (row: number, col: number) => row * cols + col;
  const rowOf = (lat: number) => Math.round((lat - minLat) / cellDeg);
  const colOf = (lng: number) => Math.round((lng - minLng) / cellDeg);

  // Ground class per lattice cell, computed once. The AP rule needs a cell's
  // neighbours' classes, and re-testing a 135-point polygon eight times per cell
  // would be eight times the work for the same answer.
  const ground: CellGround[] = new Array<CellGround>(rows * cols);
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      ground[index(r, c)] = classifyGround(minLat + r * cellDeg, minLng + c * cellDeg, input.coast);
    }
  }

  // Echo, keyed by lattice slot. A cell arriving twice keeps the stronger
  // return, which is what the collector's own "max within the cell" means.
  const echo = new Map<number, RadarCell>();
  for (const cell of input.cells) {
    const lat = finite(cell?.lat);
    const lon = finite(cell?.lon);
    const dbz = finite(cell?.dbz);
    if (lat === null || lon === null || dbz === null) continue;
    const r = rowOf(lat);
    const c = colOf(lon);
    if (r < 0 || r >= rows || c < 0 || c >= cols) continue;
    const slot = index(r, c);
    const held = echo.get(slot);
    if (held === undefined || dbz > held.dbz) echo.set(slot, { lat, lon, dbz });
  }

  // Anomalous propagation. Refused by CONNECTED BLOB, not cell by cell, and the
  // difference is the whole rule — see `AP_MAX_CELLS` and the header.
  const suppressed: RadarCell[] = [];
  {
    const slots = [...echo.keys()];
    const visited = new Set<number>();
    for (const start of slots) {
      if (visited.has(start)) continue;
      const blob: number[] = [];
      const stack = [start];
      visited.add(start);
      let touchesLand = false;
      let peakDbz = -Infinity;
      while (stack.length > 0) {
        const slot = stack.pop() as number;
        blob.push(slot);
        if (ground[slot] !== "sea") touchesLand = true;
        const cell = echo.get(slot);
        if (cell !== undefined && cell.dbz > peakDbz) peakDbz = cell.dbz;
        const r = Math.floor(slot / cols);
        const c = slot % cols;
        for (let dr = -1; dr <= 1; dr++) {
          for (let dc = -1; dc <= 1; dc++) {
            if (dr === 0 && dc === 0) continue;
            const nr = r + dr;
            const nc = c + dc;
            if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
            const neighbour = index(nr, nc);
            if (!echo.has(neighbour) || visited.has(neighbour)) continue;
            visited.add(neighbour);
            stack.push(neighbour);
          }
        }
      }
      if (touchesLand) continue;
      if (peakDbz < AP_MIN_DBZ) continue;
      if (blob.length > AP_MAX_CELLS) continue;
      for (const slot of blob) {
        const cell = echo.get(slot);
        if (cell !== undefined) suppressed.push(cell);
        echo.delete(slot);
      }
    }
  }

  // Fill. `null` is "no operating radar covers this", which is a different
  // statement from "a radar looked and saw nothing" and has its own value.
  const dbz: (number | null)[] = new Array<number | null>(rows * cols);
  let unknown = 0;
  let wetCells = 0;
  let peak: { dbz: number; lat: number; lng: number } | null = null;

  for (let r = 0; r < rows; r++) {
    const lat = minLat + r * cellDeg;
    for (let c = 0; c < cols; c++) {
      const slot = index(r, c);
      const lng = minLng + c * cellDeg;
      const hit = echo.get(slot);
      if (hit !== undefined) {
        dbz[slot] = hit.dbz;
        if (hit.dbz >= RADAR_RAIN_DBZ) wetCells += 1;
        if (peak === null || hit.dbz > peak.dbz) peak = { dbz: hit.dbz, lat, lng };
        continue;
      }
      // `null` is claimed narrowly, and the reason is worth reading. The
      // station table this box is handed is a REGIONAL subset of the national
      // composite's contributors — sixteen radars around California, against a
      // product every WSR-88D in the country feeds. So "outside the range of
      // all sixteen" is not "unobserved", and painting the far Pacific corner
      // and the Nevada line as unknown on that basis would be a claim made from
      // a partial list. What this box *can* say is that a radar it has a status
      // for is off the air and nothing it knows of covers the hole.
      const covered = up.some((s) => distanceKm(lat, lng, s.lat, s.lon) <= stationRangeKm(s));
      const blind = !covered && down.some((s) => distanceKm(lat, lng, s.lat, s.lon) <= stationRangeKm(s));
      if (!blind) {
        dbz[slot] = RADAR_DRY_DBZ;
        continue;
      }
      dbz[slot] = null;
      unknown += 1;
    }
  }

  const quiet: RadarFieldBuild["quiet"] =
    wetCells === 0
      ? "nothing-survived"
      : wetFraction < RADAR_MIN_WET_FRACTION
        ? "below-threshold"
        : null;

  if (quiet !== null) {
    return { field: null, suppressed, unknown, wetCells, peak, quiet };
  }

  return {
    field: {
      minLat,
      minLng,
      cellLat: cellDeg,
      cellLng: cellDeg,
      rows,
      cols,
      dbz,
      observedAt: input.observedAt,
      wetFraction,
      stations: stations.length,
      stationsDown: down.length,
    },
    suppressed,
    unknown,
    wetCells,
    peak,
    quiet: null,
  };
}

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

/**
 * Read one `RadarBody` and decide what this board draws.
 *
 * Pure and total: a malformed body, a body from a server one version behind, or
 * `null` all produce an honest empty sky rather than an exception. The consumer
 * is a render loop.
 */
export function promoteRadar(
  body: RadarBody | null | undefined,
  nowMs: number = Date.now(),
): RadarPromotion {
  const empty = emptyRadarPromotion();
  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 source: RadarSourceId = body.source === "cloud1" ? "cloud1" : "none";

  const field = readField(body.field);
  const wetFraction = field?.wetFraction ?? 0;
  const stations = field?.stations ?? 0;
  const stationsDown = field?.stationsDown ?? 0;

  if (field === null) {
    return {
      source,
      fetchedAt,
      ageMs,
      field: null,
      wetFraction,
      stations,
      stationsDown,
      message:
        ageMs === null
          ? empty.message
          : `Nothing is falling on this board. ${stationLine(stations, stationsDown)} ` +
            `Last scan ${age(ageMs)}.`,
    };
  }

  let wet = 0;
  let unknown = 0;
  let peak = -Infinity;
  for (const value of field.dbz) {
    if (value === null) {
      unknown += 1;
      continue;
    }
    if (value >= RADAR_RAIN_DBZ) wet += 1;
    if (value > peak) peak = value;
  }

  return {
    source,
    fetchedAt,
    ageMs,
    field,
    wetFraction,
    stations,
    stationsDown,
    message:
      `Rain over ${(wetFraction * 100).toFixed(2)}% of California — ` +
      `${wet} cell${wet === 1 ? "" : "s"} of ${field.rows * field.cols}, ` +
      `strongest ${peak.toFixed(1)} dBZ. ` +
      `${stationLine(stations, stationsDown)}` +
      (unknown > 0 ? ` ${unknown} cells have no radar over them. ` : " ") +
      `Scan ${field.observedAt}${ageMs === null ? "" : `, ${age(ageMs)}`}.`,
  };
}

// ---- Small helpers --------------------------------------------------------

function stationLine(stations: number, down: number): string {
  if (stations === 0) return "No radar has reported.";
  const up = Math.max(0, stations - down);
  return down === 0
    ? `All ${stations} radars reporting.`
    : `${up} of ${stations} radars reporting, ${down} off the air.`;
}

function age(ms: number): string {
  const minutes = Math.round(ms / 60_000);
  if (minutes < 1) return "under a minute old";
  if (minutes < 120) return `${minutes} minute${minutes === 1 ? "" : "s"} old`;
  const hours = Math.round(minutes / 60);
  return `${hours} hour${hours === 1 ? "" : "s"} old`;
}

/**
 * Read the wire's lattice, or refuse it.
 *
 * A `dbz` array whose length disagrees with `rows * cols` is not a field with a
 * problem, it is a field that would be drawn *rotated* — every row after the
 * first offset by the difference. Refused whole rather than padded.
 */
function readField(raw: RadarBody["field"] | undefined): RadarField | null {
  if (raw === null || raw === undefined || typeof raw !== "object") return null;
  const rows = finite(raw.rows);
  const cols = finite(raw.cols);
  const minLat = finite(raw.minLat);
  const minLng = finite(raw.minLng);
  const cellLat = finite(raw.cellLat);
  const cellLng = finite(raw.cellLng);
  if (rows === null || cols === null || rows <= 0 || cols <= 0) return null;
  if (minLat === null || minLng === null || cellLat === null || cellLng === null) return null;
  if (cellLat <= 0 || cellLng <= 0) return null;
  if (!Array.isArray(raw.dbz) || raw.dbz.length !== rows * cols) return null;

  const dbz: (number | null)[] = new Array<number | null>(rows * cols);
  for (let i = 0; i < dbz.length; i++) {
    dbz[i] = finite(raw.dbz[i]);
  }

  return {
    minLat,
    minLng,
    cellLat,
    cellLng,
    rows,
    cols,
    dbz,
    observedAt: typeof raw.observedAt === "string" ? raw.observedAt : "",
    wetFraction: clamp01(finite(raw.wetFraction) ?? 0),
    stations: Math.max(0, Math.round(finite(raw.stations) ?? 0)),
    stationsDown: Math.max(0, Math.round(finite(raw.stationsDown) ?? 0)),
  };
}

function finite(value: unknown): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

function clamp01(value: number): number {
  return Math.min(1, Math.max(0, value));
}
