/**
 * Fly the camera somewhere, like what you see, and get back the five numbers
 * that put it there.
 *
 * Adding a city to this repo means hand-authoring `chapters: Chapter[]`, and
 * every chapter carries a `focus` — `{ lat, lng, distance, height, rotation }`
 * — which is only knowable by flying somewhere, liking the frame, and then
 * working out what the numbers were. The loop that produced the twelve poses in
 * `cities/sf.ts` was: guess five numbers, rebuild, look, guess again. New York
 * is on the roadmap and it needs a dozen more of them.
 *
 * ## This is the inverse of `chapterPose`, and the round trip is measured
 *
 * `scene.ts` turns a focus into a camera with
 *
 *     target   = (x, groundAt(lat,lng), z)          where [x,z] = project(lat,lng)
 *     position = target + (sin(rot)·distance, height, cos(rot)·distance)
 *
 * which is invertible in one direction and *not* in the other, and the part
 * that is not is the whole reason this file measures itself. Going backwards:
 * `lat`/`lng` come from `unproject` of the orbit target's x and z, `distance`
 * and `rotation` are the polar form of the horizontal offset from target to
 * camera, and `height` is the camera's y above the ground under the target. The
 * camera position comes back exactly. The *target* does not, because
 * `chapterPose` pins target.y to the ground and OrbitControls does not: pan the
 * view and the target lifts off the terrain, and no chapter can express that.
 * The panel therefore shows the lift and the aim error it causes rather than
 * quietly emitting a pose that frames something else. See `measure`.
 *
 * ## The maths is duplicated from `scene.ts` on purpose, and it is a liability
 *
 * `chapterPose` is a closure inside `createScene` and is not exported, so
 * `poseOf` below is a copy of it. That is the one thing in this file that can
 * rot silently: change the pose convention in `scene.ts` and this tool will go
 * on confidently emitting the old one. The fix is for `scene.ts` to export the
 * conversion and for this file to import it; until then, the two blocks are
 * written to look identical so a diff between them is obvious.
 *
 * ## Precision: five decimals of degree, two of unit, five of radian
 *
 * The point of the tool is a block you can paste, so the numbers have to be
 * short enough to read and long enough to reproduce the frame. Measured over
 * every authored chapter in both packs plus eight jittered poses around each,
 * worst case:
 *
 *   - **What the packs carry today** (4 dp of degree, integer distance and
 *     height, 2 dp of radian, all typed by hand from a map): the camera lands
 *     265 m from where it was on the Bay Area board and 466 m out on SoCal, and
 *     the view direction is 0.7°–1.2° off — about thirty pixels across a
 *     1600-pixel frame. Fine for a pose a human invented at those digits;
 *     useless for reproducing one a human found by flying.
 *   - **This scheme**: camera position within 0.0066 scene units (0.62 m) on
 *     the Bay Area and 0.0063 units (2.44 m) on SoCal, orbit target within
 *     0.74 m and 0.89 m, and the aim within 0.0098° — 0.024% of a 42° frame,
 *     which is a third of a pixel at 1600 wide.
 *
 * Going finer buys nothing anyone can see and costs a digit in a file people
 * read. Trailing zeros are trimmed, so a pose that happens to be round emits
 * `rotation: 0.4`, exactly as `sf.ts` already has it.
 *
 * **The order of the quantisation is load-bearing.** Round `lat`/`lng` *first*,
 * then solve `distance`, `height` and `rotation` against the ground under the
 * rounded point. The obvious order — take all five numbers off the live camera,
 * round all five — feeds the terrain's own slope into the camera: `height` is
 * measured from the ground under the exact target and re-applied over the
 * ground under the rounded one, and with `verticalExaggeration` at 3.6 a metre
 * of horizontal rounding on the side of Twin Peaks is several units of altitude.
 * Measured, that order costs 0.98 m instead of 0.62 m at these digits, and
 * 7.8 m instead of 3.0 m at the packs' four decimals.
 *
 * ## Shape
 *
 * Same self-contained imperative handle as `engine/minimap.ts`, and the same
 * two rules: the caller supplies a container and owns where it goes, and
 * `tick()` runs inside a frame loop so it compares a timestamp and eight
 * numbers and returns. Unlike the minimap it never writes to the camera at all
 * — the only way this tool moves anything is by handing a `Pose` to the kit's
 * own `flyTo`.
 *
 * God tier only. `access.ts` is clear that a browser-side check is theatre
 * against anyone with a console, so the gate here is not security; it is a
 * loaded gun pointed away from the ninety-nine percent of sessions that have no
 * business seeing an authoring instrument at all.
 */

