/**
 * The fire feed's one upstream: a **projection**, served by the machine that
 * owns the database.
 *
 * ### Read this before changing anything here
 *
 * The obvious way to build this feed was to copy `fires.sqlite` onto this box
 * and query it locally. That was rejected, and the reason is not performance and
 * not dependency count — though it is also both of those.
 *
 * The store is centred on a private home. `observations` carries `distance_km`,
 * `bearing_deg` and `threat`, and `detections` carries `distance_km`, all four
 * measured from that address. `threat` is the one that was nearly missed: it is
 *
 *     (16 / distance_to_house)^2 x log10(acres) x momentum x containment
 *                               x wind-alignment-to-house
 *
 * and acreage and containment are already public — they come from CAL FIRE. So
 * that expression **inverts**: one fire gives a circle around the house, three
 * give an intersection. A copy of that database on cloud-2, which is public
 * facing, is a home address sitting on disk waiting for one careless star-query
 * in a route somebody writes next year.
 *
 * So 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 columns it was never sent. That ordering is the
 * security property: the leak is made impossible rather than merely avoided.
 *
 * It is also why there is no sqlite driver in this repo's dependency list, and
 * `scripts/check-dependency-licenses.mjs` is entitled to keep it that way.
 *
 * ### Both halves, or neither
 *
 * `fetchProjection` asks for incidents and detections in parallel and returns
 * `null` if **either** fails. That is deliberate and it is the opposite of what
 * a partial-tolerance instinct suggests. A body carrying live incidents and a
 * silently empty detection array is a board that has quietly stopped showing the
 * strongest evidence it has, with nothing to say so — the same class of failure
 * as a cron that reports success while every snapshot inside it fails. 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 { FireDetection, FireIncident } from "../../../src/server/wire.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 upstream caps its own SQL, so this is the second of two bounds rather than
 * the only one — but a caller that trusts an upstream's cap is a caller that
 * inherits the day the upstream's cap changes. `flights/adsb.ts` takes the same
 * belt-and-braces position for the same reason: `http.ts` bounds the *bytes*,
 * and this bounds what is kept and served on.
 */
const MAX_INCIDENTS = 500;
const MAX_DETECTIONS = 2_000;

export interface FiresSnapshot {
  /** Epoch ms at which this box completed the fetch. */
  fetchedAt: number;
  /** The upstream de-duplication watermark. Every incident row matched it. */
  latestSeen: string | null;
  incidents: FireIncident[];
  detections: FireDetection[];
  detectionWindowHours: number;
  attribution: string[];
}

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

/** What the projection endpoint answers with. Restated, never imported. */
interface IncidentsResponse {
  latestSeen?: unknown;
  incidents?: unknown;
  attribution?: unknown;
}

interface DetectionsResponse {
  windowHours?: unknown;
  detections?: unknown;
  attribution?: unknown;
}

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

  const [incidentsBody, detectionsBody] = await Promise.all([
    getJson<IncidentsResponse>(`${base}/incidents`, options),
    getJson<DetectionsResponse>(
      `${base}/detections?hours=${encodeURIComponent(String(Math.round(windowHours)))}`,
      options,
    ),
  ]);

  // Both or neither. See the header — a live incident set beside a silently
  // empty detection array is a board that has stopped saying what it knows.
  if (incidentsBody === null || detectionsBody === null) {
    log.warn(
      "fires:cloud1: the projection did not answer with both halves " +
        `(incidents ${incidentsBody === null ? "failed" : "ok"}, ` +
        `detections ${detectionsBody === null ? "failed" : "ok"}); keeping the last whole body`,
    );
    return null;
  }

  const incidents = readArray(incidentsBody.incidents, MAX_INCIDENTS, readIncident);
  const detections = readArray(detectionsBody.detections, MAX_DETECTIONS, readDetection);

  return {
    fetchedAt: Date.now(),
    latestSeen: nonEmptyString(incidentsBody.latestSeen),
    incidents,
    detections,
    detectionWindowHours: finite(detectionsBody.windowHours) ?? Math.round(windowHours),
    attribution: mergeAttribution(incidentsBody.attribution, detectionsBody.attribution),
  };
}

// ---- Reading somebody else's JSON -----------------------------------------
//
// Field by field, checked rather than cast. 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 and a renderer draws a fire at 0,0 in the Gulf of Guinea. Nothing here
// throws: a row that will not read is dropped, and the fetch above still returns
// a body.

function readIncident(raw: unknown): FireIncident | 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);
  const lastSeen = nonEmptyString(row.lastSeen);
  // No id, no coordinate or no watermark means nothing downstream can place it,
  // de-duplicate it or link to it. All three are structural, not cosmetic.
  if (id === null || lat === null || lon === null || lastSeen === null) return null;

  return {
    id,
    source: nonEmptyString(row.source) ?? "",
    name: nonEmptyString(row.name),
    lat,
    lon,
    provenance: nonEmptyString(row.provenance) ?? "us-gov",
    county: nonEmptyString(row.county),
    type: (nonEmptyString(row.type) ?? "").trim(),
    url: nonEmptyString(row.url),
    firstSeen: nonEmptyString(row.firstSeen) ?? lastSeen,
    lastSeen,
    observedAt: nonEmptyString(row.observedAt),
    acres: finite(row.acres),
    pctContained: finite(row.pctContained),
  };
}

function readDetection(raw: unknown): FireDetection | 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);
  if (lat === null || lon === null) return null;

  const persistentDays = finite(row.persistentDays) ?? 0;
  return {
    sat: nonEmptyString(row.sat) ?? "",
    acquiredAt: nonEmptyString(row.acquiredAt) ?? "",
    lat,
    lon,
    frp: finite(row.frp),
    // Carried verbatim. MODIS puts an integer 0-100 in this column and VIIRS
    // puts low/nominal/high; normalising here would mean choosing one of them to
    // be wrong. `detectionConfidence()` in `src/server/fires.ts` does the branch
    // once, on the client, where `sat` is beside it.
    confidence: nonEmptyString(row.confidence),
    // Absent means not persistent, which is the direction that draws MORE rather
    // than fewer — so a projection one version behind this one shows the
    // industrial flare as a weak hot pixel rather than hiding a real fire.
    persistent: row.persistent === true,
    persistentDays,
  };
}

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