/**
 * The catalogue, and the three ways it is allowed to be wrong.
 *
 * CelesTrak is one person's bandwidth and it rate-limits, so — as with the ADS-B
 * tests — most of this file is about **how often this server calls out** rather
 * than about what comes back. The difference is the failure mode being guarded
 * against: adsb.lol answers a caller who is asking too often with a status code,
 * and CelesTrak answers with **200 and a sentence**. A soft failure that parses
 * as an empty catalogue would overwrite the last good sky with nothing and look
 * like a clear night — so a group that yields no element sets is treated as a
 * group that did not answer, and that is worth a test of its own.
 *
 * The other half is about `merge`, and about the one thing in this module that
 * only real data ever revealed: the supplemental Starlink feed is larger than
 * the whole object ceiling, so a merge that spends capacity in list order serves
 * a catalogue containing nothing but Starlink. See the suite that says so.
 */

import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { MAX_SATELLITES, parseTle } from "../satellites/celestrak.ts";
import type { SatellitesBody } from "../../../src/server/wire.ts";

const realFetch = globalThis.fetch;
let calls: string[] = [];
/** Every User-Agent this server actually put on the wire, in order. */
let agents: string[] = [];
let feedIsUp = true;
/** Served instead of a real TLE body, for the soft-failure tests. */
let feedOverride: string | undefined = undefined;
/** Served for the Starlink feed alone, for the ceiling test. */
let starlinkOverride: string | undefined = undefined;

/**
 * A plausible three-line element set. The numbers are ISS's published TLE
 * checksummed shape rather than a hand-mangled one, because `parseTle` reads
 * fixed columns and a shortened line would exercise a path no real feed takes.
 */
const ISS = [
  "ISS (ZARYA)",
  "1 25544U 98067A   26037.51782528 -.00002182  00000-0 -11606-4 0  2927",
  "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
].join("\n");

const STARLINK = [
  "STARLINK-1007",
  "1 44713U 19074A   26037.54166667  .00002182  00000-0  16538-3 0  9995",
  "2 44713  53.0538 156.7285 0001367  86.7419 273.3728 15.06391223 12345",
].join("\n");

function feed(url: string): string | undefined {
  if (!feedIsUp) return undefined;
  if (feedOverride !== undefined) return feedOverride;
  if (url.includes("FILE=starlink")) return starlinkOverride ?? STARLINK;
  if (url.includes("GROUP=stations")) return ISS;
  // Every other configured group answers with nothing usable, which is a
  // partial-merge case and must still produce a sky.
  return "";
}

globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
  const url = String(input);
  calls.push(url);
  agents.push(String((init?.headers as Record<string, string> | undefined)?.["user-agent"] ?? ""));
  const body = feed(url);
  if (body === undefined) return new Response("nope", { status: 503 });
  return new Response(body, { status: 200, headers: { "content-type": "text/plain" } });
}) as unknown as typeof globalThis.fetch;

after(() => {
  globalThis.fetch = realFetch;
});

beforeEach(() => {
  calls = [];
  agents = [];
  feedIsUp = true;
  feedOverride = undefined;
  starlinkOverride = undefined;
});

function appWith(env: Record<string, string>) {
  const config = loadConfig(env);
  config.logLevel = "silent";
  return buildApp(config);
}

const celestrakEnv = { TERA_SATELLITES_SOURCE: "celestrak" };

async function get(app: ReturnType<typeof buildApp>): Promise<SatellitesBody> {
  const res = await app.inject({ method: "GET", url: "/api/v1/satellites" });
  assert.equal(res.statusCode, 200);
  return res.json() as SatellitesBody;
}

describe("a box with no satellite source", () => {
  it("serves an empty catalogue rather than an invented one", async () => {
    const app = appWith({});
    after(() => app.close());

    const body = await get(app);
    assert.equal(body.source, "none");
    assert.deepEqual(body.satellites, []);
  });

  it("makes no outbound request at all", async () => {
    const app = appWith({});
    after(() => app.close());

    await get(app);
    await get(app);
    assert.deepEqual(calls, []);
  });
});

