/**
 * Which fires may be drawn — and, far more often, which may not.
 *
 * This is the most important module in the fire feed and it is the one with the
 * least code in it. Everything else moves bytes; this decides whether a board
 * that looks calm is telling the truth.
 *
 * ### The quiet day is the dangerous one
 *
 * On the day this was written the SoCal board's bounds contained **twenty-two
 * live incident rows**. Every single one had `acres: null`. Fifteen were
 * nameless LA County dispatch numbers — `LAC-297933`, `LAC-298861` — records
 * that open when an engine rolls and close when it turns round. Nothing was
 * burning in Los Angeles. Drawn without a gate, that is twenty-two orange marks
 * over a city on a day nothing happened, in a frame whose palette was checked by
 * eye and contains no other warm colour at all: pale sand basin, white-blue
 * buildings, green ridges, blue ocean. One orange glyph is the most salient
 * object on that board. Twenty-two of them, wrong, spends the board's
 * credibility permanently, and no later polish buys it back.
 *
 * The same gate, on the same data, on the same day, returns **exactly five**
 * fires on the California board: Timber (7,591 ac, 29% contained), Alpaugh
 * (3,600 ac, 30%), Carrizo (268 ac), Amber (10 ac) and GREEN (10 ac). Both
 * answers are correct. That is the whole argument for this file: an honest empty
 * board and a truthful full one have to come out of one rule.
 *
 * ### Four independent reasons a row is not a fire
 *
 * They are independent, which is why they are four conditions and not one score:
 *
 * 1. **No acreage, or under ten.** A dispatch record with `acres: null` is not
 *    an event, it is a radio call. Ten is a judgement and is stated as one — see
 *    `FIRE_TIER_MIN_ACRES`.
 * 2. **Eighty percent contained or more.** A contained fire is news that has
 *    finished happening.
 * 3. **`type === "RX"` — a prescribed burn.** Deliberate, scheduled, frequently
 *    adjacent to real fire ground, and completely indistinguishable from a
 *    wildfire under a distance filter. Drawing one as a wildfire raises an alarm
 *    about a planned event.
 * 4. **A stale `lastSeen`.** The upstream 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 keeps its old `lastSeen`
 *    forever: twenty-one of ninety-five rows were ghosts, including a second
 *    Timber Fire 850 m from the live one with different acreage. A reader that
 *    skips this both double-counts and under-reports the same fire at once.
 *
 * A fifth filter — a name that reads as a drill — is applied for the same reason
 * the first one is: an exercise is not an event.
 *
 * ### Tiers, not a score
 *
 * Tier 0 is not drawn. Tier 1 is a mark and nothing else. Tier 2 (a hundred
 * acres and up) additionally earns a plume, because a plume is the layer most
 * able to overstate: at board altitude the eye has no scale reference, and a
 * 268-acre fire under a forty-kilometre smoke column is a lie told in a medium
 * that reads as truthful.
 *
 * ### Detections are evidence, and are separated here rather than downstream
 *
 * A satellite hot pixel is a pixel that was hot on one overpass. 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, on both days
 * the store holds, and it will be there tomorrow. `persistent` pixels are split
 * out of the drawn set here — counted, never discarded silently — so that no
 * renderer has to remember to do it.
 *
 * ### Why this file is here and not in `engine/`
 *
 * It is pure, it imports nothing but types, and it touches neither three.js nor
 * the DOM, so the renderer and the test suite can both have it. The gate is a
 * statement about data, and a statement about data that lives inside a mesh
 * builder is a statement nobody can test without a WebGL context.
 */

import type { FireDetection, FireIncident, FiresBody, FiresSourceId } from "./wire.ts";

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

/**
 * The acreage below which a row is not drawn at all.
 *
 * **An untested judgement, and it is worth saying so out loud.** The upstream
 * store has never held a SoCal fire between 1 and 100 acres, so this boundary
 * has never been exercised against the case it exists for: a genuinely dangerous
 * five-acre fire in Griffith Park would be invisible under it. It is kept
 * because the agencies' own LA County records make the false-positive rate below
 * ten acres overwhelming — fifteen nameless dispatch numbers on an ordinary
 * Friday — and because the cost of the two errors is not symmetric. A board that
 * cries wolf is never believed again; a board that is one tier slow on a small
 * fire is a board that catches up in ten minutes. If it bites, it is one
 * constant.
 */
export const FIRE_TIER_MIN_ACRES = 10;

/** At and above this, a fire has earned a plume as well as a mark. */
export const FIRE_TIER_PLUME_ACRES = 100;

/** Containment at or above which a fire is no longer news. Percent. */
export const FIRE_MAX_CONTAINED_PCT = 80;

/**
 * The most fires one board will draw.
 *
 * Not a data claim — the state has produced five — but a bound on what an
 * upstream that has gone strange can do to a fixed instance buffer downstream.
 * The set is sorted by acreage first, so the cap drops the smallest.
 */
export const FIRE_DRAW_LIMIT = 64;

/** The most off-board fires worth naming in a caption. */
export const FIRE_OFF_BOARD_LIMIT = 3;

