/**
 * Real aircraft, from feeds that can actually be pointed at.
 *
 * Two sources, one shape. `adsb.lol` and `airplanes.live` serve the same
 * volunteer-fed ADS-B in the same JSON, keyless and with open terms — set
 * `TERA_ADSB_ENDPOINT` to whichever. A local `dump1090` writes that same JSON to
 * disk, and reading it is the best answer of the three: an RTL-SDR on a box in
 * the Bay produces first-party data with no terms to comply with at all.
 *
 * FlightRadar24 is deliberately absent and will stay absent. Their terms forbid
 * scraping and forbid redistribution, so a client for it in an Apache-2.0 repo
 * would be shipping instructions for breaking a ToS. If a private deployment
 * wants it, it is an adapter in that deployment. ARCHITECTURE.md §4.
 */

import { readFile } from "node:fs/promises";
import { getJson } from "../http.ts";
import type { WireAircraft } from "../../../src/server/wire.ts";

/** The shared dump1090/readsb aircraft record, as both feeds emit it. */
interface RawAircraft {
  hex?: string;
  flight?: string;
  lat?: number;
  lon?: number;
  alt_baro?: number | string;
  track?: number;
}

interface AircraftEnvelope {
  ac?: RawAircraft[];
  aircraft?: RawAircraft[];
  now?: number;
}

export interface FlightsSnapshot {
  aircraft: WireAircraft[];
  observedAt: number;
}

/** Just enough of the service's logger to say a feed came back oversized. */
export interface AdsbLog {
  warn(msg: string): void;
}

/**
 * One circle of sky, from a hosted feed.
 *
 * `center` is a **region centre from `regions.ts`**, never a coordinate off the
 * wire. That is the whole reason the routes validate before they get here: this
 * function will happily fetch anywhere, and the thing standing between it and
 * being an open proxy pointed at a community feed is that its callers can only
 * hand it points an operator configured. The radius is clamped to the 250 nm
 * both feeds accept, in `config.ts`.
 *
 * How often this is allowed to be called, and the arithmetic that keeps it
 * inside adsb.lol's one-request-per-second ceiling, is in `flights/index.ts`.
 */
export async function fetchAdsb(
  endpoint: string,
  center: { lat: number; lng: number },
  radiusNm: number,
  log?: AdsbLog,
): Promise<FlightsSnapshot | null> {
  const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
  const body = await getJson<AircraftEnvelope>(url);
  if (body === null) return null;
  return normalise(body, (dropped) =>
    log?.warn(`flights:adsb: feed sent ${dropped + MAX_ROWS} aircraft; kept the first ${MAX_ROWS}`),
  );
}

/**
 * A receiver's own `aircraft.json`, read off local disk.
 *
 * dump1090 rewrites this file every second, so a partial read is a real
 * possibility rather than a theoretical one — which is why an unparseable body
 * returns `null` and lets the caller keep the previous snapshot instead of
 * emptying the sky for one tick.
 */
export async function readDump1090(path: string, log?: AdsbLog): Promise<FlightsSnapshot | null> {
  try {
    const text = await readFile(path, "utf8");
    return normalise(JSON.parse(text) as AircraftEnvelope, (dropped) =>
      log?.warn(
        `flights:dump1090: ${path} held ${dropped + MAX_ROWS} aircraft; ` +
          `kept the first ${MAX_ROWS}`,
      ),
    );
  } catch {
    return null;
  }
}

/**
 * How many aircraft one snapshot may carry.
 *
 * Not a limit anybody should ever meet: a 250 nm circle over the busiest
 * airspace on earth is a few thousand contacts, and the board draws a dart for
 * each. It is here because the row count is set by somebody else's server and
 * the parsed array is held in this process and served, cached, to every
 * anonymous caller for the length of the TTL — a feed answering with 200,000
 * rows was measured at a 14.9 MB public body. The excess is dropped and
 * counted rather than silently trimmed, so an operator whose sky is suddenly
 * capped finds out from the log rather than from a map that looks thin.
 */
const MAX_ROWS = 5000;

/**
 * One envelope, turned into a snapshot — or `null` if it is not an envelope.
 *
 * ### Checked, not assumed
 *
 * This was `body.ac ?? body.aircraft ?? []` followed by a `for…of`, so a
 * well-formed JSON 200 with a number in `ac` threw "rows is not iterable" out
 * of `fetchAdsb` — past `upstream.ts`, which then neither stamped its clock nor
 * caught it, and out to the route as a 500. `markers/gate.ts` has always done
 * this for its own input; the hosted-feed path was the one that trusted the
 * wire.
 *
 * ### Why an unreadable body is `null` and not an empty sky
 *
 * `?? []` would still be here and would still not throw, and it would be the
 * wrong answer. `flights/index.ts` reads `null` as "fall back to the plan" and
 * reads a snapshot — *including an empty one* — as observed truth, which is
 * correct: three in the morning over a small city really is an empty sky. A
 * garbled body coerced to zero aircraft is therefore served as `mode: "live"`
 * with nothing in it, and the operator who has just turned on ADS-B gets a
 * blank map that claims to be real. That is precisely the outcome the top of
 * this file says the fallback exists to prevent.
 *
 * So the test is on the *array*, not on its length: a body carrying `ac: []`
 * is an empty circle and is live; a body carrying neither `ac` nor `aircraft`
 * as an array is not an answer at all.
 */
function normalise(
  body: AircraftEnvelope,
  onDrop?: (dropped: number) => void,
): FlightsSnapshot | null {
  const raw = body.ac ?? body.aircraft;
  if (!Array.isArray(raw)) return null;
  const rows = raw.length > MAX_ROWS ? raw.slice(0, MAX_ROWS) : raw;
  if (raw.length > rows.length) onDrop?.(raw.length - rows.length);

  const aircraft: WireAircraft[] = [];
  for (const a of rows) {
    // A row that is not an object at all reaches this from the same wire that
    // sent a number where the array was.
    if (a === null || typeof a !== "object") continue;
    if (typeof a.lat !== "number" || typeof a.lon !== "number") continue;
    const callsign = a.flight?.trim();
    const id = a.hex ?? callsign;
    if (id === undefined || id === "") continue;
    aircraft.push({
      id,
      callsign: callsign === "" ? undefined : callsign,
      lat: a.lat,
      lng: a.lon,
      // The feeds report barometric altitude in feet, and send the string
      // "ground" for anything that is not flying. The wire carries metres.
      altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 0,
      heading: typeof a.track === "number" ? a.track : 0,
    });
  }
  return { aircraft, observedAt: observedAtMs(body.now) };
}

/**
 * dump1090 stamps `now` in seconds and the hosted feeds stamp it in
 * milliseconds, using the same field name. Anything past the year 2001 in
 * milliseconds is already too large to be a plausible epoch in seconds, so the
 * magnitude tells them apart without needing to know which feed answered.
 */
function observedAtMs(now: number | undefined): number {
  if (typeof now !== "number" || !Number.isFinite(now)) return Date.now();
  return now > 1e12 ? Math.round(now) : Math.round(now * 1000);
}
