/**
 * Which birds may be drawn, what the sky says when none may, and the one number
 * that is wrong by six times if you compute it the obvious way.
 *
 * ### The empty state ships first, because it is what most people see
 *
 * BirdCast measures nocturnal migration and measures it **only after dark**. Of
 * the 297 granules in the upstream store, 176 are daytime and hold 104 rows
 * between them, against 7,719 at night. The layer is therefore absent about
 * fourteen hours in every twenty-four *by construction*, before any question of
 * season, and every default frame `scripts/look.mjs` takes is a daylight frame.
 * A layer that only looks right during the small fraction of frames where it has
 * something to say is a layer that looks broken the rest of the time.
 *
 * So `quiet` is not optional and is never blank. It carries a reason and a
 * sentence, and the sentence is the layer:
 *
 *     Nothing is aloft. BirdCast measures migration only after dark. Last night
 *     393,290 birds crossed California heading south-east, peaking at 1,501,193
 *     aloft at 23:20 PDT, at a mean 726 metres.
 *
 * ### The six-times error
 *
 * `SELECT SUM(birds_crossed)` over the 58 county rows for the night of
 * 2026-08-21 gives **2,360,086**. The authoritative figure, on the `US-CA` state
 * row for the same night, is **393,290**. Both are correct: a bird that crosses
 * four counties is counted in four of them, so the county rows are a spatial
 * distribution and the state row is the crossing count. Nothing in this file
 * ever sums counties into a headline, and `statewideHeadline` reads the state
 * row or returns `null`.
 *
 * ### The state row has no coordinate, and it is enormous
 *
 * `counties` in the store is 59 rows, not 58: fifty-eight counties plus `US-CA`,
 * `kind='state'`, with **NULL lat and lon** and, in tonight's granule, 793,141
 * birds aloft against the largest county's 82,549. A per-county path that does
 * not take it out places a 793,141-bird blob at 0,0 in the Gulf of Guinea — or,
 * worse, at whatever a `?? 0` turns it into. It is excluded here on three
 * independent grounds, because one of them is the one that will be missed.
 *
 * Pure: this imports nothing but types, touches no DOM and constructs no mesh.
 * 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 { MigrationCounty, MigrationField, MigrationNight, MigrationQuiet } from "../engine/types.ts";
import type { BirdsBody, BirdsSourceId, WireBirdCounty } from "./wire.ts";

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

/**
 * The id of the row that is a state and not a county.
 *
 * Named rather than inlined because it is checked in more than one place and
 * because the *reason* it is checked has to travel with it. See the header.
 */
export const BIRDS_STATE_ROW_ID = "US-CA";

/**
 * Solar elevation above which nothing may be drawn, in degrees.
 *
 * Civil twilight. BirdCast's product is nocturnal, so a daytime row is not a
 * small measurement — it is a measurement of something the instrument does not
 * measure, and 104 of them exist in the store. This is what refuses them, and it
 * refuses them whatever the feed says, because being wrong about the sun is not
 * a thing this build is prepared to be.
 */
export const BIRDS_MAX_SOLAR_ELEVATION_DEG = -6;

/**
 * Birds aloft below which a county is not drawn at all.
 *
 * A judgement, and a small one: BirdCast reports continuous fields, so a county
 * with four birds over it is a rounding artefact of a forecast raster rather
 * than four birds. Set low enough that a genuinely quiet county still shows.
 */
export const BIRDS_MIN_ALOFT = 25;

/** The most counties one board will draw. Fifty-eight exist; this bounds a bad day. */
export const BIRDS_COUNTY_LIMIT = 64;

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

/** Everything a board needs to draw migration, and to explain an empty sky. */
export interface BirdsPromotion {
  source: BirdsSourceId;
  /** 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 `MigrationLayer.setField`. `null` only when nothing answered. */
  field: MigrationField | null;
  /** Rows the gate refused — the state row, and any county under the floor. */
  suppressed: number;
  /** The panel's sentence. Never blank, and never "no data". */
  message: string;
}

export interface BirdsPromoteOptions {
  nowMs?: number;
  /**
   * The sun's elevation over California, degrees. The same number `Atmosphere`
   * is working from, injected rather than recomputed — a second opinion about
   * where the sun is, taken from a clock the scrubber does not own, is how a
   * night board and a night layer end up disagreeing.
   */
  solarElevationDeg?: number;
}

/** The answer for a board with no feed behind it at all. */
export function emptyBirdsPromotion(): BirdsPromotion {
  return {
    source: "none",
    fetchedAt: new Date(0).toISOString(),
    ageMs: null,
    field: null,
    suppressed: 0,
    message:
      "No migration feed is configured, so nothing is drawn over this board. " +
      "That is a fact about this box, not about the sky.",
  };
}

// ---- Exclusion ------------------------------------------------------------

/**
 * Is this row a county, or is it the state pretending to be one?
 *
 * Three independent tests, and they are three rather than one because the one
 * that gets missed is never the one you thought of. The `US-CA` row fails all
 * three today; a future `US-CA-REGION-N` row with real coordinates would fail
 * only the first, and a county whose Census join silently produced nulls would
 * fail only the second.
 */
