/**
 * The radar gate, held to the three things a screenshot cannot check.
 *
 * **It refuses sea clutter.** DATA-CATALOG.md:84 already records a 54.5 dBZ cell
 * between Long Beach and Catalina on a clear day, and the upstream applies no
 * anomalous-propagation filter of any kind. A thunderstorm drawn over the ocean
 * on a blue afternoon is the fire layer's twenty-two-orange-marks failure in a
 * different palette, and the only place it can be prevented is here — a
 * statement about data that lives inside a mesh builder is a statement nobody
 * can test without a WebGL context (`engine/fires.ts:16-23`).
 *
 * **It knows "not on-line" from "off the air".** Six of the sixteen stations in
 * the store read `RDA - Maintenance Action Mandatory` right now, including Los
 * Angeles and San Diego, and every one of them is transmitting. A gate that
 * treated the flag as an outage would grey out the southern half of the state on
 * a night with a monsoon over it. That case is asserted below with the real
 * strings.
 *
 * **It knows Nevada from the Pacific.** The board now reaches -114.0 and 42.05 N,
 * so a third of it is land the California coastline trace has never described. A
 * land test of "inside the pack's polygon" calls a Great Basin thunderstorm sea
 * clutter, and tonight's frame has one at 41.375,-117.125 carrying 53 dBZ.
 *
 * The coastline is the pack's own, not a fake: a rectangle would pass every one
 * of these assertions while the shipped board drew rain over Reno.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import CALIFORNIA_CITY from "../../cities/california.ts";
import {
  AP_MAX_CELLS,
  AP_MIN_DBZ,
  buildRadarField,
  classifyGround,
  emptyRadarPromotion,
  promoteRadar,
  RADAR_DRY_DBZ,
  RADAR_MIN_WET_FRACTION,
  RADAR_RAIN_DBZ,
  stationIsDown,
  type RadarCell,
  type RadarStation,
} from "../../server/radar.ts";
import type { RadarBody } from "../../server/wire.ts";

const COAST = CALIFORNIA_CITY.landmasses;
const BOARD = CALIFORNIA_CITY.bounds;

/**
 * The station table as it stands, with the operability strings verbatim.
 *
 * Copied from `station_state` rather than invented. The point of the file is
 * that six of these are flagged and none of the six is down.
 */