import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { Pose } from "../engine/scenekit.ts";
import type { Chapter } from "../engine/types.ts";
import type { World } from "../engine/world.ts";

export interface PoseEditorOptions {
  /**
   * Where the panel mounts. The tool appends exactly one element to it and
   * owns everything below that; the caller owns the box, its position and its
   * size.
   */
  container: HTMLElement;
  /**
   * Pass `access.can.debug`. Construction throws when it is false — see the
   * file header for why that is a thrown programming error and not a hidden
   * no-op.
   */
  allowed: boolean;
  /** The same `World` the scene is drawing. `groundAt` must agree, or nothing does. */
  world: World;
  /** The live scene camera. Read every tick, written never. */
  camera: THREE.PerspectiveCamera;
  /** The live orbit controls. `controls.target` is the pose's target. */
  controls: OrbitControls;
  /**
   * The kit's flight, `SceneKit.flyTo`. The only channel through which this
   * tool is allowed to move the camera, and the reason it takes a callback
   * rather than the controls: a tool that wrote `camera.position` directly
   * would be a second thing with opinions about where the camera is, which is
   * the failure CONTRACT.md §1 splits `Stage` and `SceneKit` to avoid.
   */
  flyTo(pose: Pose): void;
  /**
   * `SceneKit.flying`. Capture is refused mid-flight: a frame sampled halfway
   * through an eased interpolation is a pose nobody chose, and it looks
   * plausible enough in the readout to get pasted.
   */
  flying?(): boolean;
  /**
   * The chapters already in the pack, for the starting number and for the
   * duplicate-id warning. `chapterById` in `scene.ts` is built from an object
   * literal, so two chapters sharing an id means one of them silently is not
   * in the tour.
   */
  existingChapters?: readonly Chapter[];
}

/** How far the emitted block lands from the camera it was taken off. */
export interface PoseResidual {
  /** Camera position error, in scene units. */
  position: number;
  /** Orbit target error, in scene units. Carries the ground lock as well as rounding. */
  target: number;
  /** Angle between the live view direction and the emitted one, in degrees. */
  aim: number;
  /** `aim` as a fraction of the camera's vertical field of view. */
  frame: number;
  /**
   * How far the live orbit target floats above the terrain under it, in scene
   * units. Measured against the ground under the *unrounded* target, so it is
   * a statement about the camera and not about the emission precision: it is
   * non-zero exactly when the view has been panned, and that is the one part
   * of a pose a `Chapter` cannot carry.
   */
  lift: number;
}

export interface CapturedPose {
  chapter: Chapter;
  residual: PoseResidual;
}

export interface PoseEditor {
  /** Read the live camera, append a chapter to the session list, select it. */
  capture(): CapturedPose | null;
  poses(): readonly CapturedPose[];
  /** The whole list as a paste-ready `chapters` array. */
  code(): string;
  /** Call from the frame loop. Cheap by construction; see `tick`. */
  tick(): void;
  setVisible(visible: boolean): void;
  destroy(): void;
}

// ---- Precision --------------------------------------------------------------

const LATLNG_DP = 5;
const SPAN_DP = 2;
const ROT_DP = 5;
const TAU = Math.PI * 2;
/** Where the packs wrap. Not enforced by a formatter in this repo; matched by eye. */
const COLUMNS = 100;
/** Live readout ceiling. The stage runs at 60 and none of these digits need it. */
const FRAME_MS = 120;
/**
 * Aim error, in degrees, above which the panel stops calling a pose clean.
 * A tenth of a degree is four pixels across a 1600-pixel frame — under it
 * nothing on screen moves, over it the pasted chapter is framing something
 * slightly different from what was approved.
 */
const AIM_WARN = 0.1;

