/**
 * The radar feed's one upstream: a **projection**, served by the machine that
 * owns the database.
 *
 * ### Read this before changing anything here
 *
 * `echo_cells` carries a column called `distance_km`, and it is measured **from
 * a private house**. It is the same leak class `server/src/fires/cloud1.ts` was
 * written to make impossible, and the resolution is the same one: this module
 * has **no database**. It makes two bounded GETs against an endpoint whose
 * SELECT lists are written out by hand on the other machine, and it cannot be
 * careless with a column it was never sent.
 *
 * That ordering is the security property. A route somebody writes next year
 * cannot select a column that is not on this box, and no amount of care in this
 * file would have been as strong. It is also why there is no sqlite driver in
 * this repo's dependency list and why `scripts/check-dependency-licenses.mjs` is
 * entitled to keep it that way.
 *
 * **Nothing here may ever name `distance_km` — not in a query string, not in a
 * field list, not in a comment that becomes a query string.**
 * `server/src/test/sky.test.ts` asserts the adopted key set *equals* an
 * allowlist and that no request path contains the word, which is the second
 * line: an `assert.ok(!keys.has("distance_km"))` would pass for every column
 * nobody thought to name, and that is precisely the class of column that gets
 * added later.
 *
 * ### Both halves, or neither
 *
 * Cells and station status are asked for in parallel and `null` is returned if
 * **either** fails. A lattice with a silently empty station list is a board that
 * has quietly stopped drawing its own coverage holes, and it would draw them as
 * clear sky — the strongest possible version of the failure this whole layer
 * exists to prevent. One snapshot, one answer; `upstream.ts` above this keeps
 * serving the last good whole body until a whole one arrives.
 */

import { getJson } from "../http.ts";
import type { RadarCell, RadarStation } from "../../../src/server/radar.ts";

/** How long either GET may take. The upstream reads sqlite behind its own cache. */
const TIMEOUT_MS = 8_000;

/**
 * The most rows this build will adopt from either endpoint.
 *
 * The statewide lattice is under 1,700 cells and the whole station table is
 * sixteen rows, so both of these are two orders of magnitude clear of anything
 * real. They are the second of two bounds rather than the only one — a caller
 * that trusts an upstream's cap inherits the day the upstream's cap changes.
 */
const MAX_CELLS = 8_000;
const MAX_STATIONS = 128;

export interface RadarSnapshot {
  /** Epoch ms at which this box completed the fetch. */
  fetchedAt: number;
  /** ISO-8601 of the volume scan the cells came from. */
  observedAt: string;
  /**
   * Share of California at or above the rain threshold, 0..1.
   *
   * Measured upstream over the whole state's pixels, not over our lattice. It is
   * what promotion is decided from, and it is scale-free — see
   * `RADAR_MIN_WET_FRACTION` in `src/server/radar.ts` for why that matters.
   */
  wetFraction: number;
  cells: RadarCell[];
  stations: RadarStation[];
  attribution: string[];
}

export interface RadarLog {
  warn(msg: string): void;
}

/** What the projection endpoint answers with. Restated, never imported. */
interface CellsResponse {
  observedAt?: unknown;
  wetFraction?: unknown;
  cells?: unknown;
  attribution?: unknown;
}

interface StationsResponse {
  stations?: unknown;
  attribution?: unknown;
}

export async function fetchProjection(
  base: string,
  key: string,
  log: RadarLog,
): Promise<RadarSnapshot | null> {
  if (base === "") return null;
  const headers = key === "" ? undefined : { "x-tera-key": key };
  const options = { timeoutMs: TIMEOUT_MS, ...(headers === undefined ? {} : { headers }) };

  const [cellsBody, stationsBody] = await Promise.all([
    getJson<CellsResponse>(`${base}/cells`, options),
    getJson<StationsResponse>(`${base}/stations`, options),
  ]);

  if (cellsBody === null || stationsBody === null) {
    log.warn(
      "radar:cloud1: the projection did not answer with both halves " +
        `(cells ${cellsBody === null ? "failed" : "ok"}, ` +
        `stations ${stationsBody === null ? "failed" : "ok"}); keeping the last whole body`,
    );
    return null;
  }

  return {
    fetchedAt: Date.now(),
    observedAt: nonEmptyString(cellsBody.observedAt) ?? "",
    // Clamped, not trusted. A `wetFraction` served as a percentage rather than a
    // fraction would promote every frame for ever, and the number is the only
    // thing standing between a quiet day and 1,596 texels of nothing.
    wetFraction: clamp01(finite(cellsBody.wetFraction) ?? 0),
    cells: readArray(cellsBody.cells, MAX_CELLS, readCell),
    stations: readArray(stationsBody.stations, MAX_STATIONS, readStation),
    attribution: mergeAttribution(cellsBody.attribution, stationsBody.attribution),
  };
}

// ---- Reading somebody else's JSON -----------------------------------------
//
// Field by field, checked rather than cast, and the field list is exhaustive on
// purpose: what is not named here does not enter this process. The upstream is a
// machine on the same tailnet run by the same person, which is exactly the
// relationship that produces "it will always be the right shape" — and then a
// collector gains a column.

function readCell(raw: unknown): RadarCell | null {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
  const row = raw as Record<string, unknown>;
  const lat = finite(row.lat);
  const lon = finite(row.lon);
  const dbz = finite(row.dbz);
  if (lat === null || lon === null || dbz === null) return null;
  return { lat, lon, dbz };
}

function readStation(raw: unknown): RadarStation | null {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
  const row = raw as Record<string, unknown>;
  const lat = finite(row.lat);
  const lon = finite(row.lon);
  const id = nonEmptyString(row.id);
  if (id === null || lat === null || lon === null) return null;
  return {
    id,
    lat,
    lon,
    // Carried verbatim and judged once, in the gate, where the argument for the
    // judgement can be read. Six of sixteen stations read "Maintenance Action
    // Mandatory" right now and every one of them is transmitting.
    type: nonEmptyString(row.type),
    operability: nonEmptyString(row.operability),
  };
}

function readArray<T>(raw: unknown, cap: number, read: (row: unknown) => T | null): T[] {
  if (!Array.isArray(raw)) return [];
  const out: T[] = [];
  for (const row of raw) {
    if (out.length >= cap) break;
    const parsed = read(row);
    if (parsed !== null) out.push(parsed);
  }
  return out;
}

/** Both credit lines, de-duplicated, in the order they arrived. */
function mergeAttribution(a: unknown, b: unknown): string[] {
  const out: string[] = [];
  for (const source of [a, b]) {
    if (!Array.isArray(source)) continue;
    for (const line of source) {
      if (typeof line !== "string" || line === "" || out.includes(line)) continue;
      out.push(line);
    }
  }
  return out;
}

function nonEmptyString(value: unknown): string | null {
  if (typeof value !== "string") return null;
  const trimmed = value.trim();
  return trimmed === "" ? null : trimmed;
}

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));
}
