/**
 * A per-key TTL cache in front of something that is allowed to fail.
 *
 * Weather and flights had the same fifteen lines each, and once both of them
 * became *per region* they would have had the same fifteen lines with the same
 * `Map` bolted on. Three rules, and they are the three the weather service
 * already stated:
 *
 * 1. **Nothing is fetched until somebody asks.** A box nobody visits makes no
 *    outbound requests at all, which is what keeps a public-good API's fair-use
 *    policy satisfiable by a deployment that is mostly idle.
 * 2. **A dead upstream serves the last good answer**, per key, and only reports
 *    nothing when it has never once answered for that key. Ten-minute-old
 *    weather beats no weather by a mile and beats a 503 by more.
 * 3. **A failed fetch stamps the clock too**, and a fetch that *throws* is a
 *    fetch that failed. A source that is down — or that has started answering
 *    in a shape this build cannot read — is retried on the same cadence as one
 *    that is up, not on every request, which is the bug that turns somebody
 *    else's outage into your outbound flood. See `refresh`.
 *
 * Concurrent misses on one key collapse into one upstream request. Misses on
 * *different* keys do not, deliberately: they are different places and the
 * caller's rate budget is worked out per key in `flights/index.ts`.
 *
 * ### On the size of the map
 *
 * Keys are region ids, and the region set comes from the environment
 * (`regions.ts`), so the number of entries is bounded by the operator's config
 * and cannot be grown by anybody sending requests. That invariant lives in the
 * routes, which resolve a query to a configured region *before* anything here is
 * touched; this module trusts it and does not re-check it. Key this on anything
 * a caller can choose and it becomes an unbounded map.
 */

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

export interface Upstream<T> {
  /**
   * The freshest value for `key`, or `null` if nothing has ever answered for it.
   *
   * `fetchFresh` is passed per call rather than at construction so the caller
   * can close over the region it just resolved instead of keeping a second
   * lookup table. It must be the same function of the same key every time, which
   * is trivially true for both callers here — the key *is* the region.
   */
  get(key: string, fetchFresh: () => Promise<T | null>): Promise<T | null>;
}

interface Entry<T> {
  value: T | null;
  /** Epoch ms of the last completed attempt, successful or not. */
  fetchedAt: number;
  inFlight: Promise<void> | null;
}

export interface UpstreamOptions {
  /** Prefix for the one warning line this ever logs, e.g. `weather:nws`. */
  label: string;
  ttlSeconds: number;
  log: UpstreamLog;
}

export function createUpstream<T>(opts: UpstreamOptions): Upstream<T> {
  const ttlMs = Math.max(0, opts.ttlSeconds) * 1000;
  const entries = new Map<string, Entry<T>>();

  /**
   * One attempt, and it cannot fail in a way the caller has to know about.
   *
   * The `try` is rule 3, and the `finally` is the whole of it. `fetchFresh` is
   * supposed to return `null` for every failure — `http.ts` does exactly that —
   * but "supposed to" is not a guarantee, and one upstream that answers 200
   * with a field of the wrong type is enough: `adsb.ts` iterating a non-array
   * `ac`, or `nws.ts` iterating a `cloudLayers` that came back as a number,
   * throws out of here. Before this, that throw skipped the clock stamp *and*
   * propagated to the route, so the TTL — the only rate limit on outbound calls
   * — collapsed to one upstream request per inbound request and the caller got
   * a 500. Five requests to `/api/v1/flights` produced five fetches at
   * adsb.lol, from the operator's address, and five 500s; that is the flood
   * rule 3 exists to prevent, delivered by the failure mode it exists for.
   *
   * So a throw is made indistinguishable from the `null` it should have been:
   * clock stamped, last good value kept, route answers 200 with whatever is in
   * hand. The stack goes in the log line, because a source whose *shape*
   * changed is a different problem from a source that is down and the operator
   * needs to be able to tell them apart.
   */
  async function refresh(key: string, entry: Entry<T>, fetchFresh: () => Promise<T | null>) {
    let fresh: T | null = null;
    let threw: unknown = null;
    try {
      fresh = await fetchFresh();
    } catch (err) {
      threw = err;
    } finally {
      entry.fetchedAt = Date.now();
    }
    if (threw === null && fresh !== null) {
      entry.value = fresh;
      return;
    }
    const why =
      threw === null
        ? "did not answer"
        : `answered with something this build cannot read (${threw instanceof Error ? threw.message : String(threw)})`;
    opts.log.warn(
      `${opts.label}: ${key} ${why}; serving ` +
        `${entry.value === null ? "the fallback" : "the last good answer"}`,
    );
  }

  return {
    async get(key: string, fetchFresh: () => Promise<T | null>): Promise<T | null> {
      let entry = entries.get(key);
      if (entry === undefined) {
        entry = { value: null, fetchedAt: 0, inFlight: null };
        entries.set(key, entry);
      }

      if (Date.now() - entry.fetchedAt > ttlMs) {
        const pending = entry;
        pending.inFlight ??= refresh(key, pending, fetchFresh).finally(() => {
          pending.inFlight = null;
        });
        await pending.inFlight;
      }

      return entry.value;
    },
  };
}