export function createPoseEditor(options: PoseEditorOptions): PoseEditor {
  if (!options.allowed) {
    throw new Error("poseEditor is a god-tier instrument and was constructed without the tier");
  }
  const { world, camera, controls } = options;
  const existing = options.existingChapters ?? [];

  // ---- The conversion -------------------------------------------------------

  /**
   * A copy of `chapterPose` in `engine/scene.ts`. Kept character-for-character
   * where it can be, so that a diff between the two files reads as a diff. See
   * the file header: this duplication is the one thing here that can rot.
   *
   * It writes into scratch vectors rather than allocating, because `measure`
   * calls it from the frame loop. `SceneKit.flyTo` copies out of the pose it is
   * given, so handing it the scratch is safe — and if that ever stops being
   * true this is where it breaks.
   */
  const scratch: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };

  function poseOf(focus: Chapter["focus"], into: Pose = scratch): Pose {
    const [x, z] = world.project(focus.lat, focus.lng);
    const groundY = world.groundAt(focus.lat, focus.lng);
    into.target.set(x, groundY, z);
    into.position.set(
      x + Math.sin(focus.rotation) * focus.distance,
      groundY + focus.height,
      z + Math.cos(focus.rotation) * focus.distance,
    );
    return into;
  }

  /**
   * The live camera, as a `Chapter["focus"]`, already at emission precision.
   *
   * Quantised in the order the file header argues for: the target first,
   * everything else against where the target landed. The bearing convention is
   * `chapterPose`'s — `atan2(dx, dz)`, so zero is due south of the target and
   * the angle opens toward the east, which is not the compass bearing anyone
   * expects and is what the packs are already written in.
   */
  function readFocus(): Chapter["focus"] {
    const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
    const lat = round(rawLat, LATLNG_DP);
    const lng = round(rawLng, LATLNG_DP);
    const [x, z] = world.project(lat, lng);
    const groundY = world.groundAt(lat, lng);
    const dx = camera.position.x - x;
    const dz = camera.position.z - z;
    const distance = Math.hypot(dx, dz);
    // Directly overhead the bearing is undefined, and `atan2` does not say so:
    // on a pair of negative zeros it answers -π, which the wrap below turns
    // into a confident π. The pose still round-trips either way — sin and cos
    // of anything times a zero distance is a zero offset — but
    // `rotation: 3.14159, distance: 0` in a city pack is a riddle, so straight
    // down is written as zero.
    let rotation = distance < 1e-6 ? 0 : Math.atan2(dx, dz);
    if (rotation < 0) rotation += TAU;
    rotation = round(rotation, ROT_DP);
    // Rounding up through a full turn: a bearing a hair below due south comes
    // out of the wrap as 6.283185…, which at five decimals is 6.28319, which is
    // larger than a turn. Zero is the same pose and reads like one.
    if (rotation >= TAU) rotation = 0;
    return {
      lat,
      lng,
      distance: round(distance, SPAN_DP),
      height: round(camera.position.y - groundY, SPAN_DP),
      rotation,
    };
  }

  const probe: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
  const liveDir = new THREE.Vector3();
  const emitDir = new THREE.Vector3();

  /**
   * What the emitted block costs, against the camera it was taken off.
   *
   * This is the correctness claim of the whole tool and it is checked at
   * capture rather than asserted in a comment: the focus goes back through the
   * local copy of `chapterPose` and the two poses are differenced. Three
   * separate numbers because they fail separately — rounding moves the camera,
   * the ground lock moves the target, and only the angle between the two view
   * vectors says whether any of it is visible.
   */
  function measure(focus: Chapter["focus"]): PoseResidual {
    poseOf(focus, probe);
    liveDir.subVectors(controls.target, camera.position);
    emitDir.subVectors(probe.target, probe.position);
    const degrees =
      liveDir.lengthSq() > 0 && emitDir.lengthSq() > 0
        ? (liveDir.angleTo(emitDir) * 180) / Math.PI
        : 0;
    // Against the ground under the live target rather than under the rounded
    // one. The difference is sub-millimetre and it is still worth the second
    // sample: `probe.target.y` folds the terrain's slope across a metre of
    // rounding into a number the panel presents as "you panned", and a warning
    // that fires on its own rounding is a warning people learn to ignore.
    const [rawLat, rawLng] = world.unproject(controls.target.x, controls.target.z);
    return {
      position: probe.position.distanceTo(camera.position),
      target: probe.target.distanceTo(controls.target),
      aim: degrees,
      frame: camera.fov > 0 ? degrees / camera.fov : 0,
      lift: controls.target.y - world.groundAt(rawLat, rawLng),
    };
  }

  // ---- The session list -----------------------------------------------------

  interface Entry {
    chapter: Chapter;
    residual: PoseResidual;
    /** False once the id has been typed, so a later label edit stops overwriting it. */
    idAuto: boolean;
    row: HTMLElement;
    name: HTMLElement;
    note: HTMLElement;
  }

  const entries: Entry[] = [];
  let selected: Entry | null = null;
  let visible = true;
  let destroyed = false;

  // ---- DOM ------------------------------------------------------------------

  /**
   * The panel lives in a shadow root.
   *
   * `index.html` is the entire stylesheet of this application and it belongs to
   * whoever is integrating this tool, not to the tool. A shadow root is the
   * only way to ship a widget with its own styling that neither reads from nor
   * writes to that file. Custom properties still cross the boundary, which is
   * the useful half of the isolation: `var(--amber)` below picks up the app's
   * own accent when the panel is mounted inside it and falls back to the same
   * literal when it is mounted anywhere else.
   */
  const host = document.createElement("div");
  host.className = "tera-pose-editor";
  const root = host.attachShadow({ mode: "open" });
  const style = document.createElement("style");
  style.textContent = CSS;
  root.append(style);

  const panel = el("div", "panel");
  root.append(panel);

  panel.append(el("div", "hd", "Chapter pose"));

  // The live readout.
  const live = el("div", "live");
  const liveLatLng = el("div", "row mono");
  const liveFocus = el("div", "row mono");
  const liveMeta = el("div", "row sub");
  const liveWarn = el("div", "warn");
  liveWarn.hidden = true;
  live.append(liveLatLng, liveFocus, liveMeta, liveWarn);
  panel.append(live);

  const captureBtn = el("button", "btn primary", "Capture pose") as HTMLButtonElement;
  captureBtn.type = "button";
  captureBtn.addEventListener("click", () => {
    capture();
  });
  panel.append(captureBtn);

  // The naming form. It edits whichever pose is selected rather than being a
  // form you fill in before capturing: you find the frame first and work out
  // what to call it second, which is the order the job actually happens in.
  const form = el("div", "form");
  const fId = field(form, "id", "kebab-case, unique in the pack");
  const fNumber = field(form, "number", '"13"');
  const fLabel = field(form, "label", "Mission Bay");
  const fShort = field(form, "shortLabel", "Mission Bay");
  const fDesc = area(form, "description", "A sentence about why it is on the map.");
  panel.append(form);

  const list = el("div", "list");
  panel.append(list);

  const actions = el("div", "actions");
  const copyOne = el("button", "btn", "Copy chapter") as HTMLButtonElement;
  const copyAll = el("button", "btn", "Copy all") as HTMLButtonElement;
  copyOne.type = "button";
  copyAll.type = "button";
  copyOne.addEventListener("click", () => {
    if (selected) void copy(emitChapter(selected.chapter));
  });
  copyAll.addEventListener("click", () => {
    if (entries.length > 0) void copy(code());
  });
  actions.append(copyOne, copyAll);
  panel.append(actions);

  const msg = el("div", "msg");
  panel.append(msg);

  /**
   * The clipboard fallback.
   *
   * `navigator.clipboard` needs a secure context, and a self-hoster running
   * this off `http://` on a LAN address has none — which is a documented
   * deployment in `deploy/STATIC.md`, not an edge case. So the failure path is
   * a textarea with the text already selected, and the user presses their own
   * copy key.
   *
   * `document.execCommand("copy")` is deliberately not tried in between. Inside
   * a shadow root the selection it copies is not reliably the one you just
   * made, and it reports success either way; a button that says "Copied" and
   * copied nothing is worse than a button that says it could not.
   */
  const out = el("textarea", "out") as HTMLTextAreaElement;
  out.readOnly = true;
  out.spellcheck = false;
  out.hidden = true;
  panel.append(out);

  /**
   * Keystrokes stop at the shadow boundary.
   *
   * `main.ts` binds the application's shortcuts to `window` and guards them by
   * testing whether `event.target` is an input or a textarea. **That guard does
   * not work through a shadow root**: the event is retargeted on its way out, so
   * by the time it reaches `window` the target is this host `<div>` and the
   * guard passes. Typing "Mission Bay" into the label field would fly the camera
   * to chapter one on the "1", toggle the plan view on the "m" and open an
   * office on the "o" — which is to say a tool whose entire premise is that it
   * does not perturb the scene would be the only thing in the app that did.
   *
   * Everything is swallowed rather than only the keys that currently mean
   * something, because the alternative is this list going stale the first time
   * somebody adds a shortcut. Escape is the one that also does something here:
   * it puts the clipboard fallback away.
   */
  function containKeys(event: Event) {
    event.stopPropagation();
    if (!(event instanceof KeyboardEvent) || event.key !== "Escape") return;
    if (out.hidden) return;
    out.hidden = true;
    say("");
  }
  root.addEventListener("keydown", containKeys);
  root.addEventListener("keyup", containKeys);

  options.container.append(host);

  // ---- Form wiring ----------------------------------------------------------

  function bindField(input: HTMLInputElement | HTMLTextAreaElement, apply: (v: string) => void) {
    input.addEventListener("input", () => {
      if (!selected) return;
      apply(input.value);
      refreshRow(selected);
      warnDuplicate();
    });
  }

  bindField(fId, (v) => {
    if (!selected) return;
    selected.chapter.id = v;
    // Typing an id takes it off the leash; a later label edit stops rewriting
    // it. Clearing the field puts it back, which is the only way to undo that
    // without a reset button nobody would find.
    selected.idAuto = v.trim() === "";
    if (selected.idAuto) selected.chapter.id = slug(selected.chapter.label);
  });
  bindField(fNumber, (v) => {
    if (selected) selected.chapter.number = v;
  });
  bindField(fLabel, (v) => {
    if (!selected) return;
    selected.chapter.label = v;
    if (selected.idAuto) {
      selected.chapter.id = slug(v);
      fId.value = selected.chapter.id;
    }
  });
  bindField(fShort, (v) => {
    if (selected) selected.chapter.shortLabel = v;
  });
  bindField(fDesc, (v) => {
    if (selected) selected.chapter.description = v;
  });

  // ---- Capture and the list -------------------------------------------------

  function capture(): CapturedPose | null {
    if (options.flying?.()) {
      say("Still flying — wait for the camera to land.");
      return null;
    }
    const focus = readFocus();
    const residual = measure(focus);
    const number = pad(existing.length + entries.length + 1);
    const label = `Untitled ${number}`;
    const chapter: Chapter = {
      id: slug(label),
      number,
      label,
      shortLabel: label,
      focus,
      description: "",
    };

    const row = el("div", "item");
    const name = el("button", "name") as HTMLButtonElement;
    name.type = "button";
    const note = el("div", "res mono");
    const fly = el("button", "ico", "fly") as HTMLButtonElement;
    const drop = el("button", "ico", "×") as HTMLButtonElement;
    fly.type = "button";
    drop.type = "button";
    fly.title = "Fly to the emitted pose — the rounded numbers, not the live camera";
    drop.title = "Forget this pose";
    const head = el("div", "item-head");
    head.append(name, fly, drop);
    row.append(head, note);

    const entry: Entry = { chapter, residual, idAuto: true, row, name, note };
    name.addEventListener("click", () => select(entry));
    fly.addEventListener("click", () => {
      // Through the kit's own flight, and to the *emitted* pose rather than the
      // captured one, because the emitted pose is what the paste will produce
      // and the whole point is to see it before trusting it.
      options.flyTo(poseOf(entry.chapter.focus));
    });
    drop.addEventListener("click", () => remove(entry));

    entries.push(entry);
    list.append(row);
    refreshRow(entry);
    select(entry);
    warnDuplicate();
    return { chapter, residual };
  }

  function remove(entry: Entry) {
    const at = entries.indexOf(entry);
    if (at < 0) return;
    entries.splice(at, 1);
    entry.row.remove();
    if (selected === entry) select(entries[Math.min(at, entries.length - 1)] ?? null);
    warnDuplicate();
  }

  function select(entry: Entry | null) {
    if (selected) selected.row.classList.remove("sel");
    selected = entry;
    if (entry) entry.row.classList.add("sel");
    form.classList.toggle("off", entry === null);
    copyOne.disabled = entry === null;
    copyAll.disabled = entries.length === 0;
    fId.value = entry?.chapter.id ?? "";
    fNumber.value = entry?.chapter.number ?? "";
    fLabel.value = entry?.chapter.label ?? "";
    fShort.value = entry?.chapter.shortLabel ?? "";
    fDesc.value = entry?.chapter.description ?? "";
  }

  function refreshRow(entry: Entry) {
    const r = entry.residual;
    entry.name.textContent = `${entry.chapter.number} · ${entry.chapter.label || "—"}`;
    entry.note.textContent =
      `pos ${r.position.toFixed(4)}u · aim ${r.aim.toFixed(4)}° · ` +
      `${(r.frame * 100).toFixed(3)}% of frame`;
    entry.note.classList.toggle("bad", r.aim > AIM_WARN);
  }

  /**
   * Two chapters with the same id is not a lint, it is a missing chapter:
   * `scene.ts` keys its flights off an object built from the list, so the
   * second one wins and the first is unreachable from the legend.
   */
  function warnDuplicate() {
    const seen = new Set(existing.map((c) => c.id));
    const clashes: string[] = [];
    for (const e of entries) {
      if (seen.has(e.chapter.id)) clashes.push(e.chapter.id);
      seen.add(e.chapter.id);
    }
    if (clashes.length > 0) say(`Duplicate id: ${clashes.join(", ")}`, true);
    else if (msg.classList.contains("bad")) say("");
  }

  function say(text: string, bad = false) {
    msg.textContent = text;
    msg.classList.toggle("bad", bad && text !== "");
  }

  async function copy(text: string) {
    if (navigator.clipboard && window.isSecureContext) {
      try {
        await navigator.clipboard.writeText(text);
        say("Copied.");
        return;
      } catch {
        // Permission refused, or a context the API decided was not secure
        // enough after all. Fall through to the textarea.
      }
    }
    out.hidden = false;
    out.value = text;
    out.focus({ preventScroll: true });
    out.select();
    say("Clipboard unavailable — press Ctrl/Cmd-C.");
  }

  function code(): string {
    return emitChapters(entries.map((e) => e.chapter));
  }

  // ---- The live readout -----------------------------------------------------

  let lastDraw = 0;
  // Compared exactly rather than with an epsilon, for the reason `minimap.ts`
  // spells out: OrbitControls' damping asymptotes, and an epsilon freezes the
  // readout a few frames before the camera has actually stopped.
  let lastCamX = NaN;
  let lastCamY = NaN;
  let lastCamZ = NaN;
  let lastTgtX = NaN;
  let lastTgtY = NaN;
  let lastTgtZ = NaN;

  function moved(): boolean {
    return (
      camera.position.x !== lastCamX ||
      camera.position.y !== lastCamY ||
      camera.position.z !== lastCamZ ||
      controls.target.x !== lastTgtX ||
      controls.target.y !== lastTgtY ||
      controls.target.z !== lastTgtZ
    );
  }

  function paintLive() {
    const focus = readFocus();
    const r = measure(focus);
    liveLatLng.textContent = `lat ${num(focus.lat)}   lng ${num(focus.lng)}`;
    liveFocus.textContent =
      `dist ${num(focus.distance)}   height ${num(focus.height)}   rot ${num(focus.rotation)}`;

    // The 3D standoff against the controls' own ceiling, because a pose outside
    // it is one `controls.update()` away from being quietly reeled in — the
    // trap `sf.ts` has a paragraph about above its regional chapters.
    const radius = Math.hypot(focus.distance, focus.height);
    const ceiling = controls.maxDistance;
    const groundM = world.unitsToMetres(controls.target.y - r.lift);
    liveMeta.textContent =
      `ground ${groundM.toFixed(0)} m · standoff ${radius.toFixed(1)} of ${ceiling.toFixed(0)}`;

    const tight = ceiling > 0 && radius > ceiling * 0.99;
    if (Math.abs(r.lift) > 1e-4 && r.aim > AIM_WARN) {
      liveWarn.hidden = false;
      liveWarn.textContent =
        `Target is ${r.lift.toFixed(2)}u off the ground — a chapter cannot carry that, ` +
        `so the emitted pose aims ${r.aim.toFixed(2)}° elsewhere. Re-fly a chapter to reset it.`;
    } else if (tight) {
      liveWarn.hidden = false;
      liveWarn.textContent = "At the orbit ceiling — the pose may be reeled in on arrival.";
    } else if (r.aim > AIM_WARN) {
      liveWarn.hidden = false;
      liveWarn.textContent = `Round trip is ${r.aim.toFixed(3)}° out.`;
    } else {
      liveWarn.hidden = true;
    }
  }

  function tick() {
    if (destroyed || !visible) return;
    const now = performance.now();
    if (now - lastDraw < FRAME_MS) return;
    if (!moved()) return;
    lastDraw = now;
    lastCamX = camera.position.x;
    lastCamY = camera.position.y;
    lastCamZ = camera.position.z;
    lastTgtX = controls.target.x;
    lastTgtY = controls.target.y;
    lastTgtZ = controls.target.z;
    paintLive();
  }

  select(null);
  paintLive();

  return {
    capture,
    poses: () => entries.map((e) => ({ chapter: e.chapter, residual: e.residual })),
    code,
    tick,
    setVisible(next) {
      visible = next;
      host.hidden = !next;
      // The readout is stale by however long the panel was shut, and `moved()`
      // will say nothing changed if the camera happens to be back where it was.
      if (next) paintLive();
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      root.removeEventListener("keydown", containKeys);
      root.removeEventListener("keyup", containKeys);
      // Everything else this file listens to is on a node inside `host`, so
      // removing it takes the listeners with it. The entries hold DOM that is
      // inside `host` too; dropping the array is what stops them being reachable.
      host.remove();
      entries.length = 0;
      selected = null;
    },
  };
}

