/**
 * Live traffic, and the budget that keeps it welcome.
 *
 * adsb.lol is a volunteer-fed community feed that asks for no more than one
 * request per second and will rate-limit a caller who ignores it. Most of this
 * file is therefore about **how often this server is capable of calling out**,
 * not about what comes back: one poll per region per TTL, a TTL with a floor
 * under it, and a refused query that costs the upstream nothing. The arithmetic
 * those tests pin down is written out in `flights/index.ts`.
 *
 * The one that is not about rate is the URL assertion. A caller's coordinate
 * must select a configured region and must never itself be fetched.
 */

import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";

const realFetch = globalThis.fetch;
let calls: string[] = [];
let feedIsUp = true;
/** A body to serve instead of the usual one, for the wrong-shape tests. */
let feedOverride: unknown = undefined;

/** One aircraft, whose id encodes the point that was asked about. */
function feed(url: string): unknown | undefined {
  if (!feedIsUp) return undefined;
  const point = /\/v2\/point\/(-?[\d.]+)\/(-?[\d.]+)\/(\d+)$/.exec(url);
  if (point === null) return undefined;
  if (feedOverride !== undefined) return feedOverride;
  return {
    now: 1_770_000_000_000,
    ac: [{ hex: `a${point[1]}`, flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }],
  };
}

