/**
 * celestrak.org — the general perturbations element sets.
 *
 * CelesTrak is Dr T.S. Kelso's, it has been running since 1985, and it is the
 * canonical public mirror of the US Space Force's catalogue. The data are US
 * Government works and are not copyrightable; the *service* is one person's
 * bandwidth bill, which is the constraint that shapes everything below.
 *
 * ### The rate limit, and the one URL that gets around it honestly
 *
 * CelesTrak asks callers not to fetch more often than the data changes — element
 * sets are regenerated a few times a day — and enforces it. A caller who polls
 * `gp.php?GROUP=starlink` on a short interval gets a 403, or worse gets a 200
 * whose body is the sentence "has not updated since", which parses as zero
 * satellites and looks exactly like an empty sky.
 *
 * There are two answers and this module uses both:
 *
 * 1. **The supplemental feed for Starlink.** SpaceX publishes its own ephemeris,
 *    CelesTrak republishes it at `supplemental/sup-gp.php?FILE=starlink`, and
 *    that endpoint is not under the same ceiling as the primary group — which is
 *    the point of it existing. It is also *better data*: supplemental elements
 *    come from the operator rather than from radar, so a satellite that
 *    manoeuvred last night is in the right place this morning.
 * 2. **A six-hour TTL**, which is longer than it looks like it needs to be and is
 *    still four fetches a day against a source that regenerates a handful of
 *    times. `upstream.ts` rule 3 does the rest: a failed fetch stamps the clock,
 *    so a 403 costs one request every six hours rather than one per viewer.
 *
 * The body that says "has not updated since" is caught explicitly rather than
 * left to the parser, because rule 3 only helps if a soft failure is recognised
 * as a failure. A 200 that yields no satellites and a 403 must reach the cache
 * as the same thing, or the last good catalogue gets overwritten with nothing.
 *
 * ### What this deliberately does not do
 *
 * No key, no account, no `space-track.org`. Space-Track has the fuller catalogue
 * and requires credentials and a login flow whose terms restrict redistribution —
 * which would put a licence argument between this repo and a stranger who cloned
 * it, and the acceptance test for the whole server is that a stranger with no
 * keys gets something that works. See CONTRACT.md §5.1.
 *
 * And no User-Agent games. The identifying agent from `http.ts` goes out here
 * exactly as it does to NWS. A source that would rather not serve us is entitled
 * to that, and the response to a rate limit is a longer TTL, never a costume.
 */

import { getText, userAgent } from "../http.ts";
import type { SatelliteGroup, WireSatellite } from "../../../src/server/wire.ts";

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

/**
 * A parsed catalogue and the instant it was read.
 *
 * The timestamp is the *fetch* and not the element epoch, because a body can mix
 * element sets from several days and the client's question is "how old is the
 * freshest thing this server knows", which the fetch answers and the epochs do
 * not.
 */
export interface SatelliteSnapshot {
  /** Epoch milliseconds. */
  fetchedAt: number;
  satellites: WireSatellite[];
}

/**
 * The groups fetched, in the order they are merged.
 *
 * This is a short list where Osiris-style aggregators run forty, and the length
 * is the design. Every entry is one more request against a volunteer's server
 * every six hours, and this layer draws satellites over a city — the debris
 * fields and the cubesat catalogue would add eleven thousand objects nobody can
 * see and nobody asked about, at the cost of a slower boot and a fatter cache.
 *
 * Order does not decide capacity — `merge` does, and it reserves the ceiling for
 * the small groups before the `fill` one takes the remainder. Without that, the
 * supplemental Starlink feed is large enough to consume the entire cap on its
 * own and every other group here is fetched and then thrown away.
 */
const GROUPS: { url: string; group: SatelliteGroup; fill?: true }[] = [
  // `fill` — see `merge`. Starlink is both the largest group by an order of
  // magnitude and the one everybody came for, so it takes whatever capacity the
  // others leave rather than competing with them for it.
  {
    url: "https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=starlink&FORMAT=tle",
    group: "starlink",
    fill: true,
  },
  { url: group("stations"), group: "station" },
  { url: group("gps-ops"), group: "navigation" },
  { url: group("galileo"), group: "navigation" },
  { url: group("oneweb"), group: "comms" },
  { url: group("iridium-NEXT"), group: "comms" },
  { url: group("weather"), group: "weather" },
  // The naked-eye catalogue: the hundred-odd objects bright enough to be worth
  // drawing for somebody who might go outside and look for one.
  { url: group("visual"), group: "other" },
];

function group(name: string): string {
  return `https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=tle`;
}

