/**
 * One ladder over three boards: every authored chapter as a rung, ordered by
 * how far the camera stands off.
 *
 * ## Why this file exists
 *
 * The product ships three city packs and, until this module, the interface said
 * so out loud: a strip of three tabs naming three *modes*, each swapping a
 * different list of chapters underneath it. That is the single most direct way
 * to tell a visitor they are looking at three products rather than one place.
 * The boards themselves are not the problem — measured, the 20x horizontal
 * scale jump between California and the Bay Area is *invisible*, because
 * `World.project` is a uniform scale in x/z with no vertical term and a uniform
 * scale leaves a perspective image identical. What announces three boards is the
 * chrome around them, and this is the data that lets the chrome stop.
 *
 * ## Stand-off, and not altitude
 *
 * The rungs are ordered by `cameraStandoffMetres` — the true-metre distance from
 * the camera to what it is looking at — and that choice is measured rather than
 * aesthetic. Sorted by *altitude* the three boards interleave badly: SoCal's
 * Orange County chapter sits 5.97 km up and the Bay Area's Peninsula 3.93 km,
 * both of them inside California's own range, and an altitude cannot tell a low
 * oblique from a high plan. Sorted by stand-off they separate cleanly —
 * California bottoms out at 242 km, the Southland's whole-board pose is 108.8 km
 * and the Bay Area's is 71.2 km. Stand-off is what decides how much earth is in
 * frame, which is what decides which board can fill it.
 *
 * Both numbers are read straight off the authored `focus` block and are
 * therefore facts about the *pack*, not about the runtime: `standoffM` is
 * `hypot(distance, height) x metresPerUnit` and `altitudeM` is
 * `height x metresPerUnit / verticalExaggeration`, which is exactly what
 * `SceneHandle.cameraStandoffMetres()` and `cameraAltitudeMetres()` report once
 * the camera has arrived. Deriving them here rather than measuring them at
 * runtime is what keeps this module pure and the ladder stable across a resize.
 * (`chapterFraming` scales a pose on a narrow window; that is a framing
 * correction for a phone, not a change to what the chapter *is*.)
 *
 * ## What this module is not allowed to do
 *
 * **It never edits a pack.** Twenty-nine capture guards in `scripts/brand-assets`
 * aim at `#chapters .chapter` by index and assert the button's `shortLabel`, so
 * a pack whose chapter order or short labels moved would silently re-shoot every
 * still on lumbridgecorp.com. Two rungs currently read "Whole Board" and two read
 * "The Valley"; both collisions are resolved *here*, in `LADDER_LABELS`, and the
 * packs keep the strings the guards assert on. The ladder is a new **view** over
 * the same data, never a replacement for it.
 *
 * **It holds the handover table, and `City` does not.** `handoverStandoffM` is a
 * fact about how the *ladder* hands one board to the next, and putting it on the
 * `City` type would widen a pack — a file that must stay pure, reviewable data —
 * for the benefit of one consumer. It lives in `HANDOVER_STANDOFF_M` below, next
 * to the argument for its value.
 */

import type { Chapter, City } from "./types.ts";

/** The three regions a rung can belong to, and the order they descend in. */
export type LadderRegion = "state" | "southland" | "bay";

/** The heading the places list prints above each region's rungs. */
export const REGION_LABELS: Readonly<Record<LadderRegion, string>> = Object.freeze({
  state: "The state",
  southland: "The Southland",
  bay: "The Bay",
});

/** Which region a board id belongs to. */
export const BOARD_REGION: Readonly<Record<string, LadderRegion>> = Object.freeze({
  california: "state",
  socal: "southland",
  sf: "bay",
});

/**
 * The stand-off, in true metres, below which a board is willing to take the
 * camera from the one above it. Authored, and deliberately not derived.
 *
 * The natural derived rule — hand over only once the incoming board's bounds
 * cover the frame — computes a maximum stand-off of 31.6 km for a target at
 * downtown San Francisco with a 63.1 degree horizontal field of view, because
 * the board's west edge is only 19.4 km away. The Bay Area's own whole-board
 * chapter stands off 71.2 km, and the shipped marketing still for it shows the
 * board's northern land edge across the top of the frame. So the derived rule
 * would refuse the board at its own opening pose. One authored number per pack
 * is honest, is one line, and is checkable against a photograph.
 *
 * California is absent: it is the root of every descent and hands over to
 * nothing. `handoverStandoff` returns `Infinity` for it, which reads correctly
 * in the comparison below without a special case.
 */