// ---- Emission ---------------------------------------------------------------

/**
 * One chapter, in the shape `cities/sf.ts` already has: two-space indent, key
 * order `id, number, label, shortLabel, focus, description`, and a trailing
 * comma, because what you are pasting is an element of the `CHAPTERS` array
 * rather than a standalone declaration.
 */
export function emitChapter(chapter: Chapter, indent = "  "): string {
  const inner = `${indent}  `;
  return [
    `${indent}{`,
    `${inner}id: ${quote(chapter.id)},`,
    `${inner}number: ${quote(chapter.number)},`,
    `${inner}label: ${quote(chapter.label)},`,
    `${inner}shortLabel: ${quote(chapter.shortLabel)},`,
    emitFocus(chapter.focus, inner),
    emitDescription(chapter.description, inner),
    `${indent}},`,
  ].join("\n");
}

/** The session list as the declaration a city pack ends with. */
export function emitChapters(chapters: readonly Chapter[]): string {
  const body = chapters.map((c) => emitChapter(c)).join("\n");
  return `export const CHAPTERS: City["chapters"] = [\n${body}\n];\n`;
}

/**
 * The focus, on one line where it fits and one key per line where it does not.
 *
 * Every focus in both packs is on one line today, but they were typed at four
 * decimals and integer distances; a captured pose at five and two runs to about
 * 97 columns and a long one goes over. This is the only formatting rule in the
 * file and it is the one the rest of the tree follows by eye — there is no
 * formatter in `devDependencies` to defer to.
 */