const STATIONS: RadarStation[] = [
  { id: "KBBX", lat: 39.49611, lon: -121.63165, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" },
  { id: "KBHX", lat: 40.49833, lon: -124.29215, type: "WSR-88D", operability: "RDA - On-line" },
  { id: "KDAX", lat: 38.50111, lon: -121.67782, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" },
  { id: "KESX", lat: 35.70111, lon: -114.89138, type: "WSR-88D", operability: "RDA - On-line" },
  { id: "KEYX", lat: 35.09777, lon: -117.56074, type: "WSR-88D", operability: "RDA - On-line" },
  { id: "KHNX", lat: 36.31416, lon: -119.63213, type: "WSR-88D", operability: "RDA - On-line" },
  { id: "KMUX", lat: 37.15522, lon: -121.89843, type: "WSR-88D", operability: "RDA - On-line" },
  { id: "KNKX", lat: 32.91888, lon: -117.04193, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" },
  { id: "KSOX", lat: 33.81773, lon: -117.63599, type: "WSR-88D", operability: "RDA - Maintenance Action Required" },
  { id: "KVBX", lat: 34.83855, lon: -120.3979, type: "WSR-88D", operability: "RDA - On-line" },
  { id: "KVTX", lat: 34.41166, lon: -119.1786, type: "WSR-88D", operability: "RDA - Maintenance Action Mandatory" },
  { id: "TLAS", lat: 36.144, lon: -115.007, type: "TDWR", operability: "RDA - On-line" },
];

/** A cell centre, on the product's own quarter-degree lattice. */
function cell(lat: number, lon: number, dbz: number): RadarCell {
  return { lat, lon, dbz };
}

function build(cells: RadarCell[], stations = STATIONS, wetFraction = 0.02) {
  return buildRadarField({
    cells,
    stations,
    bounds: BOARD,
    coast: COAST,
    observedAt: "2026-08-23T03:55Z",
    wetFraction,
  });
}

function at(field: NonNullable<ReturnType<typeof build>["field"]>, lat: number, lng: number) {
  const row = Math.round((lat - field.minLat) / field.cellLat);
  const col = Math.round((lng - field.minLng) / field.cellLng);
  assert.ok(row >= 0 && row < field.rows && col >= 0 && col < field.cols, `${lat},${lng} is off the lattice`);
  return field.dbz[row * field.cols + col];
}

// ---- Anomalous propagation ------------------------------------------------

describe("the sea-clutter rule", () => {
  it("drops 47.5 dBZ off Big Sur and keeps the same value over the Inland Empire", () => {
    // 35.625,-121.375 is the lattice cell the design's own probe found carrying
    // 47.5 dBZ over 302 pixels of open water. 33.875,-117.375 is Riverside.
    assert.equal(classifyGround(35.625, -121.375, COAST), "sea");
    assert.equal(classifyGround(33.875, -117.375, COAST), "land");

    const out = build([cell(35.625, -121.375, 47.5), cell(33.875, -117.375, 47.5)]);
    const field = out.field;
    assert.ok(field, "a frame with a real Riverside storm in it must be promoted");

    assert.equal(at(field, 35.625, -121.375), RADAR_DRY_DBZ, "the ocean cell must not survive");
    assert.equal(at(field, 33.875, -117.375), 47.5, "the land cell must survive untouched");
    assert.equal(out.suppressed.length, 1);
    assert.equal(out.suppressed[0]?.dbz, 47.5);
  });

  it("keeps an offshore cell that is part of the same blob as a cell over land", () => {
    // Rain coming ashore. The seaward half is over water and reaches 55 dBZ, and
    // deleting it would punch a hole in the middle of a real storm — which is
    // the failure a per-cell version of this rule actually produced, measured on
    // tonight's 03:55Z frame.
    // 34.625 N, because at 34.375 the shore is already at -119.6: south of
    // Point Conception the coast turns east and the Santa Barbara Channel is all
    // water out to Ventura. Getting that wrong is how the first draft of this
    // test wrote a cell "ashore near Vandenberg" that was 60 km out to sea.
    assert.equal(classifyGround(34.625, -120.625, COAST), "land");
    const out = build([
      cell(34.625, -121.125, 55),
      cell(34.625, -120.875, 52),
      cell(34.625, -120.625, 48), // ashore, on Vandenberg
    ]);
    assert.deepEqual(out.suppressed, []);
    assert.ok(out.field);
    assert.equal(at(out.field, 34.625, -121.125), 55);
  });

  it("leaves weak marine echo alone, because drizzle offshore is real", () => {
    const weak = AP_MIN_DBZ - 5;
    const out = build([cell(35.625, -121.875, weak), cell(35.875, -121.875, weak), cell(33.875, -117.375, 45)]);
    assert.deepEqual(out.suppressed, []);
    assert.ok(out.field);
    assert.equal(at(out.field, 35.625, -121.875), weak);
  });

  it("refuses to delete a blob larger than one radar's clutter ring", () => {
    // AP_MAX_CELLS is a cap on how much weather one bad night can remove, and it
    // is a judgement rather than a measurement — so it is asserted, not assumed.
    const big: RadarCell[] = [];
    for (let i = 0; i <= AP_MAX_CELLS; i++) {
      big.push(cell(33.375 + Math.floor(i / 6) * 0.25, -121.875 + (i % 6) * 0.25, 45));
    }
    const out = build(big);
    assert.deepEqual(out.suppressed, [], "a system this size is not clutter");
  });

  it("does not mistake Nevada for the Pacific", () => {
    // The extended board's eastern third is land the California trace has never
    // described. Tonight's Great Basin storm sits at 41.375,-117.125 at 53 dBZ.
    assert.equal(classifyGround(41.375, -117.125, COAST), "unmapped");
    const out = build([cell(41.375, -117.125, 53)]);
    assert.deepEqual(out.suppressed, []);
    assert.ok(out.field);
    assert.equal(at(out.field, 41.375, -117.125), 53);
  });

  it("puts the shore where the shore is, at every latitude", () => {
    // The first version of the land test took the westernmost coastline VERTEX
    // within half a degree, which on a diagonal coast is wrong by the diagonal:
    // between 35.1 N and 36.1 N the shore moves 1.2 degrees of longitude, and
    // the band rule declared the Big Sur clutter inland.
    assert.equal(classifyGround(35.625, -121.125, COAST), "land");
    assert.equal(classifyGround(35.375, -121.125, COAST), "sea");
    assert.equal(classifyGround(35.875, -121.375, COAST), "land");
    assert.equal(classifyGround(35.625, -121.375, COAST), "sea");
  });
});

// ---- Coverage holes -------------------------------------------------------

describe("a radar that is off the air", () => {
  it("marks its cells unknown rather than dry", () => {
    const inoperable: RadarStation[] = [
      { id: "KDOWN", lat: 40.0, lon: -120.0, type: "WSR-88D", operability: "RDA - Inoperable" },
      // Far enough away that its 230 km reach cannot cover the cell under test.
      { id: "KFAR", lat: 33.0, lon: -117.0, type: "WSR-88D", operability: "RDA - On-line" },
    ];
    const out = build([cell(33.125, -117.125, 45)], inoperable);
    const field = out.field;
    assert.ok(field);

    assert.equal(at(field, 40.125, -120.125), null, "a cell only a dead radar covers is unknown");
    assert.equal(at(field, 33.125, -116.875), RADAR_DRY_DBZ, "a cell a live radar covers is dry");
    assert.equal(field.stationsDown, 1);
    assert.equal(field.stations, 2);
  });

  it("does not count a maintenance work order as an outage", () => {
    // The trap. Six of sixteen stations carry one of these right now and all six
    // are transmitting; treating them as down blanks the southern half of the
    // state on the night this was written.
    assert.equal(stationIsDown("RDA - Maintenance Action Mandatory"), false);
    assert.equal(stationIsDown("RDA - Maintenance Action Required"), false);
    assert.equal(stationIsDown("RDA - On-line"), false);
    assert.equal(stationIsDown("RDA - Inoperable"), true);
    assert.equal(stationIsDown("RDA - Off-line"), true);
    // Unknown is not down. A status feed that hiccups must not grey the board.
    assert.equal(stationIsDown(""), false);
    assert.equal(stationIsDown(null), false);
    assert.equal(stationIsDown(undefined), false);

    const out = build([cell(33.875, -117.375, 45)]);
    assert.ok(out.field);
    assert.equal(out.field.stationsDown, 0, "not one of the twelve real stations is down");
    assert.equal(at(out.field, 33.875, -117.625), RADAR_DRY_DBZ, "Orange County is dry, not unknown");
  });
});

// ---- Promotion ------------------------------------------------------------

describe("promotion", () => {
  it("is decided from statewide coverage and not from cell count", () => {
    const cells = [cell(33.875, -117.375, 45)];
    const below = build(cells, STATIONS, RADAR_MIN_WET_FRACTION / 2);
    assert.equal(below.field, null);
    assert.equal(below.quiet, "below-threshold");

    const above = build(cells, STATIONS, RADAR_MIN_WET_FRACTION * 2);
    assert.ok(above.field, "the same one cell, above the coverage floor, is drawn");
  });

  it("draws nothing when the gate has taken everything that was there", () => {
    const out = build([cell(35.625, -121.375, 47.5)], STATIONS, 0.02);
    assert.equal(out.field, null);
    assert.equal(out.quiet, "nothing-survived");
    assert.equal(out.suppressed.length, 1, "…and says what it took");
  });

  it("gives a lattice that is the product's own grid, clipped to the board", () => {
    const out = build([cell(33.875, -117.375, 45)]);
    const field = out.field;
    assert.ok(field);
    // 32.5-42.05 N and -124.5 to -114.0 W on a quarter-degree grid whose centres
    // sit on the half-cell. Every one of these is derived, not configured, so a
    // board that moves again moves this with it.
    assert.equal(field.rows, 38);
    assert.equal(field.cols, 42);
    assert.equal(field.minLat, 32.625);
    assert.equal(field.minLng, -124.375);
    assert.equal(field.dbz.length, 38 * 42);
    assert.ok(field.rows * field.cols * 4 < 8 * 1024, "the whole raster is under 8 KB as RGBA");
  });
});

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

describe("promoteRadar", () => {
  const body = (over: Partial<RadarBody>): RadarBody => ({
    source: "cloud1",
    fetchedAt: "2026-08-23T04:00:21Z",
    field: null,
    ttlSeconds: 300,
    ...over,
  });
  const NOW = Date.parse("2026-08-23T04:05:00Z");

  it("survives a null, an undefined and a body full of nonsense", () => {
    for (const input of [null, undefined, 42, "rain", { source: "cloud1" }]) {
      const out = promoteRadar(input as unknown as RadarBody, NOW);
      assert.equal(out.field, null);
      assert.notEqual(out.message, "");
    }
  });

  it("says which kind of empty it is", () => {
    const never = promoteRadar(body({ fetchedAt: new Date(0).toISOString() }), NOW);
    assert.equal(never.ageMs, null);
    assert.match(never.message, /fact about this box/);

    const quiet = promoteRadar(body({}), NOW);
    assert.equal(quiet.ageMs, 4 * 60_000 + 39_000);
    assert.match(quiet.message, /Nothing is falling/);
    assert.match(quiet.message, /minutes old/);
  });

  it("refuses a lattice whose dbz length disagrees with its own shape", () => {
    // Not a field with a problem — a field that would be drawn rotated, every
    // row after the first offset by the difference.
    const out = promoteRadar(
      body({
        field: {
          minLat: 32.625, minLng: -124.375, cellLat: 0.25, cellLng: 0.25,
          rows: 38, cols: 42, dbz: [45, null], observedAt: "x",
          wetFraction: 0.02, stations: 16, stationsDown: 0,
        },
      }),
      NOW,
    );
    assert.equal(out.field, null);
  });

  it("carries a promoted lattice through unchanged, and counts what is in it", () => {
    const built = build([cell(33.875, -117.375, 61.5), cell(34.125, -117.375, 22)]);
    assert.ok(built.field);
    const out = promoteRadar(body({ field: built.field }), NOW);
    assert.ok(out.field);
    assert.equal(out.field.dbz.length, built.field.dbz.length);
    assert.match(out.message, /61\.5 dBZ/);
    assert.match(out.message, /2 cells of 1596/);
    assert.match(out.message, /All 12 radars reporting/);
    assert.ok(RADAR_RAIN_DBZ === 20);
  });

  it("has an empty promotion that is a sentence and not a blank", () => {
    const empty = emptyRadarPromotion();
    assert.equal(empty.source, "none");
    assert.equal(Date.parse(empty.fetchedAt), 0);
    assert.ok(empty.message.length > 40);
  });
});
