/**
 * Which sky this box serves, and for which city.
 *
 * The simulated source answers from a plan and never touches the network, so it
 * is free and it is the default. The two real sources are polled on a timer and
 * cached, with the same rule the weather service follows: a feed that stops
 * answering serves its last snapshot, and a feed that has never answered falls
 * back to the plan rather than to an empty sky. An operator who turned on ADS-B
 * and got a blank map would reasonably conclude the renderer was broken.
 *
 * Everything here is **per region**. `TERA_ADSB_RADIUS_NM` around one origin
 * could only ever describe one city, and the Bay Area wants SFO, OAK and SJC
 * while the Southland wants LAX, BUR, LGB and SNA — six hundred kilometres of
 * empty coastline apart, with no sane radius that covers both. The requested
 * region supplies the centre; the radius stays what the operator configured.
 *
 * ### Whose data this is
 *
 * Nothing in this file decides who gets the credit. `TERA_ADSB_ENDPOINT` is
 * checked against an allowlist of openly-licensed feeds in `flights/licence.ts`
 * before the process finishes booting, and the credit lines, the licence id and
 * the one bit that says whether a shared cache may keep the body all come out
 * of the entry that matched. An endpoint that is not on the list never reaches
 * this file at all — `config.ts` has already demoted the source to `sim` and
 * said so on `/api/v1/health`. The version of this file that hardcoded
 * `adsb.lol` into the attribution regardless of the endpoint is why that gate
 * exists.
 *
 * ### Staying inside adsb.lol's limits
 *
 * adsb.lol asks for **no more than one request per second** from a client and
 * says outright that it will rate-limit callers who ignore it; airplanes.live,
 * which serves the same shape at `TERA_ADSB_ENDPOINT`, publishes the same
 * ceiling. Both are volunteer-fed community feeds paid for by people who did not
 * sign up to host somebody's map.
 *
 * This stays inside the limit structurally rather than by hoping:
 *
 * - One poll per region per `liveTtl`, never per request. Concurrent misses on a
 *   region collapse into one upstream call (`upstream.ts`).
 * - `liveTtl` is **floored at 5 seconds** as well as capped at 15. The floor is
 *   the load-bearing half and it is new: `TERA_FLIGHTS_TTL=0` used to mean one
 *   upstream request per inbound request, which is exactly the flood the limit
 *   exists to stop, delivered by a config value that reads like "as fresh as
 *   possible".
 * - The region set is a fixed allowlist from the environment, so the worst case
 *   is arithmetic rather than a guess: **regions ÷ 5 requests per second**, with
 *   every region under continuous load. With the two this repo ships that is
 *   0.4/s against a ceiling of 1/s. An operator who configures more than five
 *   regions and keeps them all warm is the case to watch, and writing the number
 *   down here is how they find that out before adsb.lol does.
 * - A box nobody is looking at polls nothing at all.
 */

import type { Config } from "../config.ts";
import type { Region } from "../regions.ts";
import type { FlightsBody } from "../../../src/server/wire.ts";
import { createUpstream } from "../upstream.ts";
import { fetchAdsb, readDump1090, type FlightsSnapshot } from "./adsb.ts";
import { planFor } from "./plan.ts";

export interface FlightsService {
  current(region: Region): Promise<FlightsBody>;
}

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

/**
 * How long a live snapshot may be cached. Aircraft move; the plan does not, so
 * only the live path is clamped. See the rate-limit note above for the floor.
 */
const LIVE_MAX_TTL_SECONDS = 15;
const LIVE_MIN_TTL_SECONDS = 5;

/**
 * The one cache key every `dump1090` region shares.
 *
 * A receiver has one antenna. Asking a Bay Area rooftop for the Southland gets
 * the Bay Area sky whatever the query said, so keying this per region would
 * multiply the file reads without changing a byte of the answer. The honest
 * thing is one key and a snapshot whose aircraft carry their own coordinates —
 * the renderer puts them where they actually are.
 */
const RECEIVER_KEY = "receiver";

export function createFlightsService(config: Config, log: FlightsLog): FlightsService {
  const { source, endpoint, radiusNm, contact, dump1090Path, epochMs, seed, ttlSeconds } =
    config.flights;
  // Both derived in `config.ts` from the host that will actually answer, by
  // `flights/licence.ts`. Read once here so that no code path in this file can
  // construct a live body with a credit line it made up.
  const { attribution, redistributable, licence } = config.flights;

  // Built once per region and kept: the plan is a pure function of the centre,
  // and it is handed out on every cacheable request.
  const plans = new Map<string, FlightsBody>();
  const plan = (region: Region): FlightsBody => {
    const existing = plans.get(region.id);
    if (existing !== undefined) return existing;
    const body: FlightsBody = {
      mode: "plan",
      source: "sim",
      t0: epochMs,
      seed,
      routes: planFor(region),
      ttlSeconds,
    };
    plans.set(region.id, body);
    return body;
  };

  const liveTtl = Math.min(LIVE_MAX_TTL_SECONDS, Math.max(LIVE_MIN_TTL_SECONDS, ttlSeconds));
  const upstream = createUpstream<FlightsSnapshot>({
    label: `flights:${source}`,
    ttlSeconds: liveTtl,
    log,
  });

  return {
    async current(region: Region): Promise<FlightsBody> {
      if (source === "sim") return plan(region);

      const key = source === "dump1090" ? RECEIVER_KEY : region.id;
      const snapshot = await upstream.get(key, () =>
        source === "dump1090"
          ? readDump1090(dump1090Path, log)
          : fetchAdsb(endpoint, region, radiusNm, contact, log),
      );
      if (snapshot === null) return plan(region);

      return {
        mode: "live",
        source,
        observedAt: snapshot.observedAt,
        aircraft: snapshot.aircraft,
        ttlSeconds: liveTtl,
        redistributable,
        ...(attribution.length > 0 ? { attribution } : {}),
        ...(licence === null ? {} : { licence }),
      };
    },
  };
}