function emitFocus(focus: Chapter["focus"], indent: string): string {
  const pairs = [
    `lat: ${num(focus.lat)}`,
    `lng: ${num(focus.lng)}`,
    `distance: ${num(focus.distance)}`,
    `height: ${num(focus.height)}`,
    `rotation: ${num(focus.rotation)}`,
  ];
  const flat = `${indent}focus: { ${pairs.join(", ")} },`;
  if (flat.length <= COLUMNS) return flat;
  return [`${indent}focus: {`, ...pairs.map((p) => `${indent}  ${p},`), `${indent}},`].join("\n");
}

function emitDescription(description: string, indent: string): string {
  // Collapsed to one line rather than escaped as `\n`. The field is a sentence
  // in a legend; a textarea that has been typed into with the Enter key still
  // means one paragraph, and a literal newline inside the quotes would not
  // compile.
  const text = quote(description.replace(/\s+/g, " ").trim());
  const flat = `${indent}description: ${text},`;
  if (flat.length <= COLUMNS) return flat;
  return `${indent}description:\n${indent}  ${text},`;
}

/**
 * Already-rounded numbers, printed short. `String` drops the trailing zeros
 * `toFixed` would leave, so a pose that lands on 0.4 emits `0.4` and matches
 * what is in the packs; nothing here reaches the magnitude where JavaScript
 * switches to exponent notation.
 */
