/**
 * Which ADS-B feeds this box is allowed to republish, and who gets the credit.
 *
 * This is the one module in the repo that exists because of a **live real-world
 * exposure** rather than a feature. `TERA_ADSB_ENDPOINT` used to be a free-form
 * string: whatever it pointed at, the aircraft that came back were served from
 * `/api/v1/flights` with `Cache-Control: public` and an `attribution` array that
 * said, unconditionally, *adsb.lol*. Three things were therefore true at once on
 * any deployment one environment variable away from the default:
 *
 *   1. this box would fetch a feed nobody had checked the terms of,
 *   2. it would hand the bytes to a shared cache to hand to everyone else, and
 *   3. it would credit a community feed that had never seen them.
 *
 * (1) is an operator's business. (2) is *Publicly Using a Derivative Database*
 * and is the trigger CONTRACT.md §8 is about — the same reasoning that made the
 * geocoder a US Census one. (3) is worse than either: an open-terms credit
 * attached to data that did not come from the open lane is a false licence
 * statement, and it is false in the direction that invites a downstream
 * consumer to redistribute something they may not.
 *
 * So the endpoint is checked against a table, and **everything the wire says
 * about the source is derived from the host that actually answered**. There is
 * no path here that lets a credit line and a hostname disagree, because the
 * credit is computed from the hostname. An endpoint that is not in the table is
 * refused — `config.ts` demotes the source to the simulated plan and writes one
 * sentence into `degraded[]` naming the host — and the demotion is deliberately
 * not fatal, for the same reason nothing else in `config.ts` is: CONTRACT.md
 * §5.1 says a misconfigured source is demoted, not a boot failure. The operator
 * gets a working map, a plan-mode sky and a line on `/api/v1/health` telling
 * them exactly which variable to fix.
 *
 * ### Adding a feed
 *
 * Read the feed's published terms. Write down, in the entry: the licence, a URL
 * where the next person can read the same terms, the credit line that feed asks
 * for, and whether those terms let this box re-serve the bytes to third parties.
 * If you cannot answer all four, the entry does not go in — a guess here is the
 * failure this module exists to prevent. `adsb.fi` and a handful of other
 * community mirrors serve the same `/v2/point` shape and are plausible next
 * entries; they are absent because nobody on this side has read their terms,
 * which is the honest reason and the only one that should ever appear here.
 *
 * ARCHITECTURE.md §4, NOTICE's AIRCRAFT DATA block.
 */

import type { FlightsBody } from "../../../src/server/wire.ts";

/**
 * The licence a feed's data arrives under.
 *
 * `first-party` is not a licence at all and says so: it is the operator's own
 * antenna, and there is nobody to comply with. See `LOOPBACK_CAVEAT` for why
 * that claim is only ever as good as the operator making it.
 */
export type AdsbLicenceId = "ODbL-1.0" | "first-party";

export interface AdsbFeedTerms {
  /** Lowercased hostname, matched exactly against the configured endpoint's. */
  host: string;
  /** Named in the credit line. This is who the bytes came from, in prose. */
  credit: string;
  licence: AdsbLicenceId;
  /** Where a human reads the terms this entry claims. Never a guess. */
  terms: string;
  /**
   * May this box re-serve the data to third parties?
   *
   * Gates `publicCache` on the flights route — see `mayRepublish`. A feed that
   * is free to *use* and not free to *redistribute* is a real category (several
   * aviation feeds are exactly that), and the whole point of carrying the flag
   * rather than assuming it is that such an entry can be added later without
   * anybody having to remember to also change the route.
   */
  redistributable: boolean;
  /**
   * Plain HTTP is acceptable for this host.
   *
   * True only for loopback: a `readsb` on the same box has no certificate and
   * needs none, and forcing TLS there would push self-hosters towards a public
   * feed for no security gain. Everything reachable off-box must be https —
   * without it, whoever is between us and the feed chooses what this box
   * publishes under an open-terms credit.
   */
  loopback?: boolean;
  /** One sentence for `degraded[]` when this entry is the configured one. */
  caveat?: string;
}