/**
 * Names that describe an exercise rather than an event.
 *
 * Anchored on word boundaries, because "Drill Creek Fire" and "TEST FIRE" are
 * different things and only one of them is furniture. Matched case-insensitively
 * against the trimmed name.
 */
const EXERCISE_NAME = /\b(training|exercise|drill|simulation|test\s*fire|do\s*not\s*use)\b/i;

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

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

/** What tier 1 and tier 2 mean, as a number a renderer can switch on. */
export type FireTier = 1 | 2;

/**
 * One fire that survived the gate, flattened for drawing.
 *
 * `acres` is a `number` here where `FireIncident.acres` is `number | null`,
 * which is the whole point of the type existing: past this gate, acreage is a
 * fact. A renderer sizing a glyph never has to ask.
 */
export interface DrawnFire {
  id: string;
  /** `null` where the agency published none. Never defaulted to the id. */
  name: string | null;
  lat: number;
  lon: number;
  county: string | null;
  source: string;
  url: string | null;
  acres: number;
  /** `null` means "the agency has not said", which is not zero. */
  pctContained: number | null;
  tier: FireTier;
  /** ISO-8601 of the observation `acres` came from. */
  observedAt: string | null;
}

/**
 * Everything a board needs to draw fire, and everything it needs to explain an
 * empty one.
 *
 * `suppressed`, `offBoard` and `fetchedAt` are the three fields that make the
 * empty case honest rather than merely blank. "Nothing is burning here" is a
 * finding; "I have not heard from the feed since Tuesday" is a fault; and
 * "ninety-three thousand acres are burning a hundred and ninety kilometres north
 * of this frame's edge" is neither. A board that cannot say which one it is in
 * is a board that is guessed at.
 */
export interface FirePromotion {
  source: FiresSourceId;
  /** 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;
  /** The de-duplication watermark every drawn row matched. */
  latestSeen: string | null;
  /** Fires inside `bounds`, worst first. At most `FIRE_DRAW_LIMIT`. */
  drawn: DrawnFire[];
  /** Fires that passed the gate but fall outside `bounds`, largest first. */
  offBoard: DrawnFire[];
  /** Live rows inside `bounds` the gate refused. The number behind a calm board. */
  suppressed: number;
  /** Hot pixels inside `bounds` that are not known furniture. */
  detections: FireDetection[];
  /** Hot pixels inside `bounds` that are. Counted so a caption can say so. */
  persistentDetections: number;
  /** How many hours of overpasses `detections` covers. */
  detectionWindowHours: number;
}

/** The answer for a board with no feed behind it at all. */
export function emptyPromotion(): FirePromotion {
  return {
    source: "none",
    fetchedAt: new Date(0).toISOString(),
    ageMs: null,
    latestSeen: null,
    drawn: [],
    offBoard: [],
    suppressed: 0,
    detections: [],
    persistentDetections: 0,
    detectionWindowHours: 0,
  };
}

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

/**
 * Apply the ladder 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. That is the
 * same posture every adapter in this repo takes toward a shape it did not build,
 * and it matters more here than elsewhere — the consumer is a render loop.
 *
 * `nowMs` is injected so a test can assert on `ageMs` without owning the clock.
 */
export function promote(
  body: FiresBody | null | undefined,
  bounds: FireBounds,
  nowMs: number = Date.now(),
): FirePromotion {
  const empty = emptyPromotion();
  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 incidents = Array.isArray(body.incidents) ? body.incidents : [];
  // The watermark comes from the body where the server stated one, and is
  // otherwise recomputed here from the rows in hand. Recomputing is the
  // fallback and not the primary: the server sees the whole table and this sees
  // only what it was sent, so a body clipped by anything at all would move its
  // own watermark and re-admit exactly the ghosts this exists to drop.
  const latestSeen =
    typeof body.latestSeen === "string" && body.latestSeen !== ""
      ? body.latestSeen
      : latestOf(incidents);

  const drawn: DrawnFire[] = [];
  const offBoard: DrawnFire[] = [];
  let suppressed = 0;

  for (const incident of incidents) {
    if (incident === null || typeof incident !== "object") continue;
    const lat = finite(incident.lat);
    const lon = finite(incident.lon);
    if (lat === null || lon === null) continue;

    const inside = within(lat, lon, bounds);
    const fire = admit(incident, lat, lon, latestSeen);
    if (fire === null) {
      // Counted only for the board being drawn. A refusal in Humboldt is not
      // something a viewer looking at Los Angeles is owed a number for.
      if (inside) suppressed += 1;
      continue;
    }
    (inside ? drawn : offBoard).push(fire);
  }

  drawn.sort(bySeverity);
  offBoard.sort(bySeverity);

  const detections: FireDetection[] = [];
  let persistentDetections = 0;
  for (const detection of Array.isArray(body.detections) ? body.detections : []) {
    if (detection === null || typeof detection !== "object") continue;
    const lat = finite(detection.lat);
    const lon = finite(detection.lon);
    if (lat === null || lon === null) continue;
    if (!within(lat, lon, bounds)) continue;
    // Split, not filtered. The industrial flare on the I-15 corridor is a real
    // measurement of a real hot object; it is simply not news, and a layer that
    // silently dropped it would have no way to say how much of the board's
    // thermal activity is furniture.
    if (detection.persistent === true) {
      persistentDetections += 1;
      continue;
    }
    detections.push(detection);
  }

  return {
    source: isSourceId(body.source) ? body.source : "none",
    fetchedAt,
    ageMs,
    latestSeen,
    drawn: drawn.slice(0, FIRE_DRAW_LIMIT),
    offBoard: offBoard.slice(0, FIRE_OFF_BOARD_LIMIT),
    suppressed,
    detections,
    persistentDetections,
    detectionWindowHours: finite(body.detectionWindowHours) ?? 0,
  };
}