export const HANDOVER_STANDOFF_M: Readonly<Record<string, number>> = Object.freeze({
  sf: 71_200,
  socal: 108_800,
});

export function handoverStandoff(boardId: string): number {
  return HANDOVER_STANDOFF_M[boardId] ?? Number.POSITIVE_INFINITY;
}

/**
 * The labels the ladder uses where a pack's own would collide.
 *
 * Keyed `board:chapter`. Four entries, and every one of them exists because two
 * packs independently chose the same words for two different places: both metro
 * packs open on a chapter labelled "Whole Board", and both call a chapter "The
 * Valley" — the San Fernando one and the silicon one. A list that merges the
 * three packs has to say which is which, and it has to say it without touching
 * `shortLabel`, which the capture guards assert on.
 */
const LADDER_LABELS: Readonly<Record<string, string>> = Object.freeze({
  "socal:all": "The Southland",
  "sf:all": "The Bay Area",
  // Its pack label is also "The Bay Area", which the whole-board rung above has
  // just taken. Its short label is unique and is the better name anyway: this is
  // the wide plan of the water, not the board.
  "sf:bay-area": "The Bay",
});

/**
 * A chapter on the California board that is a **door** rather than a camera
 * pose, and the rung it folds onto.
 *
 * `main.ts` matches these two ids against `CALIFORNIA_DESTINATIONS` and calls
 * `switchCity()` instead of `flyTo()`, so their `focus` blocks are dead for the
 * camera and live only as dots on the plan. As rungs they would be duplicates:
 * two entries at 88.4 km of stand-off called "LA" and "SF" sitting between the
 * Southland's whole-board rung and the Bay Area's, which are the two places they
 * go. So they fold on, and the ladder is 24 rungs rather than 26.
 */
const FOLDED_DOORS: Readonly<Record<string, string>> = Object.freeze({
  "california:los-angeles": "socal:all",
  "california:san-francisco": "sf:all",
});

/** One rung: a place you can be, and how far back you stand to see it. */
export interface LadderRung {
  /** `board:chapter`. Unique across the ladder; the ladder's own identity. */
  key: string;
  /** The chapter id **on its own board**, unchanged from the pack. */
  id: string;
  board: string;
  region: LadderRegion;
  /** Unique across the ladder. See `LADDER_LABELS`. */
  label: string;
  /** The pack's own short label, untouched — the string the capture guards assert. */
  shortLabel: string;
  /** The pack's own chapter number, untouched. */
  number: string;
  description: string;
  lat: number;
  lng: number;
  rotation: number;
  /** True metres from the camera to the target at this chapter's authored pose. */
  standoffM: number;
  /** True metres of air under the camera, vertical exaggeration divided out. */
  altitudeM: number;
  /**
   * The California door chapter that folded onto this rung, if one did. Kept so
   * the places list can say a rung is reachable from the state board, and so a
   * click on it from California takes the authored door rather than inventing a
   * second route.
   */
  doorFrom?: { board: string; id: string };
}

/** What `buildLadder` needs from a board. A `City` satisfies it. */
export interface LadderBoard {
  id: string;
  city: Pick<City, "chapters" | "latScale" | "verticalExaggeration">;
}

/** Metres per scene unit, horizontally. The same arithmetic `World` does. */
function metresPerUnit(latScale: number): number {
  return 111_320 / latScale;
}

export function chapterStandoffMetres(chapter: Chapter, latScale: number): number {
  return Math.hypot(chapter.focus.distance, chapter.focus.height) * metresPerUnit(latScale);
}

export function chapterAltitudeMetres(
  chapter: Chapter,
  latScale: number,
  verticalExaggeration: number,
): number {
  return (chapter.focus.height * metresPerUnit(latScale)) / verticalExaggeration;
}

/**
 * Every authored chapter of every board, as one array sorted **descending by
 * stand-off**, with the two California doors folded onto the metro rungs they
 * open.
 *
 * This is the ordered array the brief asks for and it is the coordinate the
 * handover rule reads. It is *not* the order the places list prints — see
 * `placesRows`, which groups it, and the note there for why a global sort
 * produces nine headings for three regions.
 */
