/**
 * Which boards stay in memory, and which one gets thrown away first.
 *
 * ## Why a cache at all
 *
 * Because most of a board switch was never work. Measured on this box against
 * `dist@bcac6aa` with a real GPU: switching to the Bay Area covers the screen
 * with the opaque boot card for 1,711 ms, the Southland for 1,079 and California
 * for 997 — but only 604 / 372 / 222 ms of that **blocks the main thread**, and
 * the page draws 68 / 45 / 49 frames inside the gap. Sixty-five to seventy-eight
 * per cent of every pause is a live, animating page with nothing to draw,
 * because `mountCity` disposed the outgoing board before the incoming one
 * existed. Keeping it turns a full-screen modal into a live picture that
 * freezes for a fraction of a second, and it costs no triangles: `Stage`
 * retains and pauses by contract, `tick()` renders only the current scene, and a
 * paused board is zero draw calls.
 *
 * It also recovers the shader cache, which disposal was throwing away. `three`
 * refcounts programs per material and deletes at zero, so disposing a board's
 * materials deletes its programs: `linkProgram` ran 38, 59, 78, 109 and 127
 * times across five mounts and never once reused, and a second visit to the Bay
 * Area was only 115 ms faster than the first. Retained, a return costs nothing.
 *
 * ## Why two deep is enough
 *
 * The `sf` and `socal` bounds do not intersect (37.18–38.03 N against
 * 33.28–34.36 N) and California contains both, so the set of boards that can
 * describe any one camera position is always `{california}` or
 * `{california, one metro}` — never three. Measured GPU vertex and index bytes
 * are california 5.86 MB, sf 14.12 and socal 8.06, plus heightfields of 0.49,
 * 3.06 and 0.77; California and the worst metro resident together is about
 * 40 MB, and all three about 60. `deviceProfile().handheld` changes only the
 * pixel ratio and the shadow map, so a phone carries the same resident geometry
 * as a laptop against a much smaller WebGL budget — hence two on a handheld and
 * three on a desktop, and `residentCapacity` is the one place that says so.
 *
 * ## What this module deliberately does not know
 *
 * What a board *is*. It holds an opaque payload, so the record bundling the
 * sixteen per-board variables `main.ts` used to tear down can stay in `main.ts`
 * where its types live, and this file stays a pure, testable eviction policy
 * with no DOM, no WebGL and no `three` import. The caller disposes what it is
 * handed back; nothing here frees anything, for the same reason `Stage` does not
 * (CONTRACT.md §1).
 */

/**
 * How many boards may be resident at once, including the pinned one.
 *
 * Three on a desktop is every board this build has, which is what makes the
 * SoCal-to-Bay-Area rebuild disappear as well as the return to California. Two
 * on a handheld is California plus whichever metro is on screen, which is the
 * provably sufficient set above.
 */
export function residentCapacity(handheld: boolean): number {
  return handheld ? 2 : 3;
}

export interface BoardCacheOptions {
  /**
   * Board ids that are never evicted.
   *
   * California, in practice: it is the cheapest board (5.86 MB) and the root of
   * every descent, so it is the one most likely to be asked for again and the
   * one that costs least to keep.
   */
  pinned?: readonly string[];
  /** Resident boards, including pinned ones. See `residentCapacity`. */
  capacity: number;
}

export interface BoardCache<T> {
  get(id: string): T | null;
  has(id: string): boolean;
  /** Every resident id, least-recently-shown first. */
  ids(): readonly string[];
  size(): number;
  /**
   * Store a board and mark it the most recently shown.
   *
   * Returns whatever had to be evicted to stay inside `capacity` — the caller's
   * job to dispose, and it must not forget `environmentRig.release(scene)`: the
   * rig's ledger is a strong reference, and a board left in it is the whole
   * scene graph retained.
   */
  put(id: string, value: T): readonly T[];
  /** Mark an already-resident board as the most recently shown. */
  touch(id: string): void;
  /** Take one out without evicting anything else. */
  remove(id: string): T | null;
  /** Take everything out, most-recently-shown last. */
  drain(): readonly T[];
}