/**
 * The four conditions plus the exercise name, in one place, returning the
 * flattened row or `null`.
 *
 * Ordered cheapest-first only by accident; they are independent and the order
 * carries no meaning, which is deliberate. A scoring function would have an
 * order and would therefore have a tuning knob, and a tuning knob is how "does
 * this get drawn" stops being answerable in a sentence.
 */
function admit(
  incident: FireIncident,
  lat: number,
  lon: number,
  latestSeen: string | null,
): DrawnFire | null {
  // 4. A row the collector has stopped seeing but never deleted.
  if (latestSeen !== null && incident.lastSeen !== latestSeen) return null;

  // 3. A prescribed burn is not a wildfire.
  const type = typeof incident.type === "string" ? incident.type.trim().toUpperCase() : "";
  if (type === "RX") return null;

  // 1. No acreage is not "small". It is "nobody has said this is a fire".
  const acres = finite(incident.acres);
  if (acres === null || acres < FIRE_TIER_MIN_ACRES) return null;

  // 2. Contained is finished.
  const pctContained = finite(incident.pctContained);
  if ((pctContained ?? 0) >= FIRE_MAX_CONTAINED_PCT) return null;

  const name = typeof incident.name === "string" ? incident.name.trim() : "";
  if (name !== "" && EXERCISE_NAME.test(name)) return null;

  return {
    id: typeof incident.id === "string" ? incident.id : "",
    name: name === "" ? null : name,
    lat,
    lon,
    county: typeof incident.county === "string" && incident.county !== "" ? incident.county : null,
    source: typeof incident.source === "string" ? incident.source : "",
    url: typeof incident.url === "string" && incident.url !== "" ? incident.url : null,
    acres,
    pctContained,
    tier: acres >= FIRE_TIER_PLUME_ACRES ? 2 : 1,
    observedAt: typeof incident.observedAt === "string" ? incident.observedAt : null,
  };
}

// ---- Detections -----------------------------------------------------------

/**
 * One hot pixel's confidence as a fraction, or `null` where it cannot be read.
 *
 * **The branch that has to exist.** MODIS publishes an integer 0–100 in this
 * column and VIIRS publishes `low`/`nominal`/`high` in the same one, so a
 * consumer that maps the raw value to an opacity is wrong for one of the two on
 * every frame — and wrong in the direction that matters, because `"nominal"`
 * parses to `NaN` and `NaN` reaches a shader as a hole.
 *
 * The VIIRS steps are the product's own three-way split and are placed at the
 * middles of the thirds rather than at 0/0.5/1, because `low` is not "no
 * confidence" — it is the bottom band of a detection that was still published.
 */
export function detectionConfidence(detection: FireDetection): number | null {
  const raw = detection.confidence;
  if (typeof raw !== "string" || raw.trim() === "") return null;
  const value = raw.trim().toLowerCase();

  if (typeof detection.sat === "string" && detection.sat.toUpperCase().startsWith("VIIRS")) {
    if (value === "low" || value === "l") return 1 / 6;
    if (value === "nominal" || value === "n") return 0.5;
    if (value === "high" || value === "h") return 5 / 6;
    return null;
  }

  const numeric = Number(value);
  if (!Number.isFinite(numeric)) return null;
  return Math.min(1, Math.max(0, numeric / 100));
}

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

function within(lat: number, lon: number, bounds: FireBounds): boolean {
  return (
    lat >= bounds.minLat && lat <= bounds.maxLat && lon >= bounds.minLng && lon <= bounds.maxLng
  );
}

/** Worst first: acreage descending, then least-contained, then id for stability. */
function bySeverity(a: DrawnFire, b: DrawnFire): number {
  if (b.acres !== a.acres) return b.acres - a.acres;
  const ca = a.pctContained ?? 0;
  const cb = b.pctContained ?? 0;
  if (ca !== cb) return ca - cb;
  return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}

function latestOf(incidents: readonly FireIncident[]): string | null {
  let latest: string | null = null;
  for (const incident of incidents) {
    const seen = incident?.lastSeen;
    if (typeof seen !== "string" || seen === "") continue;
    if (latest === null || seen > latest) latest = seen;
  }
  return latest;
}

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

function isSourceId(value: unknown): value is FiresSourceId {
  return value === "none" || value === "cloud1";
}