/**
 * The ceiling on what is kept, after merging.
 *
 * Starlink alone is past eight thousand objects and the supplemental feed serves
 * all of them. Every one costs a `SatRec` in every browser that opens the map,
 * and SGP4 is not free — the client propagates the whole set on a timer. Six
 * thousand is comfortably more than a city view can draw legibly and comfortably
 * under what a laptop can propagate at 4 Hz, and going over it truncates rather
 * than failing, because a partial sky beats no sky.
 *
 * This ceiling is reached on every real fetch, because Starlink alone exceeds it.
 * `merge` says so in the log rather than trimming quietly.
 */
export const MAX_SATELLITES = 6000;

/**
 * TLE text is at most a few megabytes; the default 4 MB ceiling in `http.ts` is
 * the right order but the Starlink supplemental feed alone runs to roughly
 * 1.6 MB and the merged set is larger, so this is stated rather than inherited.
 */
const MAX_BODY_BYTES = 8 * 1024 * 1024;

/**
 * Longer than the six-second default because these are big text bodies from one
 * modestly-resourced server, and a timeout here is not an outage — it is one
 * group missing from a merge that still returns the other seven.
 */
const TIMEOUT_MS = 20_000;

/**
 * The whole catalogue, or `null` if **nothing at all** answered.
 *
 * A partial answer is a success: seven groups out of eight is a sky, and
 * returning `null` for it would discard seven good fetches and serve the
 * previous snapshot instead. Only a total failure is a failure, which is the
 * same rule `upstream.ts` applies one level up.
 */
export async function fetchCatalogue(
  contact: string,
  log: CelestrakLog,
): Promise<SatelliteSnapshot | null> {
  const headers = { "user-agent": userAgent(contact) };

  // Concurrently, because they are independent and eight sequential 20-second
  // timeouts is a two-and-a-half-minute worst case on a route somebody is
  // waiting on. Eight parallel requests every six hours is not a flood.
  const fetched = await Promise.all(
    GROUPS.map(async (spec) => {
      const text = await getText(spec.url, {
        headers,
        timeoutMs: TIMEOUT_MS,
        maxBytes: MAX_BODY_BYTES,
      });
      if (text === null) {
        log.warn(`satellites:celestrak: ${spec.url} did not answer`);
        return [];
      }

      // The decision is the parse, not a guess about the body — see `whyEmpty`.
      const parsed = parseTle(text, spec.group);
      if (parsed.length === 0) {
        log.warn(`satellites:celestrak: ${spec.url} ${whyEmpty(text)}`);
        return [];
      }
      return parsed;
    }),
  );

  const satellites = merge(fetched, GROUPS.findIndex((spec) => spec.fill === true), log);
  if (satellites.length === 0) return null;

  return { fetchedAt: Date.now(), satellites };
}

/**
 * Why a 200 yielded no element sets, for the log line and for nothing else.
 *
 * The *decision* is made by the parse: a body that produces no element sets did
 * not answer, whatever it contained. That is the honest test and it needs no
 * heuristics — an earlier version of this guessed from the body length instead,
 * with a 200-byte floor, and a group holding one satellite is about 160 bytes.
 * The guard rejected legitimate answers, which is exactly the failure mode it
 * was written to prevent, aimed the other way.
 *
 * What the body *said* still matters to a human, though: "CelesTrak is telling
 * us to slow down" calls for a longer TTL and "the group is genuinely empty"
 * calls for editing `GROUPS`, and one warning line that distinguishes them saves
 * an operator the trip.
 */
function whyEmpty(text: string): string {
  if (text.includes("has not updated since")) {
    return "was refused for asking too often (CelesTrak answers this with a 200); " +
      "raise TERA_SATELLITES_TTL";
  }
  if (text.trim() === "") return "answered with an empty body";
  return "answered with something that holds no element sets";
}

/**
 * Three-line TLE text into element sets.
 *
 * The format is fixed-width and unforgiving, and the parse leans on that: a line
 * starting `1 ` followed by a line starting `2 ` is an element set, and whatever
 * came immediately before it is the name. That is enough to handle both the
 * three-line form CelesTrak serves and the bare two-line form some mirrors do,
 * without a mode flag.
 *
 * Anything that does not fit is skipped rather than throwing. This function is
 * reading somebody else's file over the network; the one thing it must not do is
 * take the server down because a line was short.
 */
