/**
 * The licence gate on `TERA_ADSB_ENDPOINT`.
 *
 * This is the test file for the one exposure in this repo that is not a
 * feature. Before the gate, `TERA_ADSB_ENDPOINT` accepted any string; whatever
 * came back was served from `/api/v1/flights` with `Cache-Control: public` and
 * an `attribution` array that read "Aircraft positions from the adsb.lol
 * community feed" — hardcoded, next to the fetch, regardless of where the fetch
 * went. A deployment was therefore one environment variable away from
 * publishing somebody else's non-redistributable data, to a shared cache, under
 * an open-terms credit belonging to a volunteer feed that had never seen it.
 *
 * Two properties are asserted here more than any other, because they are the
 * two that were false:
 *
 *   1. **An allowlisted host is credited to itself.** Not to the default, not
 *      to the first entry in the table — to the host that actually answered.
 *   2. **A host that is not on the allowlist is refused**, loudly, rather than
 *      fetched and mis-credited. Refused means: no request, no live body, one
 *      sentence in `degraded[]` naming the host, and the simulated plan on the
 *      wire instead.
 *
 * Everything else in this file is a door on the same room: schemes, credentials,
 * query strings, loopback, and the `redistributable` bit that decides whether a
 * CDN is allowed to keep a copy.
 */

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 { createFlightsService } from "../flights/index.ts";
import {
  ADSB_ALLOWLIST,
  adsbAttribution,
  checkAdsbEndpoint,
  FIRST_PARTY_RECEIVER,
  isOpenAdsbUrl,
  mayRepublish,
  type AdsbFeedTerms,
} from "../flights/licence.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";

const realFetch = globalThis.fetch;
let calls: string[] = [];