function num(value: number): string {
  return String(value === 0 ? 0 : value);
}

function quote(text: string): string {
  return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}

function round(value: number, dp: number): number {
  return Number(value.toFixed(dp));
}

function pad(n: number): string {
  return String(n).padStart(2, "0");
}

function slug(label: string): string {
  const s = label
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "");
  return s === "" ? "chapter" : s;
}

// ---- DOM helpers ------------------------------------------------------------

function el(tag: string, className: string, text?: string): HTMLElement {
  const node = document.createElement(tag);
  node.className = className;
  if (text !== undefined) node.textContent = text;
  return node;
}

function field(parent: HTMLElement, name: string, placeholder: string): HTMLInputElement {
  const wrap = el("label", "f");
  wrap.append(el("span", "k", name));
  const input = document.createElement("input");
  input.type = "text";
  input.placeholder = placeholder;
  input.spellcheck = false;
  wrap.append(input);
  parent.append(wrap);
  return input;
}

function area(parent: HTMLElement, name: string, placeholder: string): HTMLTextAreaElement {
  const wrap = el("label", "f col");
  wrap.append(el("span", "k", name));
  const input = document.createElement("textarea");
  input.rows = 3;
  input.placeholder = placeholder;
  wrap.append(input);
  parent.append(wrap);
  return input;
}

