/**
 * The migration feed's one upstream: a **projection**, served by the machine
 * that owns the database.
 *
 * The same arrangement as `fires/cloud1.ts` and `radar/cloud1.ts`, and it is
 * worth saying why it applies here too even though `birds.sqlite` holds nothing
 * private. The value is not only in what this particular store contains: it is
 * that **cloud-2 has no database at all**, so no route written on this box next
 * year can select a column from any of them. Making that true of every feed is
 * what keeps it true of the one that matters.
 *
 * ### Both halves, or neither
 *
 * The newest ten-minute granule and last night's state summary are asked for in
 * parallel, and `null` comes back if either fails. That matters more here than
 * anywhere else in this build, because the two halves answer *different*
 * questions and the second one is the whole empty state: without last night's
 * figures the daylight sky has nothing to say but "nothing is aloft", which is
 * the blank panel this layer was designed to avoid.
 *
 * ### The state row is dropped on the way in, and again on the way out
 *
 * `counties` upstream is 59 rows: 58 counties plus `US-CA`, `kind='state'`, with
 * NULL coordinates and — in tonight's granule — 793,141 birds aloft against the
 * largest county's 82,549. It is refused here by `countyRows`, refused again by
 * `promoteBirds` in the browser, and a third time by the layer's own reader.
 * Three lines for one row is not paranoia: the failure it prevents is a
 * three-quarter-million-bird blob at 0,0, and the reason it would happen is that
 * the row looks exactly like the other fifty-eight.
 */

import { getJson } from "../http.ts";
import { countyRows } from "../../../src/server/birds.ts";
import type { WireBirdCounty } from "../../../src/server/wire.ts";

/** How long either GET may take. */
const TIMEOUT_MS = 8_000;

/** California has 58 counties; the cap is a bound on a bad day, not a fit. */
const MAX_COUNTIES = 256;

export interface BirdsSnapshot {
  /** Epoch ms at which this box completed the fetch. */
  fetchedAt: number;
  /** ISO-8601 of the ten-minute granule, or "" if the upstream sent none. */
  observedAt: string;
  counties: WireBirdCounty[];
  /** Last night, from the state row. Never a sum over counties. */
  statewide: {
    crossed: number;
    peakAloft: number;
    peakAt: string;
    meanAltitude: number;
    heading: string;
  } | null;
  attribution: string[];
}

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

interface CountiesResponse {
  observedAt?: unknown;
  counties?: unknown;
  attribution?: unknown;
}

interface NightResponse {
  night?: unknown;
  attribution?: unknown;
}

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

  const [countiesBody, nightBody] = await Promise.all([
    getJson<CountiesResponse>(`${base}/counties`, options),
    getJson<NightResponse>(`${base}/night`, options),
  ]);

  if (countiesBody === null || nightBody === null) {
    log.warn(
      "birds:cloud1: the projection did not answer with both halves " +
        `(counties ${countiesBody === null ? "failed" : "ok"}, ` +
        `night ${nightBody === null ? "failed" : "ok"}); keeping the last whole body`,
    );
    return null;
  }

  const rows = readArray(countiesBody.counties, MAX_COUNTIES, readCounty);

  return {
    fetchedAt: Date.now(),
    observedAt: nonEmptyString(countiesBody.observedAt) ?? "",
    // The state row goes here and not downstream. Everything past this point is
    // a county with a coordinate and an area.
    counties: countyRows(rows),
    statewide: readNight(nightBody.night),
    attribution: mergeAttribution(countiesBody.attribution, nightBody.attribution),
  };
}

// ---- Reading somebody else's JSON -----------------------------------------

function readCounty(raw: unknown): WireBirdCounty | null {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
  const row = raw as Record<string, unknown>;
  const id = nonEmptyString(row.id);
  const lat = finite(row.lat);
  const lon = finite(row.lon);
  const areaKm2 = finite(row.areaKm2);
  // No id, no coordinate or no area means nothing downstream can place it or
  // scatter it. All three are structural, not cosmetic — and the state row is
  // missing two of the three, which is the second reason it never survives.
  if (id === null || lat === null || lon === null || areaKm2 === null) return null;
  return {
    id,
    name: nonEmptyString(row.name) ?? "",
    lat,
    lon,
    areaKm2,
    aloft: Math.max(0, finite(row.aloft) ?? 0),
    altitude: Math.max(0, finite(row.altitude) ?? 0),
    direction: finite(row.direction) ?? 0,
    speed: Math.max(0, finite(row.speed) ?? 0),
  };
}

/**
 * Last night's state row.
 *
 * `null` rather than a zeroed object when it is missing, because zero birds
 * crossing California is a finding and an absent summary is not one.
 */
function readNight(raw: unknown): BirdsSnapshot["statewide"] {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
  const row = raw as Record<string, unknown>;
  const crossed = finite(row.crossed);
  const peakAloft = finite(row.peakAloft);
  if (crossed === null || peakAloft === null) return null;
  return {
    crossed,
    peakAloft,
    peakAt: nonEmptyString(row.peakAt) ?? "",
    meanAltitude: Math.max(0, finite(row.meanAltitude) ?? 0),
    heading: nonEmptyString(row.heading) ?? "",
  };
}

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

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