/**
 * 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 under the ODbL — set
 * `TERA_ADSB_ENDPOINT` to whichever, and to nothing else: `licence.ts` holds
 * the allowlist and derives the credit from whichever answered. 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 do not
 * permit scraping and do not permit 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, userAgent } from "../http.ts";
import { isOpenAdsbUrl } from "./licence.ts";
import type { WireAircraft } from "../../../src/server/wire.ts";

/**
 * The shared dump1090/readsb aircraft record, as both feeds emit it.
 *
 * Declared field by field rather than as an index signature, and the list grew
 * because the fields that were missing from it were the ones the map needed
 * most. For a long time this read hex/flight/lat/lon/alt_baro/track, which is
 * enough to put a dart somewhere and not enough to make it fly: a client handed
 * positions alone can only interpolate between the last two it was sent, so
 * every aeroplane arrived at the newest known point and stopped dead until the
 * next snapshot. The velocity was in every row of the feed the whole time and
 * this file threw it away.
 *
 * Sampled live from `api.adsb.lol/v2/point` while writing this, so the names and
 * the units are observed rather than remembered:
 *
 * ```json
 * { "hex": "a923cd", "flight": "UAL505  ", "r": "N68834", "t": "B739",
 *   "gs": 249.2, "track": 357.7, "baro_rate": 1344, "alt_baro": 4950,
 *   "seen_pos": 0.183 }
 * ```
 */
interface RawAircraft {
  hex?: string;
  flight?: string;
  lat?: number;
  lon?: number;
  alt_baro?: number | string;
  track?: number;
  /** Ground speed in knots. `0.0` on a parked aircraft or a ground vehicle. */
  gs?: number;
  /** Barometric climb rate, feet per minute, positive up. */
  baro_rate?: number;
  /** Geometric climb rate, feet per minute. Present when `baro_rate` is not. */
  geom_rate?: number;
  /** Registration — the tail number, e.g. `"N68834"`. */
  r?: string;
  /** ICAO type designator, e.g. `"B739"`. */
  t?: string;
  /** Seconds since this row's *position* was last updated. */
  seen_pos?: number;
}

/** One knot in metres per second. The wire carries SI; the feed does not. */
const KNOT_MS = 0.514_444;
/** One foot per minute in metres per second. */
const FPM_MS = 0.00508;

/**
 * A finite number, or `undefined` — so an optional wire field is either a
 * measurement or absent, and never `NaN` dressed as one.
 *
 * Every velocity below goes through this. A feed that sends `"gs": null` for a
 * target it has a position but no velocity for is normal traffic, not an error,
 * and the honest thing to do with it is to say nothing: a client that
 * dead-reckons a null speed as zero draws a parked airliner at 10,000 feet, and
 * one that reads it as `NaN` moves the aircraft to nowhere at all.
 */