export function createBoardCache<T>(options: BoardCacheOptions): BoardCache<T> {
  const pinned = new Set(options.pinned ?? []);
  const capacity = Math.max(1, Math.floor(options.capacity));
  /**
   * Insertion order is recency order.
   *
   * A `Map` iterates in insertion order and `delete` + `set` is how a key is
   * moved to the end of it, so the least recently shown board is simply the
   * first key the iterator yields. That is the whole LRU and it needs no
   * timestamps — which matters, because a timestamp would have to come from a
   * clock, and this module has no business reading one.
   */
  const entries = new Map<string, T>();

  function evictable(protectedId: string | null): string | null {
    for (const id of entries.keys()) {
      if (pinned.has(id)) continue;
      if (id === protectedId) continue;
      return id;
    }
    return null;
  }

  return {
    get: (id) => entries.get(id) ?? null,
    has: (id) => entries.has(id),
    ids: () => [...entries.keys()],
    size: () => entries.size,

    put(id, value) {
      const evicted: T[] = [];
      // Replacing an id in place is not an eviction and must not free the
      // incoming board — the common shape of that mistake is a rebuild racing a
      // switch, which the caller's `AbortController` already prevents, and this
      // is the second line of defence.
      const previous = entries.get(id);
      if (previous !== undefined && previous !== value) evicted.push(previous);
      entries.delete(id);
      entries.set(id, value);

      while (entries.size > capacity) {
        const victim = evictable(id);
        // Nothing left that may be freed: every remaining board is pinned or is
        // the one being shown. Over capacity is the correct outcome — a pin is a
        // statement that the board matters more than the ceiling does.
        if (victim === null) break;
        const record = entries.get(victim);
        entries.delete(victim);
        if (record !== undefined) evicted.push(record);
      }
      return evicted;
    },

    touch(id) {
      const value = entries.get(id);
      if (value === undefined) return;
      entries.delete(id);
      entries.set(id, value);
    },

    remove(id) {
      const value = entries.get(id);
      if (value === undefined) return null;
      entries.delete(id);
      return value;
    },

    drain() {
      const all = [...entries.values()];
      entries.clear();
      return all;
    },
  };
}

// ---- Which board to load before anybody asks for it -------------------------

/** A board the prefetcher may load, described by facts a pack already states. */
export interface PrefetchTier {
  id: string;
  /** The stand-off at which this tier takes over, in true metres. */
  handoverStandoffM: number;
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
}

export interface PrefetchQuery {
  tiers: readonly PrefetchTier[];
  /** Where the camera is looking, in degrees. */
  lat: number;
  lng: number;
  /** How far it is standing off what it is looking at, in true metres. */
  standoffM: number;
  /** Boards already in memory, however they got there. */
  resident: readonly string[];
  /** The board on screen. Never a prefetch target, never an eviction victim. */
  visible: string | null;
  capacity: number;
  pinned?: readonly string[];
}

/**
 * How much closer than the handover the camera must be before a tier loads.
 *
 * Three handovers. The trigger has to fire early enough that a metro is resident
 * before the visitor reaches the stand-off where it would otherwise be built in
 * front of them, and late enough that it never fires while they are looking at
 * something else entirely.
 *
 * Three puts San Francisco's trigger at 213.6 km and Southern California's at
 * 326.4 km. The first lands inside the **133 km dead band** between California's
 * finest authored rung (`la-sf-i-5`, 242,206 m) and the first Bay rung
 * (71,200 m) — a stretch of the ladder with nothing authored in it, so the load
 * happens while the camera is on a state-scale rung and has somewhere to be.
 * It is nowhere near the overview (1,551 km) or Shasta (501 km), which is the
 * property that matters: landing on the state board must not load two metros.
 */
export const PREFETCH_HANDOVER_MULTIPLE = 3;

/**
 * How far outside a tier's own rectangle the camera may be and still arm it,
 * as a multiple of the tier's half-diagonal.
 *
 * 1.5 gives San Francisco a 92 km disc and Southern California a 146 km one.
 * The two metros are 313.9 km apart edge to edge and 92 + 146 = 238, so **the
 * discs provably cannot overlap and at most one tier is ever a candidate**.
 * That is the same covering-set argument `residentCapacity` already rests on,
 * re-derived here rather than assumed, and it is what makes a two-deep handheld
 * cache safe to prefetch into at all.
 */
export const PREFETCH_REACH_MULTIPLE = 1.5;

/** Metres per degree of latitude. The longitude term is cosine-corrected below. */
const M_PER_DEG_LAT = 111_320;

/** Great-circle-ish distance from a point to a rectangle, in true metres. */
function metresToBounds(lat: number, lng: number, b: PrefetchTier["bounds"]): number {
  const dLat = Math.max(b.minLat - lat, 0, lat - b.maxLat);
  const dLng = Math.max(b.minLng - lng, 0, lng - b.maxLng);
  // Cosine taken at the *camera*, not at the rectangle's centre: the caller may
  // be a long way north of a southern tier, and the squash that matters for how
  // far it has to travel is the one where it is standing.
  const east = dLng * M_PER_DEG_LAT * Math.cos((lat * Math.PI) / 180);
  return Math.hypot(dLat * M_PER_DEG_LAT, east);
}