/** One aircraft, from whatever host was asked. Shape is the shared dump1090 one. */
globalThis.fetch = (async (input: unknown) => {
  const url = String(input);
  calls.push(url);
  if (!/\/v2\/point\//.test(url)) return new Response("nope", { status: 404 });
  return new Response(
    JSON.stringify({
      now: 1_770_000_000_000,
      ac: [{ hex: "a1b2c3", flight: "LMB1 ", lat: 37.5, lon: -122.3, alt_baro: 10_000, track: 90 }],
    }),
    { status: 200, headers: { "content-type": "application/json" } },
  );
}) as unknown as typeof globalThis.fetch;

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

beforeEach(() => {
  calls = [];
});

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

/**
 * The URL an operator would write for this entry. Loopback speaks plain http,
 * and an IPv6 literal has to be bracketed before it is a URL at all — which is
 * exactly the kind of detail a table-driven test finds and a hand-written one
 * does not.
 */
function baseUrlFor(terms: AdsbFeedTerms): string {
  const host = terms.host.includes(":") ? `[${terms.host}]` : terms.host;
  return `${terms.loopback === true ? "http" : "https"}://${host}`;
}

/** The `degraded[]` lines this configuration produced, minus the ones about radius etc. */
function endpointLines(degraded: string[]): string[] {
  return degraded.filter((line) => line.includes("TERA_ADSB_ENDPOINT"));
}

describe("an allowlisted endpoint", () => {
  it("credits the host that actually answered, not the default", async () => {
    // The exact bug, in one assertion: point the box at airplanes.live and the
    // credit must say airplanes.live. It used to say adsb.lol.
    const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://api.airplanes.live" });
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.equal(config.flights.source, "adsb");
    assert.ok(body.mode === "live");
    const credits = (body.attribution ?? []).join(" ");
    assert.match(credits, /airplanes\.live/);
    assert.doesNotMatch(credits, /adsb\.lol/);
    assert.equal(calls[0], "https://api.airplanes.live/v2/point/37.7749/-122.4194/40");
  });

  it("credits every entry to itself, whichever one is configured", () => {
    // Generalises the assertion above over the whole table, so an entry added
    // later cannot inherit the previous one's credit by being copied.
    for (const terms of ADSB_ALLOWLIST) {
      const verdict = checkAdsbEndpoint(baseUrlFor(terms));
      assert.ok(verdict.ok, `${terms.host} should be allowed`);
      const credits = verdict.attribution.join(" ");
      assert.ok(credits.includes(terms.credit), `${terms.host} must be credited to itself`);
      for (const other of ADSB_ALLOWLIST) {
        if (other.credit === terms.credit) continue;
        assert.ok(
          !credits.includes(other.credit),
          `${terms.host} must not be credited to ${other.host}`,
        );
      }
    }
  });

  it("states the licence as well as the source, which is what ODbL asks for", () => {
    const verdict = checkAdsbEndpoint("https://api.adsb.lol");
    assert.ok(verdict.ok);
    assert.equal(verdict.terms.licence, "ODbL-1.0");
    assert.match(verdict.attribution.join(" "), /Open Database License/);
    assert.match(verdict.attribution.join(" "), /opendatacommons\.org/);
  });

  it("passes its own gate on the default configuration", () => {
    // A default that fails its own validation is a gate nobody can use.
    const { config } = appWith({});
    assert.equal(config.flights.source, "adsb");
    assert.equal(config.flights.endpoint, "https://api.adsb.lol");
    assert.deepEqual(endpointLines(config.degraded), []);
    assert.equal(config.flights.redistributable, true);
  });

  it("normalises the endpoint so a trailing slash is not a second URL", () => {
    const verdict = checkAdsbEndpoint("https://api.adsb.lol///");
    assert.ok(verdict.ok);
    assert.equal(verdict.endpoint, "https://api.adsb.lol");
  });
});

describe("an endpoint that is not on the allowlist", () => {
  it("is refused rather than fetched and mis-credited", async () => {
    const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" });
    after(() => app.close());

    // Demoted, not fatal: CONTRACT.md §5.1. The map still works.
    assert.equal(config.flights.source, "sim");

    const lines = endpointLines(config.degraded);
    assert.equal(lines.length, 1, "exactly one sentence about the endpoint");
    assert.match(lines[0] ?? "", /flights\.example\.com/, "the line names the host");

    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.equal(body.mode, "plan");
    assert.deepEqual(calls, [], "a refused endpoint is never fetched");
    // The whole point: nobody is credited for data nobody served.
    assert.equal(JSON.stringify(body).includes("adsb.lol"), false);
  });

  it("does not leave the refused URL anywhere later code could use it", () => {
    const { config } = appWith({ TERA_ADSB_ENDPOINT: "https://flights.example.com" });
    assert.equal(config.flights.endpoint, "");
    assert.deepEqual(config.flights.attribution, []);
    assert.equal(config.flights.redistributable, false);
    assert.equal(config.flights.licence, null);
  });

  it("refuses plain http off-box, where the path chooses what we publish", () => {
    const verdict = checkAdsbEndpoint("http://api.adsb.lol");
    assert.ok(!verdict.ok);
    assert.match(verdict.reason, /https/);
  });

  it("refuses a URL carrying credentials, because open feeds are keyless", () => {
    const verdict = checkAdsbEndpoint("https://user:secret@api.adsb.lol");
    assert.ok(!verdict.ok);
    assert.match(verdict.reason, /credential/);
  });

  it("refuses a query string rather than pasting it into the middle of a path", () => {
    const verdict = checkAdsbEndpoint("https://api.adsb.lol?key=hunter2");
    assert.ok(!verdict.ok);
    assert.match(verdict.reason, /query string/);
  });

  it("refuses something that is not a URL at all", () => {
    assert.ok(!checkAdsbEndpoint("api.adsb.lol").ok);
    assert.ok(!checkAdsbEndpoint("").ok);
  });

  it("refuses a host that merely contains an allowlisted name", () => {
    // The attack the allowlist has to survive: an exact-match table, never a
    // substring test.
    for (const host of ["api.adsb.lol.example.com", "notapi.adsb.lol", "adsb.lol.evil.test"]) {
      const verdict = checkAdsbEndpoint(`https://${host}`);
      assert.ok(!verdict.ok, `${host} must not pass`);
    }
  });

  it("points a near miss at the entry it was probably reaching for", () => {
    // `adsb.lol` serves a website; `api.adsb.lol` serves the API. An operator
    // who types the first one deserves better than a flat refusal.
    const verdict = checkAdsbEndpoint("https://adsb.lol");
    assert.ok(!verdict.ok);
    assert.match(verdict.reason, /did you mean https:\/\/api\.adsb\.lol/);
  });
});

describe("a receiver of one's own", () => {
  it("takes loopback over plain http and credits the operator, not a feed", async () => {
    const { config, app } = appWith({ TERA_ADSB_ENDPOINT: "http://127.0.0.1:8080" });
    after(() => app.close());

    assert.equal(config.flights.source, "adsb");
    assert.equal(config.flights.licence, "first-party");
    const body = (await app.inject({ method: "GET", url: "/api/v1/flights" })).json<FlightsBody>();
    assert.ok(body.mode === "live");
    assert.match((body.attribution ?? []).join(" "), /own ADS-B receiver/);
    assert.equal(calls[0], "http://127.0.0.1:8080/v2/point/37.7749/-122.4194/40");
  });

  it("says out loud that only the operator can vouch for a loopback claim", () => {
    const { config } = appWith({ TERA_ADSB_ENDPOINT: "http://localhost:8080" });
    const lines = endpointLines(config.degraded);
    assert.equal(lines.length, 1);
    assert.match(lines[0] ?? "", /loopback/);
  });

  it("credits the dump1090 file source from the same table", () => {
    // Same data, different door. If these two ever disagree about who to
    // credit, one of them is lying.
    const config = loadConfig({
      TERA_FLIGHTS_SOURCE: "dump1090",
      TERA_DUMP1090_PATH: "/tmp/aircraft.json",
    });
    assert.equal(config.flights.source, "dump1090");
    assert.deepEqual(config.flights.attribution, adsbAttribution(FIRST_PARTY_RECEIVER));
    assert.equal(config.flights.licence, "first-party");
  });
});

describe("what a shared cache is allowed to keep", () => {
  it("lets a CDN hold a redistributable live body", async () => {
    const { app } = appWith({});
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/flights" });
    assert.match(res.headers["cache-control"] as string, /^public, max-age=\d+$/);
  });

  it("keeps a non-redistributable body private, which is the fail-closed default", () => {
    // No entry in today's table is non-redistributable, and the flag exists so
    // that adding one — a feed we may use but not re-serve — needs no change to
    // the route. This is the branch that would then run.
    const body: FlightsBody = {
      mode: "live",
      source: "adsb",
      observedAt: 0,
      aircraft: [],
      ttlSeconds: 5,
      redistributable: false,
    };
    assert.equal(mayRepublish(body), false);
    assert.equal(mayRepublish({ ...body, redistributable: true }), true);
  });

  it("always lets the simulated plan be shared, because it is ours", () => {
    const plan: FlightsBody = {
      mode: "plan",
      source: "sim",
      t0: 0,
      seed: 1,
      routes: [],
      ttlSeconds: 300,
    };
    assert.equal(mayRepublish(plan), true);
  });
});

describe("the second lock, on the door itself", () => {
  it("will not fetch a non-allowlisted endpoint even if one reaches the service", async () => {
    // `config.ts` cannot produce this. A future caller building the endpoint
    // from somewhere else could, and this is what happens when it does: no
    // request, and the plan on the wire.
    const config = loadConfig({ TERA_FLIGHTS_SOURCE: "adsb" });
    config.flights.endpoint = "https://flights.example.com";
    const service = createFlightsService(config, { warn: () => {} });

    const body = await service.current(config.regions[0]);
    assert.equal(body.mode, "plan");
    assert.deepEqual(calls, []);
  });

  it("knows an open feed's URL from any other", () => {
    assert.equal(isOpenAdsbUrl("https://api.adsb.lol/v2/point/1/2/40"), true);
    assert.equal(isOpenAdsbUrl("https://flights.example.com/v2/point/1/2/40"), false);
    assert.equal(isOpenAdsbUrl("not a url"), false);
  });
});

describe("the table itself", () => {
  it("says where every claim it makes can be checked", () => {
    for (const terms of ADSB_ALLOWLIST) {
      assert.equal(terms.host, terms.host.toLowerCase(), `${terms.host} must be lowercase`);
      assert.notEqual(terms.credit, "", `${terms.host} needs a credit line`);
      assert.notEqual(terms.terms, "", `${terms.host} needs terms a human can read`);
      assert.ok(
        terms.licence === "ODbL-1.0" || terms.licence === "first-party",
        `${terms.host} names a licence`,
      );
      // Anything off-box must be https-only; only a loopback entry may relax it.
      if (terms.loopback !== true) {
        assert.ok(!checkAdsbEndpoint(`http://${terms.host}`).ok, `${terms.host} must be https`);
      }
    }
  });

  it("carries no feed whose terms forbid what this box does with it", () => {
    // A `redistributable: false` entry is legitimate, but it must never be
    // reachable while the route is still handing bodies to a shared cache. That
    // is `mayRepublish`'s job, and this asserts the two stay wired together.
    for (const terms of ADSB_ALLOWLIST) {
      const verdict = checkAdsbEndpoint(baseUrlFor(terms));
      assert.ok(verdict.ok);
      assert.equal(
        mayRepublish({
          mode: "live",
          source: "adsb",
          observedAt: 0,
          aircraft: [],
          ttlSeconds: 5,
          redistributable: verdict.terms.redistributable,
        }),
        terms.redistributable,
      );
    }
  });
});