export function buildLadder(boards: readonly LadderBoard[]): readonly LadderRung[] {
  const rungs: LadderRung[] = [];
  const doors: { key: string; onto: string }[] = [];

  for (const board of boards) {
    const region = BOARD_REGION[board.id] ?? "state";
    for (const chapter of board.city.chapters) {
      const key = `${board.id}:${chapter.id}`;
      const onto = FOLDED_DOORS[key];
      if (onto !== undefined) {
        doors.push({ key, onto });
        continue;
      }
      rungs.push({
        key,
        id: chapter.id,
        board: board.id,
        region,
        label: LADDER_LABELS[key] ?? chapter.label,
        shortLabel: chapter.shortLabel,
        number: chapter.number,
        description: chapter.description,
        lat: chapter.focus.lat,
        lng: chapter.focus.lng,
        rotation: chapter.focus.rotation,
        standoffM: chapterStandoffMetres(chapter, board.city.latScale),
        altitudeM: chapterAltitudeMetres(
          chapter,
          board.city.latScale,
          board.city.verticalExaggeration,
        ),
      });
    }
  }

  for (const door of doors) {
    const rung = rungs.find((candidate) => candidate.key === door.onto);
    if (!rung) continue;
    const [board = "", id = ""] = door.key.split(":");
    rung.doorFrom = { board, id };
  }

  // Descending, and ties broken by key so the array is a pure function of the
  // packs. Two rungs at an identical stand-off is not hypothetical — the two
  // folded California doors shared one before they folded.
  rungs.sort((a, b) => b.standoffM - a.standoffM || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
  return rungs;
}

/**
 * The ladder as the places list prints it: grouped by region, groups ordered by
 * their own top rung, rungs inside a group still descending by stand-off.
 *
 * ## Why the printed list is grouped and the array is not
 *
 * Because a globally interleaved order is unreadable. Sorted purely by stand-off
 * the twenty-four rungs alternate Southland, Bay, Southland, Bay eleven times —
 * 108.8 km Southland, 71.2 km Bay, 38.6 Southland, 30.1 Southland, 30.0 Bay,
 * 28.8 Southland, 26.3 Bay — so a list that printed a heading whenever the
 * region changed would print **nine headings for three regions**. That is
 * noise, and it argues for three boards more loudly than the tab strip it
 * replaces.
 *
 * Grouping keeps one ordering rule — stand-off, everywhere — and gives the three
 * headings the brief asks for. The groups themselves are ordered by their top
 * rung, so the page still reads top to bottom as a descent: the state from
 * 1,551 km down to 242, the Southland from 108.8 km down to 9.1, the Bay from
 * 71.2 km down to 3.9.
 */
export function placesRows(ladder: readonly LadderRung[]): readonly LadderRung[] {
  const regions: LadderRegion[] = ["state", "southland", "bay"];
  const top = new Map<LadderRegion, number>();
  for (const rung of ladder) {
    const seen = top.get(rung.region);
    if (seen === undefined || rung.standoffM > seen) top.set(rung.region, rung.standoffM);
  }
  const ordered = regions
    .filter((region) => top.has(region))
    .sort((a, b) => (top.get(b) ?? 0) - (top.get(a) ?? 0));
  const rows: LadderRung[] = [];
  for (const region of ordered) {
    for (const rung of ladder) if (rung.region === region) rows.push(rung);
  }
  return rows;
}

/**
 * Which rung the camera is standing on, for the highlight that makes the list
 * an altimeter rather than a menu.
 *
 * Two rules, in this order, and the order is the whole design:
 *
 *   1. **If the camera is where a chapter put it, that chapter wins.** Within
 *      `PARKED_TOLERANCE` of its authored stand-off, the rung you clicked stays
 *      lit even when a neighbour's number is arithmetically nearer. Four of the
 *      Bay Area's rungs sit inside 12% of each other (Marin 21.5 km, Silicon
 *      Valley 20.7, San Jose 19.6, the East Bay 19.2), and a highlight that
 *      hopped between them the instant you arrived would read as a bug.
 *   2. **Otherwise the nearest stand-off on the resident board wins**, in log
 *      space, because the ladder spans four orders of magnitude and a linear
 *      "nearest" would pin everything below 20 km onto one rung.
 *
 * `currentId` is the resident board's own current chapter — `SceneHandle.current()`
 * — and `null` is a legitimate answer: a camera dragged out past the top rung of
 * a board is not standing on any of them.
 */
const PARKED_TOLERANCE = 0.18;

export function activeRung(
  ladder: readonly LadderRung[],
  boardId: string,
  standoffM: number,
  currentId: string | null,
): LadderRung | null {
  const onBoard = ladder.filter((rung) => rung.board === boardId);
  if (onBoard.length === 0) return null;

  if (currentId !== null) {
    const parked = onBoard.find((rung) => rung.id === currentId);
    if (parked && Math.abs(standoffM - parked.standoffM) <= parked.standoffM * PARKED_TOLERANCE) {
      return parked;
    }
  }

  if (!Number.isFinite(standoffM) || standoffM <= 0) {
    return onBoard.find((rung) => rung.id === currentId) ?? null;
  }

  let best: LadderRung | null = null;
  let bestDistance = Number.POSITIVE_INFINITY;
  for (const rung of onBoard) {
    if (rung.standoffM <= 0) continue;
    const distance = Math.abs(Math.log(standoffM) - Math.log(rung.standoffM));
    if (distance < bestDistance) {
      bestDistance = distance;
      best = rung;
    }
  }
  return best;
}

/**
 * Whether the camera should be handed from one board to another, and to which.
 *
 * Pure, and it is the whole of the free-camera board rule. Three inputs and no
 * state beyond what the caller passes back in:
 *
 *   - `standoffM` — the live `cameraStandoffMetres()`, already true metres and
 *     already free of both scale and exaggeration.
 *   - `target` — where the orbit is pointed, in lat/lng. A board is only a
 *     candidate if it contains this.
 *   - `current` — the resident board, so the answer can be "stay".
 *
 * ## The hysteresis band, and why it is not optional
 *
 * A camera parked exactly on a threshold and nudged by a wheel notch would tear
 * down and rebuild a board on every notch. Promotion happens below
 * `handover x PROMOTE`, demotion above `handover x DEMOTE`, and the gap between
 * them is dead air where the answer is always "stay". `dragging` is the second
 * guard: `OrbitControls` fires `change` on every damped frame of a drag, and a
 * board swap in the middle of one takes the pointer away from the thing it is
 * holding.
 *
 * ## It has to be allowed to say no
 *
 * The two metro boards cover 25,884 km² of California's 989,000 — about 55% of
 * the population and 2.6% of the area. Two wheel notches down from the state
 * pose over Fresno lands on 806 m lots and 2 km-wide freeway symbols with
 * nothing finer to hand over to, and the honest answer there is to stay on
 * California and let the descent look coarse. `null` is that answer and it is
 * the common case.
 */
export const PROMOTE = 0.9;
export const DEMOTE = 1.15;

export interface HandoverBoard {
  id: string;
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number };
  /** Metres per scene unit. The finest board wins a tie of candidates. */
  metresPerUnit: number;
}