/** Half the diagonal of a tier's rectangle, in true metres. */
function halfDiagonal(b: PrefetchTier["bounds"]): number {
  const midLat = (b.minLat + b.maxLat) / 2;
  const north = (b.maxLat - b.minLat) * M_PER_DEG_LAT;
  const east = (b.maxLng - b.minLng) * M_PER_DEG_LAT * Math.cos((midLat * Math.PI) / 180);
  return Math.hypot(north, east) / 2;
}

/**
 * Whether a board may be admitted without evicting the one on screen.
 *
 * The policy lives here and the effects live in `main.ts`, so this module stays
 * free of DOM, WebGL and `three` — see this file's header. A prefetch that
 * evicted the visible board to make room for an invisible one would be the worst
 * possible trade, and on a two-deep handheld cache it is one `put` away.
 */
export function canAdmit(query: {
  id: string;
  resident: readonly string[];
  visible: string | null;
  capacity: number;
  pinned?: readonly string[];
}): boolean {
  if (query.resident.includes(query.id)) return true;
  if (query.resident.length < query.capacity) return true;
  const pinned = new Set(query.pinned ?? []);
  // `ids()` is least-recently-shown first, so the victim is the first entry that
  // is neither pinned nor on screen. If there is no such entry, admitting means
  // evicting something that must not be evicted.
  return query.resident.some((id) => !pinned.has(id) && id !== query.visible);
}

/**
 * The one board worth loading right now, or `null` for "do nothing".
 *
 * Scalars only, never a camera: this has to be callable from a test with no
 * `three` in it, and a function that took a `PerspectiveCamera` would drag the
 * whole engine into this module's dependency graph and out of CONTRACT §1's
 * "no DOM, no WebGL, no three" promise for it.
 *
 * Returns at most one id. Loading two metros at once is never right — they are
 * 313.9 km apart and the camera cannot be descending into both — and returning
 * one keeps the caller's abort handling to a single controller.
 */
export function prefetchTarget(query: PrefetchQuery): string | null {
  const resident = new Set(query.resident);
  /**
   * Whether there is room for another board without dropping one.
   *
   * This is the difference between two genuinely different situations, and
   * collapsing them was the first version's mistake.
   *
   * **With free room, proximity is the wrong question.** CONTRACT §1.1 already
   * promises three resident boards on a desktop with California pinned, and
   * measured that all three together are about 60 MB — the residency exists, and
   * until now the only thing that ever filled it was a visitor waiting through a
   * build. A desktop sitting on the state board with two empty slots should
   * simply *have* both metros, because the overwhelmingly common path is to land
   * on California and then click a metro, and on that path a proximity trigger
   * fires exactly never: the camera is at 1,551 km, hundreds of kilometres above
   * any threshold, and the next thing that happens is the click. A rule that only
   * helps free-camera explorers is a rule that helps almost nobody.
   *
   * **With the cache full, proximity is the only question.** Admitting now means
   * evicting something, and evicting is only worth it for a board the camera is
   * actually descending toward. That is the handheld case — capacity two, one of
   * them pinned — and it is why the discs and the dead band still exist.
   */
  const hasRoom = query.resident.length < query.capacity;
  let best: { id: string; distanceM: number } | null = null;
  for (const tier of query.tiers) {
    if (tier.id === query.visible) continue;
    if (resident.has(tier.id)) continue;
    const distanceM = metresToBounds(query.lat, query.lng, tier.bounds);
    if (!hasRoom) {
      if (query.standoffM > tier.handoverStandoffM * PREFETCH_HANDOVER_MULTIPLE) continue;
      if (distanceM > halfDiagonal(tier.bounds) * PREFETCH_REACH_MULTIPLE) continue;
    }
    if (
      !canAdmit({
        id: tier.id,
        resident: query.resident,
        visible: query.visible,
        capacity: query.capacity,
        pinned: query.pinned,
      })
    ) {
      continue;
    }
    // Nearest wins. With non-overlapping discs there is never a second
    // candidate, so this is a tie-break that should not be reachable — and it is
    // written anyway, because "should not be reachable" is how a board gets
    // chosen by declaration order the day somebody adds a fourth pack.
    if (best === null || distanceM < best.distanceM) best = { id: tier.id, distanceM };
  }
  return best?.id ?? null;
}