describe("the CelesTrak source", () => {
  it("takes the supplemental Starlink feed, not the rate-limited group", async () => {
    const app = appWith(celestrakEnv);
    after(() => app.close());

    await get(app);
    const starlink = calls.filter((url) => url.includes("starlink"));
    assert.equal(starlink.length, 1);
    assert.match(starlink[0] ?? "", /supplemental\/sup-gp\.php\?FILE=starlink/);
    // The primary group is the one that 403s under polling. It must not be here.
    assert.equal(
      calls.some((url) => url.includes("GROUP=starlink")),
      false,
    );
  });

  it("identifies the operator, rather than disguising the caller", async () => {
    const app = appWith({ ...celestrakEnv, TERA_SATELLITES_CONTACT: "ops@example.com" });
    after(() => app.close());

    await get(app);
    assert.ok(agents.length > 0, "nothing was fetched");
    for (const agent of agents) {
      assert.match(agent, /^tera-api /);
      assert.match(agent, /ops@example\.com/);
    }
  });

  it("falls back to the weather contact rather than sending an empty one", async () => {
    const config = loadConfig({ ...celestrakEnv, TERA_WEATHER_CONTACT: "ops@example.com" });
    assert.equal(config.satellites.contact, "ops@example.com");
  });

  it("asks once per TTL however many callers turn up", async () => {
    const app = appWith(celestrakEnv);
    after(() => app.close());

    await Promise.all([get(app), get(app), get(app), get(app)]);
    // One pass over the group list, not four.
    const starlink = calls.filter((url) => url.includes("starlink"));
    assert.equal(starlink.length, 1);
  });

  it("floors the TTL at an hour, whatever the environment asked for", async () => {
    const config = loadConfig({ ...celestrakEnv, TERA_SATELLITES_TTL: "0" });
    const app = appWith({ ...celestrakEnv, TERA_SATELLITES_TTL: "0" });
    after(() => app.close());

    // The config keeps what was asked; the service is what enforces the floor,
    // so the assertion belongs on the body the client is handed.
    assert.equal(config.satellites.ttlSeconds, 0);
    const body = await get(app);
    assert.ok(body.ttlSeconds >= 3600, `ttlSeconds was ${body.ttlSeconds}`);
  });

  it("serves a partial merge rather than nothing when a group goes quiet", async () => {
    const app = appWith(celestrakEnv);
    after(() => app.close());

    // Six of the eight groups answer with an empty body in the fixture above.
    const body = await get(app);
    assert.equal(body.source, "celestrak");
    assert.equal(body.satellites.length, 2);
  });

  it("reports the fetch time, so a stale catalogue can be recognised as one", async () => {
    const app = appWith(celestrakEnv);
    after(() => app.close());

    const body = await get(app);
    assert.notEqual(body.fetchedAt, new Date(0).toISOString());
    assert.ok(Date.now() - Date.parse(body.fetchedAt) < 60_000);
  });

  it("credits CelesTrak", async () => {
    const app = appWith(celestrakEnv);
    after(() => app.close());

    const body = await get(app);
    assert.match(body.attribution?.[0] ?? "", /CelesTrak/);
  });
});

/**
 * The failure this file exists for.
 *
 * A 200 carrying "has not updated since" is CelesTrak's way of saying no. Parsed
 * naively it is an empty catalogue, and an empty catalogue cached for six hours
 * is a sky that stays dark until somebody restarts the box.
 */
describe("a rate-limited fetch", () => {
  it("is treated as a failure, not as an empty sky", async () => {
    const app = appWith(celestrakEnv);
    after(() => app.close());

    // Warm the cache with a good answer first.
    const good = await get(app);
    assert.equal(good.satellites.length, 2);

    feedOverride =
      "The requested group has not updated since the last request. Please try again later.";
    // Force the TTL to have elapsed by standing up a second app against the same
    // fixture — a fresh service, so this is really "what does one bad fetch do".
    const cold = appWith(celestrakEnv);
    after(() => cold.close());
    const body = await get(cold);

    // Nothing was ever cached in this instance, so the honest answer is the
    // empty one — but it must be reported as `source: none`-shaped emptiness
    // rather than as a successful catalogue of zero satellites.
    assert.equal(body.satellites.length, 0);
    assert.equal(body.fetchedAt, new Date(0).toISOString());
  });

  it("does not overwrite the last good catalogue", async () => {
    const app = appWith({ ...celestrakEnv, TERA_SATELLITES_TTL: "3600" });
    after(() => app.close());

    const good = await get(app);
    assert.equal(good.satellites.length, 2);

    feedOverride = "has not updated since";
    const again = await get(app);
    assert.equal(again.satellites.length, 2, "the last good sky must survive a refused fetch");
  });
});