/**
 * A receiver on the same box. First-party by construction — and by trust.
 *
 * Shared with the `dump1090` file source, which is the same data arriving by a
 * different door, so the two cannot drift apart in what they claim.
 */
export const FIRST_PARTY_RECEIVER: AdsbFeedTerms = {
  host: "localhost",
  credit: "this deployment's own ADS-B receiver",
  licence: "first-party",
  terms: "no licence: data received by the operator's own antenna",
  redistributable: true,
  loopback: true,
  caveat:
    "TERA_ADSB_ENDPOINT points at a loopback address, so aircraft are credited to this " +
    "deployment's own receiver. That credit is derived from the host and only you can vouch " +
    "for it: if the process on that port is a proxy for somebody else's feed, this box is " +
    "publishing their data under your name.",
};

/**
 * The feeds this repo will point at, with the terms it publishes about them.
 *
 * Two hosted community feeds and the loopback family. Both hosted entries serve
 * volunteer-fed ADS-B under the Open Database Licence, both are keyless, and
 * both answer the same `/v2/point/:lat/:lng/:radiusNm` shape `adsb.ts` parses —
 * which is not a coincidence, they are the same lineage of software.
 *
 * The apex domains are absent on purpose: `adsb.lol` serves a website and
 * `api.adsb.lol` serves the API, and an operator who types the former gets a
 * near-miss hint out of `checkAdsbEndpoint` rather than a 404 loop.
 */
export const ADSB_ALLOWLIST: readonly AdsbFeedTerms[] = [
  {
    host: "api.adsb.lol",
    credit: "the adsb.lol community feed",
    licence: "ODbL-1.0",
    terms: "https://adsb.lol/legal-and-license/",
    redistributable: true,
  },
  {
    host: "api.airplanes.live",
    credit: "the airplanes.live community feed",
    licence: "ODbL-1.0",
    terms: "https://airplanes.live/",
    redistributable: true,
    caveat:
      "TERA_ADSB_ENDPOINT=airplanes.live: their feed is volunteer-funded and asks callers to " +
      "stay inside a request-per-second ceiling, which flights/index.ts holds structurally. " +
      "The ODbL credit is attached to every body automatically.",
  },
  FIRST_PARTY_RECEIVER,
  { ...FIRST_PARTY_RECEIVER, host: "127.0.0.1" },
  { ...FIRST_PARTY_RECEIVER, host: "::1" },
];

export type AdsbEndpointVerdict =
  | {
      ok: true;
      /** The endpoint, normalised: origin plus path, no trailing slash. */
      endpoint: string;
      terms: AdsbFeedTerms;
      /** What the wire must say about this source. Derived, never authored. */
      attribution: string[];
      /** One sentence for `degraded[]`, or `null` when there is nothing to say. */
      caveat: string | null;
    }
  | { ok: false; reason: string };

/**
 * Is this endpoint one this box may fetch, republish and credit?
 *
 * Every refusal `reason` is a fragment that reads correctly after
 * `TERA_ADSB_ENDPOINT="…"` — `config.ts` builds the sentence, this builds the
 * clause, and the host is always named so the operator can see which part of
 * their URL was the problem.
 */