export function parseTle(text: string, group: SatelliteGroup): WireSatellite[] {
  const lines = text.split("\n").map((line) => line.trimEnd());
  const out: WireSatellite[] = [];

  for (let i = 0; i < lines.length - 1; i += 1) {
    const line1 = lines[i] ?? "";
    const line2 = lines[i + 1] ?? "";
    if (!line1.startsWith("1 ") || !line2.startsWith("2 ")) continue;

    const noradId = Number.parseInt(line1.slice(2, 7).trim(), 10);
    if (!Number.isInteger(noradId) || noradId <= 0) continue;

    // The name is the preceding line when there is one that is not itself an
    // element line. A catalogue number is a poor name but it is a true one, and
    // it beats dropping the object.
    const previous = i > 0 ? (lines[i - 1] ?? "").trim() : "";
    const named = previous !== "" && !previous.startsWith("1 ") && !previous.startsWith("2 ");
    // Some feeds prefix the name line with "0 ", per the original TLE spec.
    const name = named ? previous.replace(/^0\s+/, "") : `NORAD ${noradId}`;

    out.push({ noradId, name, group, line1, line2 });
    // The element lines are consumed as a pair; the loop's own increment covers
    // the second, so one extra step here lands on the line after it.
    i += 1;
  }

  return out;
}

/**
 * One list, one entry per NORAD id, capped at `MAX_SATELLITES`.
 *
 * Two rules, and they pull in opposite directions, which is why this is not a
 * loop with a counter in it.
 *
 * **Precedence goes to the `fill` group.** A Starlink appearing both in the
 * supplemental feed and in some future catch-all group should keep the
 * operator's ephemeris and the `starlink` display bucket. The alternative
 * silently re-buckets half the constellation as `other` and nobody notices until
 * the colours look wrong.
 *
 * **Capacity goes to everybody else first.** This is the half that was missing,
 * and the bug it caused was total rather than subtle: the supplemental Starlink
 * feed is past 6,000 objects on its own, so a straight first-wins loop spent the
 * entire ceiling on it and served a sky containing *nothing else* — no ISS, no
 * GPS, no weather satellites, none of the naked-eye catalogue. Every other group
 * in `GROUPS` was fetched, parsed, and thrown away. The truncation warning fired
 * exactly as designed, which is the only reason it was caught.
 *
 * So the small groups are counted first and the fill group takes the remainder.
 * Together those two rules are worth about a thousand objects of everything else
 * plus five thousand Starlinks, which is the sky that was wanted.
 */
function merge(
  groups: WireSatellite[][],
  fillIndex: number,
  log: CelestrakLog,
): WireSatellite[] {
  const fill = groups[fillIndex] ?? [];
  const fillIds = new Set(fill.map((sat) => sat.noradId));

  const seen = new Set<number>();
  const out: WireSatellite[] = [];
  let othersDropped = 0;
  groups.forEach((list, i) => {
    if (i === fillIndex) return;
    for (const sat of list) {
      // Dropped here rather than later: the fill group's copy of this object is
      // the better one, and it is guaranteed a place below.
      if (fillIds.has(sat.noradId) || seen.has(sat.noradId)) continue;
      seen.add(sat.noradId);
      if (out.length < MAX_SATELLITES) out.push(sat);
      else othersDropped += 1;
    }
  });

  const reserved = out.length;
  for (const sat of fill) {
    if (out.length >= MAX_SATELLITES) break;
    if (seen.has(sat.noradId)) continue;
    seen.add(sat.noradId);
    out.push(sat);
  }

  /**
   * What was left out, said out loud, once per fetch — so about four times a
   * day. A cap nobody reports reads as "we served everything" when it did not.
   *
   * The fill group being trimmed is the **normal** state and not a fault: there
   * are more Starlinks than this build will draw and there is no configuration
   * in which that stops being true. It gets a line because the number is worth
   * knowing, not because anything is wrong.
   *
   * The other branch is a fault. If the small groups alone reach the ceiling,
   * somebody has added a large group to `GROUPS` without raising
   * `MAX_SATELLITES`, and the symptom is a sky with no Starlinks in it at all.
   */
  const fillServed = out.length - reserved;
  if (othersDropped > 0) {
    log.warn(
      `satellites:celestrak: the groups other than the fill group filled the ` +
        `${MAX_SATELLITES}-object ceiling on their own and ${othersDropped} were still ` +
        `dropped — the fill group got no room at all. Raise MAX_SATELLITES or shorten GROUPS.`,
    );
  } else if (fillServed < fill.length) {
    log.warn(
      `satellites:celestrak: serving ${fillServed} of ${fill.length} objects from the ` +
        `fill group, ${reserved} from the rest; the ${MAX_SATELLITES}-object ceiling is ` +
        `the limit. This is the expected state — there are more Starlinks than a browser ` +
        `will propagate.`,
    );
  }

  return out;
}