export function isCountyRow(row: WireBirdCounty | null | undefined): boolean {
  if (row === null || row === undefined || typeof row !== "object") return false;
  if (typeof row.id === "string" && row.id.trim().toUpperCase() === BIRDS_STATE_ROW_ID) return false;
  if (finite(row.lat) === null || finite(row.lon) === null) return false;
  if ((finite(row.areaKm2) ?? 0) <= 0) return false;
  return true;
}

/**
 * Every county row, in the order they arrived, with the state row and anything
 * unplaceable taken out.
 *
 * Exported so that `birdsGate.test.ts` can assert the exclusion directly rather
 * than inferring it from a rendered field, and so `server/src/birds/index.ts`
 * can apply the same rule on the way out.
 */
export function countyRows(rows: readonly WireBirdCounty[]): WireBirdCounty[] {
  return rows.filter((row) => isCountyRow(row));
}

// ---- The headline ---------------------------------------------------------

/**
 * Last night, from the state row and from nowhere else.
 *
 * `null` when the body carries no state row, which is the honest answer for a
 * box that has been up for less than one night. It is never reconstructed by
 * summing counties; see the header.
 */
export function statewideHeadline(body: BirdsBody | null | undefined): MigrationNight | null {
  const raw = body?.statewide;
  if (raw === null || raw === undefined || typeof raw !== "object") return null;
  const crossed = finite(raw.crossed);
  const peakAloft = finite(raw.peakAloft);
  if (crossed === null || peakAloft === null) return null;
  return {
    crossed,
    peakAloft,
    peakAt: typeof raw.peakAt === "string" ? raw.peakAt : "",
    meanAltitude: finite(raw.meanAltitude) ?? 0,
    heading: typeof raw.heading === "string" && raw.heading !== "" ? raw.heading : "",
  };
}

/**
 * A bearing as the words a caption uses. `130.3` is "south-east".
 *
 * Sixteen points would be more precise than the number deserves: the state's own
 * mean direction is a circular mean over thousands of ten-minute county rows, and
 * "east-south-east" claims a resolution that mean does not carry.
 */
export function headingWords(degrees: number): string {
  if (!Number.isFinite(degrees)) return "";
  const points = [
    "north", "north-east", "east", "south-east",
    "south", "south-west", "west", "north-west",
  ];
  const index = Math.round((((degrees % 360) + 360) % 360) / 45) % 8;
  return points[index] as string;
}

/**
 * An ISO instant in California's own clock, as `23:20 PDT`.
 *
 * `Intl` rather than a fixed offset, because California is UTC-7 for most of the
 * year and UTC-8 for the rest, and a caption that says PDT in January is the
 * kind of wrong nobody notices for months. Falls back to the UTC form where
 * `Intl` has no time-zone data at all, which is a real configuration of Node.
 */
export function californiaClock(iso: string): string {
  const ms = Date.parse(iso);
  if (!Number.isFinite(ms)) return "";
  try {
    return new Intl.DateTimeFormat("en-GB", {
      timeZone: "America/Los_Angeles",
      hour: "2-digit",
      minute: "2-digit",
      hour12: false,
      timeZoneName: "short",
    }).format(new Date(ms));
  } catch {
    return `${new Date(ms).toISOString().slice(11, 16)} UTC`;
  }
}

/**
 * The empty sky's sentence.
 *
 * Always says three things: that nothing is aloft, *why* — which is a fact about
 * the instrument and not about the birds — and what last night did, so that a
 * viewer who arrives at noon still learns something true. Only the third part is
 * conditional, and it is missing only on a box that has not yet seen a night.
 */
export function quietMessage(
  reason: MigrationQuiet["reason"],
  statewide: MigrationNight | null,
): string {
  const why =
    reason === "daylight"
      ? "Nothing is aloft. BirdCast measures migration only after dark."
      : reason === "off-season"
        ? "Nothing is aloft over California tonight. The radars are reporting and every county is quiet."
        : "Nothing has answered for the sky over California yet.";

  if (statewide === null) return why;

  const heading = statewide.heading === "" ? "" : ` heading ${statewide.heading}`;
  const peak = statewide.peakAt === "" ? "" : ` at ${californiaClock(statewide.peakAt)}`;
  const altitude =
    statewide.meanAltitude > 0 ? `, at a mean ${Math.round(statewide.meanAltitude)} metres` : "";
  return (
    `${why} Last night ${count(statewide.crossed)} birds crossed California${heading}, ` +
    `peaking at ${count(statewide.peakAloft)} aloft${peak}${altitude}.`
  );
}

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

/**
 * Apply the ladder to one body.
 *
 * Pure and total: `null`, a malformed body, or a body from a server one version
 * behind all produce an honest empty sky rather than an exception. The consumer
 * is a render loop.
 *
 * **Daylight wins over the feed.** If the sun is up the answer is `daylight`
 * whatever arrived, because a daytime BirdCast row is a measurement of something
 * the instrument does not measure.
 */