export interface HandoverQuery {
  boards: readonly HandoverBoard[];
  current: string;
  lat: number;
  lng: number;
  standoffM: number;
  dragging?: boolean;
}

export function handover(query: HandoverQuery): string | null {
  if (query.dragging === true) return null;
  if (!Number.isFinite(query.standoffM) || query.standoffM <= 0) return null;

  const contains = (board: HandoverBoard): boolean =>
    query.lat >= board.bounds.minLat &&
    query.lat <= board.bounds.maxLat &&
    query.lng >= board.bounds.minLng &&
    query.lng <= board.bounds.maxLng;

  const currentBoard = query.boards.find((board) => board.id === query.current);

  // Demote first: a camera that has climbed above this board's own handover
  // ceiling belongs to whatever contains it at a coarser scale, and asking about
  // promotion from a board you have already left is how an oscillation starts.
  if (currentBoard !== undefined) {
    const ceiling = handoverStandoff(currentBoard.id);
    if (Number.isFinite(ceiling) && query.standoffM > ceiling * DEMOTE) {
      const coarser = query.boards
        .filter(
          (board) =>
            board.id !== query.current &&
            contains(board) &&
            board.metresPerUnit > currentBoard.metresPerUnit,
        )
        .sort((a, b) => a.metresPerUnit - b.metresPerUnit)[0];
      return coarser?.id ?? null;
    }
  }

  const candidates = query.boards.filter(
    (board) =>
      board.id !== query.current &&
      contains(board) &&
      (currentBoard === undefined || board.metresPerUnit < currentBoard.metresPerUnit) &&
      query.standoffM < handoverStandoff(board.id) * PROMOTE,
  );
  if (candidates.length === 0) return null;
  // Finest wins. `sf` and `socal` bounds do not intersect and California
  // contains both, so the candidate set here is never larger than one — but the
  // rule is written for the general case rather than for today's three packs.
  return candidates.sort((a, b) => a.metresPerUnit - b.metresPerUnit)[0]?.id ?? null;
}