globalThis.fetch = (async (input: unknown) => {
  const url = String(input);
  calls.push(url);
  const body = feed(url);
  if (body === undefined) return new Response("nope", { status: 503 });
  return new Response(JSON.stringify(body), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
}) as unknown as typeof globalThis.fetch;

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

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

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

const adsbEnv = { TERA_FLIGHTS_SOURCE: "adsb" };

describe("the ADS-B source", () => {
  it("polls each city's own centre, and never the caller's coordinate", async () => {
    const app = appWith(adsbEnv);
    after(() => app.close());

    // Pasadena, which is inside the Southland region and is not a point this
    // deployment serves.
    await app.inject({ method: "GET", url: "/api/v1/flights?lat=34.1478&lng=-118.1445" });
    await app.inject({ method: "GET", url: "/api/v1/flights?city=sf" });

    assert.deepEqual(calls, [
      "https://api.adsb.lol/v2/point/33.8200/-118.0500/40",
      "https://api.adsb.lol/v2/point/37.7749/-122.4194/40",
    ]);
  });

  it("clamps a radius the feeds would reject, and says so", async () => {
    const config = loadConfig({ ...adsbEnv, TERA_ADSB_RADIUS_NM: "2500" });
    config.logLevel = "silent";
    const app = buildApp(config);
    after(() => app.close());

    assert.equal(config.flights.radiusNm, 250);
    assert.match(config.degraded[0] ?? "", /TERA_ADSB_RADIUS_NM/);

    await app.inject({ method: "GET", url: "/api/v1/flights" });
    assert.equal(calls[0], "https://api.adsb.lol/v2/point/37.7749/-122.4194/250");
  });

  it("polls once per region per TTL, whatever the request rate is", async () => {
    const app = appWith(adsbEnv);
    after(() => app.close());

    const ask = (city: string) =>
      app.inject({ method: "GET", url: `/api/v1/flights?city=${city}` });
    await Promise.all([ask("sf"), ask("sf"), ask("socal"), ask("sf"), ask("socal")]);
    for (let i = 0; i < 6; i++) await ask("sf");

    // Two regions, eleven requests, two upstream calls.
    assert.equal(calls.length, 2);
  });

  it("floors the poll interval so TERA_FLIGHTS_TTL=0 is not a flood", async () => {
    // This is the bug the floor exists for: a zero TTL used to mean one
    // outbound request per inbound request, straight through the rate limit,
    // from a setting that reads like "as fresh as possible".
    const app = appWith({ ...adsbEnv, TERA_FLIGHTS_TTL: "0" });
    after(() => app.close());

    let body: FlightsBody | null = null;
    for (let i = 0; i < 8; i++) {
      body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    }
    assert.equal(calls.length, 1);
    assert.ok(body !== null && body.mode === "live" && body.ttlSeconds === 5);
  });

  it("caps the poll interval too, because aircraft move", async () => {
    const app = appWith({ ...adsbEnv, TERA_FLIGHTS_TTL: "3600" });
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.ok(body.mode === "live" && body.ttlSeconds === 15);
  });

  it("costs the upstream nothing when the query is refused", async () => {
    const app = appWith(adsbEnv);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/flights?city=atlantis" });
    assert.equal(res.statusCode, 400);
    assert.deepEqual(calls, []);
  });

  it("falls back to the simulated plan rather than to an empty sky", async () => {
    feedIsUp = false;
    const app = appWith(adsbEnv);
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.equal(body.mode, "plan");
    assert.ok(body.mode === "plan" && body.routes.length > 0);
  });

  it("credits the feed it took the positions from", async () => {
    const app = appWith(adsbEnv);
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.ok(body.mode === "live");
    assert.match(body.attribution?.[0] ?? "", /adsb\.lol/);
  });
});

/**
 * The failure that turns a rate limit inside out.
 *
 * A feed that goes *down* is handled everywhere. A feed that stays up and
 * answers 200 with a field of the wrong type used to throw out of `normalise`,
 * past `upstream.ts` — which then never stamped its clock — and out to the
 * route as a 500. The TTL is the only rate limit on outbound calls, so losing it
 * meant one request to adsb.lol per inbound request, from the operator's
 * address, for as long as the feed stayed broken.
 */
describe("a feed that changed shape", () => {
  it("answers with the plan instead of a 500", async () => {
    // Valid JSON, wrong shape: `ac` is a number where an array belongs.
    feedOverride = { ac: 5, now: 1 };
    const app = appWith(adsbEnv);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/flights" });
    assert.equal(res.statusCode, 200);
    assert.equal(res.json<FlightsBody>().mode, "plan");
  });

  it("still polls once per TTL, which is the whole of the rate limit", async () => {
    feedOverride = { ac: 5, now: 1 };
    const app = appWith(adsbEnv);
    after(() => app.close());

    for (let i = 0; i < 5; i++) await app.inject({ method: "GET", url: "/api/v1/flights" });
    assert.equal(calls.length, 1);
  });

  it("survives rows that are not objects", async () => {
    feedOverride = { ac: [null, 7, "LMB1", { hex: "ok", lat: 37.5, lon: -122.3 }], now: 1 };
    const app = appWith(adsbEnv);
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.ok(body.mode === "live" && body.aircraft.length === 1);
  });

  it("caps how much of somebody else's sky it will hold and serve", async () => {
    feedOverride = {
      now: 1,
      ac: Array.from({ length: 6000 }, (_, i) => ({
        hex: `x${i}`,
        lat: 37.5,
        lon: -122.3,
        alt_baro: 1000,
      })),
    };
    const app = appWith(adsbEnv);
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.ok(body.mode === "live" && body.aircraft.length === 5000);
  });
});

describe("a local receiver", () => {
  let path = "";

  before(async () => {
    const dir = await mkdtemp(join(tmpdir(), "tera-flights-"));
    path = join(dir, "aircraft.json");
    await writeFile(
      path,
      JSON.stringify({
        now: 1_770_000_000,
        aircraft: [{ hex: "abc123", flight: "LMB9 ", lat: 37.6, lon: -122.4, alt_baro: 3000 }],
      }),
    );
  });

  it("has one antenna, so every region reads the same snapshot once", async () => {
    const app = appWith({ TERA_FLIGHTS_SOURCE: "dump1090", TERA_DUMP1090_PATH: path });
    after(() => app.close());

    const sf = (
      await app.inject({ method: "GET", url: "/api/v1/flights?city=sf" })
    ).json<FlightsBody>();
    assert.ok(sf.mode === "live" && sf.aircraft.length === 1);

    // Take the file away, then ask for the other city inside the TTL. A live
    // answer proves the two regions share one cache entry — a per-region key
    // would have gone back to disk here and found nothing.
    await rm(path);
    const socal = (
      await app.inject({ method: "GET", url: "/api/v1/flights?city=socal" })
    ).json<FlightsBody>();
    assert.ok(socal.mode === "live");
    assert.deepEqual(socal.aircraft, sf.aircraft);
  });
});