export function promoteBirds(
  body: BirdsBody | null | undefined,
  options: BirdsPromoteOptions = {},
): BirdsPromotion {
  const nowMs = options.nowMs ?? Date.now();
  const empty = emptyBirdsPromotion();
  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: BirdsSourceId = body.source === "cloud1" ? "cloud1" : "none";
  const observedAt = typeof body.observedAt === "string" ? body.observedAt : "";
  const statewide = statewideHeadline(body);

  if (ageMs === null) {
    // Nothing has ever answered. Not "the sky is empty" — "nobody has spoken".
    return { ...empty, source, fetchedAt };
  }

  const rows = Array.isArray(body.counties) ? body.counties : [];
  const placed = countyRows(rows);
  const counties: MigrationCounty[] = [];
  for (const row of placed) {
    const aloft = finite(row.aloft) ?? 0;
    if (aloft < BIRDS_MIN_ALOFT) continue;
    counties.push({
      id: typeof row.id === "string" ? row.id : "",
      name: typeof row.name === "string" ? row.name : "",
      lat: finite(row.lat) as number,
      lng: finite(row.lon) as number,
      areaKm2: finite(row.areaKm2) as number,
      aloft,
      altitude: Math.max(0, finite(row.altitude) ?? 0),
      direction: finite(row.direction) ?? 0,
      speed: Math.max(0, finite(row.speed) ?? 0),
    });
  }
  counties.sort((a, b) => b.aloft - a.aloft);
  const drawn = counties.slice(0, BIRDS_COUNTY_LIMIT);
  const suppressed = rows.length - drawn.length;

  const sun = options.solarElevationDeg;
  const daylight = typeof sun === "number" && Number.isFinite(sun) && sun > BIRDS_MAX_SOLAR_ELEVATION_DEG;

  let quiet: MigrationQuiet | null = null;
  if (daylight) {
    quiet = { reason: "daylight", message: quietMessage("daylight", statewide) };
  } else if (drawn.length === 0) {
    // Dark, the feed answered, and every county is quiet. Reported as the
    // seasonal case rather than as a fault, because that is what it is: the
    // store holds two nights and no year of history, so "off-season" is the
    // honest name for "dark and nothing flying" until there is one.
    const reason: MigrationQuiet["reason"] = body.quiet?.reason === "no-data" ? "no-data" : "off-season";
    quiet = { reason, message: quietMessage(reason, statewide) };
  }

  const field: MigrationField = {
    counties: quiet === null ? drawn : [],
    observedAt,
    statewide,
    quiet,
  };

  return {
    source,
    fetchedAt,
    ageMs,
    field,
    suppressed: quiet === null ? Math.max(0, suppressed) : rows.length,
    message: quiet !== null ? quiet.message : busyMessage(drawn, statewide, observedAt),
  };
}

/**
 * The sentence for a night with something in it.
 *
 * Note what it does **not** say: how many birds are over California right now.
 * The only way to get that from what crosses the wire is to add the counties up,
 * and that is the six-times error. The state row's instantaneous figure is not on
 * `MigrationNight`, so the honest headline available here is last night's
 * crossing count, and the present tense is spent on where and how high instead.
 */
function busyMessage(
  counties: readonly MigrationCounty[],
  statewide: MigrationNight | null,
  observedAt: string,
): string {
  const mean =
    counties.length === 0
      ? 0
      : counties.reduce((sum, c) => sum + c.altitude * c.aloft, 0) /
        Math.max(1, counties.reduce((sum, c) => sum + c.aloft, 0));
  const bearing =
    counties.length === 0 ? "" : headingWords(circularMean(counties));
  const when = observedAt === "" ? "" : ` As of ${californiaClock(observedAt)}.`;
  const last =
    statewide === null ? "" : ` Last night ${count(statewide.crossed)} crossed the state.`;
  return (
    `Birds are aloft over ${counties.length} ` +
    `${counties.length === 1 ? "county" : "counties"}` +
    `${bearing === "" ? "" : `, drifting ${bearing}`}` +
    `${mean > 0 ? `, a mean ${Math.round(mean)} metres up` : ""}.` +
    `${last}${when}`
  );
}

/**
 * The mean direction of travel, weighted by birds aloft — **circularly**.
 *
 * An arithmetic mean of bearings is wrong at the wrap: 350 and 10 average to 180
 * and point the whole state due south when it is flying due north. Tonight's
 * rows sit in the 120-150 degree bucket and would survive a naive mean, which is
 * exactly why this would ship broken and stay broken.
 */
function circularMean(counties: readonly MigrationCounty[]): number {
  let x = 0;
  let y = 0;
  for (const county of counties) {
    const weight = Math.max(0, county.aloft);
    const radians = (county.direction * Math.PI) / 180;
    x += weight * Math.cos(radians);
    y += weight * Math.sin(radians);
  }
  if (x === 0 && y === 0) return 0;
  return (((Math.atan2(y, x) * 180) / Math.PI) + 360) % 360;
}

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

function count(value: number): string {
  return Math.round(value).toLocaleString("en-US");
}

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