function finite(value: number | undefined): number | undefined {
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

/**
 * A short printable code from the feed — a registration or a type designator —
 * or `undefined`.
 *
 * Trimmed, because the feeds pad `flight` and are not consistent about the
 * others, and length-capped because these end up on a card in a browser and the
 * row is somebody else's data. Nothing is invented and nothing is expanded: the
 * designator is published as `B739` and this repo does not ship a table that
 * turns it into "Boeing 737-900", because a table like that is one more thing
 * that can be wrong about a real aeroplane.
 */
function code(value: string | undefined, max: number): string | undefined {
  if (typeof value !== "string") return undefined;
  const trimmed = value.trim();
  return trimmed === "" || trimmed.length > max ? undefined : trimmed;
}

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`.
 *
 * The same argument applies to *which* feed, and is enforced twice. `config.ts`
 * refuses an endpoint that is not on `licence.ts`'s allowlist at boot, so the
 * only string that can reach this parameter today is one an operator was told
 * about. The check below is the second lock, on the door itself: this function
 * will fetch anywhere, and it is the one that turns a URL into bytes this box
 * republishes under a derived open-terms credit. A caller that one day builds
 * the endpoint from somewhere else pays one `new URL()` per poll to find that
 * out, rather than the public finding out later.
 */
export async function fetchAdsb(
  endpoint: string,
  center: { lat: number; lng: number },
  radiusNm: number,
  contact: string,
  log?: AdsbLog,
): Promise<FlightsSnapshot | null> {
  const url = `${endpoint.replace(/\/$/, "")}/v2/point/${center.lat.toFixed(4)}/${center.lng.toFixed(4)}/${Math.round(radiusNm)}`;
  if (!isOpenAdsbUrl(url)) {
    log?.warn(
      `flights:adsb: refusing to fetch ${endpoint} — it is not an openly-licensed feed this ` +
        `build may republish. See server/src/flights/licence.ts.`,
    );
    return null;
  }
  const body = await getJson<AircraftEnvelope>(url, {
    timeoutMs: ADSB_TIMEOUT_MS,
    headers: { "user-agent": userAgent(contact) },
  });
  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;

/**
 * How long this one upstream is given to answer, overriding `http.ts`'s 6 s.
 *
 * Not a guess. Five consecutive calls to `api.adsb.lol/v2/point` measured from
 * the box that actually serves this deployment came back in 1.2 s, 5.7 s,
 * 24.3 s, 3.5 s and 4.2 s — all of them `200`, none of them wrong, and three of
 * the five outside the shared default. The feed is a volunteer-run aggregator
 * answering a geographic query, and its tail is long in a way an NWS
 * observation's is not.
 *
 * The failure that caused was invisible in the worst way: the request did not
 * error, it timed out, and `flights/index.ts` did exactly what it should with a
 * `null` — served the simulated plan. So the sky over California quietly stopped
 * being real for whoever loaded the page in that window, the health endpoint
 * still reported `flights: adsb` because the *source* was configured and
 * reachable, and `degraded[]` stayed empty because nothing was degraded at boot.
 *
 * 25 s is above the slowest measured answer. It costs nothing when the feed is
 * quick, because this runs behind `createUpstream`'s TTL and not on the request
 * path of every visitor, and a slow real answer is worth more than a fast
 * fabricated one on a board whose whole claim is that the aircraft are live.
 */
const ADSB_TIMEOUT_MS = 25_000;

/**
 * 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;
    const address = icao24(a.hex);
    const speed = finite(a.gs);
    // `baro_rate` is what the airframe's altimeter says and `alt_baro` is the
    // altitude beside it; `geom_rate` is the GNSS answer and is what a row
    // carries when the barometric one is unavailable. Either is a climb.
    const climb = finite(a.baro_rate) ?? finite(a.geom_rate);
    const age = finite(a.seen_pos);
    // Eight characters covers every civil registration in use; four is the
    // width of an ICAO type designator, and the feed emits nothing longer.
    const registration = code(a.r, 12);
    const kind = code(a.t, 8);
    aircraft.push({
      id,
      callsign: callsign === "" ? undefined : callsign,
      // Carried only when it is one. Both feeds emit `~`-prefixed anonymous
      // addresses for TIS-B and MLAT targets, which are not ICAO addresses at
      // all, and `id` falls back to the callsign for a record with no `hex` —
      // so the id is not a reliable address and a detail card must not present
      // it as one. `WireAircraft.icao24` says the same thing from the other
      // end of the wire.
      ...(address === null ? {} : { icao24: address }),
      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,
      /**
       * The velocity, and the conditions under which it is carried at all.
       *
       * **Both halves of the gate matter.** A speed is only sent when the feed
       * reported a positive one *and* reported a track to go with it, because
       * the consumer of these two numbers is a dead-reckoner and the pair is
       * what it integrates. `heading` above falls back to `0` for a row with no
       * track — which is harmless for a symmetrical glyph that is not moving,
       * and is a claim that a ground vehicle is taxiing due north at thirty
       * knots the moment anything advances it. Ground vehicles and parked
       * aircraft report `gs: 0.0` with a null track and are exactly this case.
       *
       * So: no track, no speed. `engine/flights.ts` holds such a track
       * motionless rather than flying it along an invented heading, which is
       * the right answer for something that is genuinely parked.
       */
      ...(speed === undefined || speed <= 0 || typeof a.track !== "number"
        ? {}
        : { groundSpeed: speed * KNOT_MS }),
      // Barometric first because it is what the altitude above is, so a climb
      // drawn from this rate is consistent with the height it is drawn at.
      // Geometric is a few percent different in real air and identical here.
      ...(climb === undefined ? {} : { verticalRate: climb * FPM_MS }),
      // How stale the position already was when the feed answered. Small on a
      // healthy feed and carried anyway: it is the client's only way to know
      // what instant these coordinates describe. See `WireAircraft.ageSeconds`.
      ...(age === undefined || age < 0 ? {} : { ageSeconds: age }),
      // ODbL, off the same row as the position, and publishable on the same
      // terms: this is the enrichment a commercial feed was once wanted for.
      ...(registration === undefined ? {} : { registration }),
      ...(kind === undefined ? {} : { type: kind }),
    });
  }
  return { aircraft, observedAt: observedAtMs(body.now) };
}

/**
 * A transponder address as the feeds write one: six hex digits, no prefix.
 *
 * Anchored and lowercased rather than pattern-matched loosely, because the
 * output is something a person pastes into a registry lookup — a wrong one
 * names a different aircraft, which is worse than saying nothing. Anything
 * else, including the `~abcdef` anonymous form, is `null`.
 */
function icao24(hex: string | undefined): string | null {
  if (typeof hex !== "string") return null;
  const trimmed = hex.trim().toLowerCase();
  return /^[0-9a-f]{6}$/.test(trimmed) ? trimmed : null;
}

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