/**
 * The panel's stylesheet.
 *
 * Written against the application's custom properties with the literal as the
 * fallback, so the tool looks like it belongs when it is mounted inside Tera
 * and still looks deliberate when it is mounted in a bare page. `:host` sets no
 * `all: initial` on purpose — that would reset the custom properties along with
 * everything else and the fallbacks would be all anyone ever saw.
 */
const CSS = `
:host { display: block; }
:host([hidden]) { display: none; }
* { box-sizing: border-box; }
.panel {
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 11px;
  line-height: 1.5;
  color: var(--ink, rgba(255, 255, 255, 0.78));
  background: var(--glass-strong, rgba(9, 13, 18, 0.86));
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r, 8px);
  padding: var(--s3, 12px);
  display: flex;
  flex-direction: column;
  gap: var(--s2, 8px);
}
.hd {
  text-transform: uppercase;
  letter-spacing: 0.09em;
  font-size: 10px;
  color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.live {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--s2, 8px);
  background: var(--glass-inset, rgba(255, 255, 255, 0.05));
  border-radius: var(--r-sm, 5px);
}
.row { white-space: pre; overflow-x: auto; }
.sub { color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.warn {
  margin-top: var(--s1, 4px);
  color: var(--amber-ink, #ffd68a);
  white-space: normal;
}
.btn {
  font: inherit;
  color: var(--ink, rgba(255, 255, 255, 0.78));
  background: var(--glass-inset, rgba(255, 255, 255, 0.05));
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r-sm, 5px);
  padding: var(--s2, 8px);
  cursor: pointer;
  transition: background var(--t, 150ms ease);
}
.btn:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn.primary { color: #14202b; background: var(--amber, #f2b134); border-color: transparent; }
.btn.primary:hover { background: var(--amber-lit, #ffc555); }
.form { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.form.off { opacity: 0.35; pointer-events: none; }
.f { display: flex; align-items: center; gap: var(--s2, 8px); }
.f.col { align-items: flex-start; }
.k {
  flex: 0 0 74px;
  color: var(--ink-3, rgba(255, 255, 255, 0.4));
  padding-top: 3px;
}
input, textarea {
  font: inherit;
  flex: 1 1 auto;
  min-width: 0;
  color: var(--ink, rgba(255, 255, 255, 0.78));
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r-sm, 5px);
  padding: 3px 6px;
  resize: vertical;
}
input:focus, textarea:focus { outline: 1px solid var(--amber, #f2b134); }
.list { display: flex; flex-direction: column; gap: 2px; max-height: 34vh; overflow-y: auto; }
.item {
  padding: var(--s1, 4px) var(--s2, 8px);
  border-radius: var(--r-sm, 5px);
  border: 1px solid transparent;
}
.item.sel {
  border-color: var(--amber, #f2b134);
  background: var(--glass-inset, rgba(255, 255, 255, 0.05));
}
.item-head { display: flex; align-items: center; gap: var(--s1, 4px); }
.name {
  font: inherit;
  flex: 1 1 auto;
  min-width: 0;
  text-align: left;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.ico {
  font: inherit;
  color: var(--ink-2, rgba(255, 255, 255, 0.56));
  background: none;
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r-sm, 5px);
  padding: 0 5px;
  cursor: pointer;
}
.ico:hover { color: var(--amber-ink, #ffd68a); }
.res { color: var(--ink-4, rgba(255, 255, 255, 0.26)); font-size: 10px; }
.bad { color: var(--amber-ink, #ffd68a); }
.actions { display: flex; gap: var(--s2, 8px); }
.actions .btn { flex: 1 1 0; }
.msg { min-height: 1.5em; color: var(--ink-2, rgba(255, 255, 255, 0.56)); }
.out {
  width: 100%;
  height: 18vh;
  white-space: pre;
  overflow-wrap: normal;
  overflow-x: auto;
}
.mono { font-variant-numeric: tabular-nums; }
`;