export function checkAdsbEndpoint(raw: string): AdsbEndpointVerdict {
  const trimmed = raw.trim();
  if (trimmed === "") return { ok: false, reason: "is empty" };

  let url: URL;
  try {
    url = new URL(trimmed);
  } catch {
    return { ok: false, reason: "is not a URL" };
  }

  // A credential is not a syntax problem, it is a category one: every feed in
  // the table is keyless, so a URL carrying a secret is by construction not one
  // of them — it is somebody's paid account, and paid accounts are exactly the
  // terms that do not permit republication.
  if (url.username !== "" || url.password !== "") {
    return {
      ok: false,
      reason:
        "carries credentials, and every openly-licensed feed here is keyless — a URL with a " +
        "secret in it is an account, and an account's data is not ours to re-serve",
    };
  }

  // `adsb.ts` appends `/v2/point/…` to this string. A query or a fragment
  // would end up in the middle of the path, so it is refused rather than
  // silently dropped: an operator who put an API key in `?key=` needs to see
  // that it was neither used nor honoured.
  if (url.search !== "" || url.hash !== "") {
    return {
      ok: false,
      reason:
        "carries a query string or fragment; this is a base URL that /v2/point/:lat/:lng/:nm " +
        "is appended to, so anything after it would land in the middle of the path",
    };
  }

  // `URL.hostname` keeps the brackets on an IPv6 literal — `http://[::1]/`
  // parses to a hostname of `[::1]` — so they come off before the table is
  // consulted, and the table stores the address the way a person writes it.
  const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
  const terms = ADSB_ALLOWLIST.find((entry) => entry.host === host);
  if (terms === undefined) {
    return { ok: false, reason: `points at ${host}, which ${notOnTheList(host)}` };
  }

  if (url.protocol !== "https:" && !(terms.loopback === true && url.protocol === "http:")) {
    return {
      ok: false,
      reason:
        `reaches ${host} over ${url.protocol.replace(":", "")}, and only https will do off-box: ` +
        "without it, whoever is on the path chooses what this deployment publishes under an " +
        "open-terms credit",
    };
  }

  return {
    ok: true,
    endpoint: `${url.origin}${url.pathname.replace(/\/+$/, "")}`,
    terms,
    attribution: adsbAttribution(terms),
    caveat: terms.caveat ?? null,
  };
}

/**
 * The credit lines for a feed, built from the entry rather than written twice.
 *
 * ODbL §4.3 wants the notice to name the source *and* point at the licence, so
 * an ODbL feed gets two lines and a first-party receiver gets one. The browser
 * displays whatever it is sent, in order, which is why the source line is first.
 */
export function adsbAttribution(terms: AdsbFeedTerms): string[] {
  const lines = [`Aircraft positions from ${terms.credit}`];
  if (terms.licence === "ODbL-1.0") {
    lines.push(
      "Made available under the Open Database License (ODbL) v1.0 — " +
        "https://opendatacommons.org/licenses/odbl/1-0/",
    );
  }
  return lines;
}

/**
 * The last line of defence, at the point of the request.
 *
 * `config.ts` has already validated the endpoint by the time anything calls
 * `fetchAdsb`, so this should never fire. It is here because "should never" is
 * a property of today's call graph and this module is about the case where that
 * property quietly stops holding — a future caller that builds a URL from
 * somewhere else pays one `new URL()` per poll to find out it was wrong,
 * instead of the public getting a credited copy of a feed nobody checked.
 */
export function isOpenAdsbUrl(url: string): boolean {
  try {
    const host = new URL(url).hostname.toLowerCase();
    return ADSB_ALLOWLIST.some((entry) => entry.host === host);
  } catch {
    return false;
  }
}

/**
 * May a shared cache keep this body and hand it to the next caller?
 *
 * The plan is this project's own arithmetic under Apache-2.0, so it always may.
 * A live body may only if the entry the positions came from said so. The route
 * asks this instead of asking the config, because the thing being cached is the
 * body, and a body that outlives a config change is precisely the bug a shared
 * cache produces.
 */
export function mayRepublish(body: FlightsBody): boolean {
  return body.mode === "plan" ? true : body.redistributable;
}

/**
 * "…is not on the open-feed allowlist", plus a nudge when the host looks like
 * somebody reaching for one of the entries and missing by a subdomain.
 */
function notOnTheList(host: string): string {
  const near = ADSB_ALLOWLIST.find(
    (entry) => entry.host.endsWith(`.${host}`) || host.endsWith(`.${entry.host}`),
  );
  const hosts = ADSB_ALLOWLIST.map((entry) => entry.host).join(", ");
  const hint = near === undefined ? "" : ` (did you mean https://${near.host}?)`;
  return (
    `is not one of the openly-licensed feeds this build will republish — ${hosts}${hint}. ` +
    "The endpoint is not fetched and its data is not credited to anyone"
  );
}