/**
 * The bug that only real data found.
 *
 * The supplemental Starlink feed is past six thousand objects on its own, which
 * is the whole `MAX_SATELLITES` ceiling. A straight first-wins merge spent all of
 * it there and served a catalogue that was **100% Starlink** — no ISS, no GPS,
 * no weather satellites — having fetched and parsed every one of those groups
 * first. Nothing failed; the sky just quietly had one constellation in it.
 *
 * The fixture below is the shape of that in miniature: a fill group far larger
 * than the ceiling, and one small group that must survive it.
 */
describe("the ceiling reserves room for the small groups", () => {
  /**
   * A fill group larger than the whole ceiling, which is what the real
   * supplemental Starlink feed is. Built rather than pasted: the point is the
   * count, and 6,100 hand-written element sets is not a fixture anybody reads.
   */
  function oversizedStarlinkFeed(): string {
    const lines: string[] = [];
    for (let i = 0; i < MAX_SATELLITES + 100; i += 1) {
      // NORAD numbers are five columns, so this stays inside the real format.
      const norad = String(10000 + i).padStart(5, "0");
      lines.push(
        `STARLINK-${norad}`,
        `1 ${norad}U 19074A   26037.54166667  .00002182  00000-0  16538-3 0  9995`,
        `2 ${norad}  53.0538 156.7285 0001367  86.7419 273.3728 15.06391223 12345`,
      );
    }
    return lines.join("\n");
  }

  it("does not let the fill group crowd out the stations", async () => {
    starlinkOverride = oversizedStarlinkFeed();
    const app = appWith(celestrakEnv);
    after(() => app.close());

    const body = await get(app);
    assert.equal(body.satellites.length, MAX_SATELLITES, "the ceiling should be full");

    const groups = new Set(body.satellites.map((s) => s.group));
    assert.ok(groups.has("station"), "the ISS was crowded out of the catalogue by the fill group");
    assert.ok(groups.has("starlink"), "the fill group got no room at all");
  });

  it("spends what is left on the fill group rather than capping it early", async () => {
    starlinkOverride = oversizedStarlinkFeed();
    const app = appWith(celestrakEnv);
    after(() => app.close());

    const body = await get(app);
    const starlink = body.satellites.filter((s) => s.group === "starlink").length;
    const others = body.satellites.length - starlink;
    // One ISS in the fixture, so the fill group should take everything but it.
    assert.equal(others, 1);
    assert.equal(starlink, MAX_SATELLITES - others);
  });
});

describe("parsing element sets", () => {
  it("reads the three-line form CelesTrak serves", () => {
    const parsed = parseTle(ISS, "station");
    assert.equal(parsed.length, 1);
    assert.equal(parsed[0]?.noradId, 25544);
    assert.equal(parsed[0]?.name, "ISS (ZARYA)");
    assert.equal(parsed[0]?.group, "station");
  });

  it("carries the two lines through unmodified, because SGP4 reads columns", () => {
    const parsed = parseTle(ISS, "station");
    const lines = ISS.split("\n");
    assert.equal(parsed[0]?.line1, lines[1]);
    assert.equal(parsed[0]?.line2, lines[2]);
  });

  it("names an object by its catalogue number when the feed gave no name line", () => {
    const bare = ISS.split("\n").slice(1).join("\n");
    const parsed = parseTle(bare, "other");
    assert.equal(parsed.length, 1);
    assert.equal(parsed[0]?.name, "NORAD 25544");
  });

  it("strips the leading zero some feeds put on the name line", () => {
    const parsed = parseTle(`0 ISS (ZARYA)\n${ISS.split("\n").slice(1).join("\n")}`, "station");
    assert.equal(parsed[0]?.name, "ISS (ZARYA)");
  });

  it("skips what it cannot read instead of throwing", () => {
    const parsed = parseTle("nonsense\nmore nonsense\n\n", "other");
    assert.deepEqual(parsed, []);
  });

  it("reads every set in a multi-object body", () => {
    const parsed = parseTle(`${ISS}\n${STARLINK}`, "other");
    assert.equal(parsed.length, 2);
    assert.deepEqual(
      parsed.map((s) => s.noradId),
      [25544, 44713],
    );
  });
});
