/**
 * The per-scene half of the renderer: camera, controls, the light rig, camera
 * flights and picking.
 *
 * Everything here is per-scene rather than per-stage, because the city and an
 * office want different answers to all of it — different near/far planes,
 * different orbit limits, a fixed interior rig against a driven daylight one.
 * `Stage` keeps the renderer and the loop; a `SceneKit` is what a `StageScene`
 * is built out of. See CONTRACT.md §1.
 *
 * The kit *applies* a `LightingState`; it never works one out. Whoever owns
 * the sun — `Atmosphere` for a city, a fixed constant for an office — computes
 * the state and hands it over, and nothing writes back. That is the one
 * direction CONTRACT.md §4 asks for.
 *
 * It is also where a finger meets the map. `OrbitControls` gives one gesture
 * vocabulary to both a mouse and a thumb, and the two want different answers —
 * so the kit swaps a small input profile on every `pointerdown` according to
 * `event.pointerType`. See `applyPointerProfile`. Nothing about the desktop
 * changes; the touch values are only ever installed by a touch.
 *
 * The one thing that is *not* here is `touch-action`. `OrbitControls.connect()`
 * sets `touchAction = "none"` on the element it is handed, and `index.html`
 * also sets it on `#scene` in CSS. That duplication is deliberate: the CSS rule
 * is what covers the second or two between first paint and this module
 * existing, and a drag on the canvas in that window would otherwise scroll and
 * rubber-band the page instead.
 */

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts";

/**
 * How much slower one finger turns the camera than one mouse.
 *
 * `OrbitControls` maps a drag to `2π · delta / clientHeight` on **both** axes,
 * and it has one `rotateSpeed` for both, so this is a compromise between them.
 * Azimuth is forgiving: it wraps, and at 1.0 a 140px thumb arc on an 844px-tall
 * phone swings the board 60°, which is fine. Polar is not: `maxPolarAngle`
 * leaves about 85° of usable travel against a mapping that spends 360° over a
 * screen height, so a tilt hits its clamp in the first 200 px and the camera
 * feels like it is snapping rather than tilting. 0.7 stretches that to ~300 px
 * and costs the azimuth a swing it can afford — the vertical axis is the
 * binding constraint, and there is only one dial.
 */
const TOUCH_ROTATE_SCALE = 0.7;

/** How far a finger may wander and still be a tap, in CSS px. */
const TAP_SLOP = 12;
/** How long a finger may rest and still be a tap, in ms. */
const TAP_MS = 400;
/**
 * How long after a touch a `pointerType: "mouse"` event is assumed to be the
 * browser's compatibility replay of that touch rather than a real mouse.
 *
 * Chrome finishes a tap by re-dispatching it as mouse events for pages written
 * before pointer events existed, and the tail of that replay is a
 * `pointerout`/`pointerleave` pair whose `pointerType` is `"mouse"`. Measured on
 * the deployed build it lands about 32 ms after the tap; 800 ms is far enough
 * out to cover a loaded phone and still shorter than any deliberate reach for a
 * trackpad. Being wrong in this direction costs a hybrid laptop one stale card
 * until the mouse moves again; being wrong in the other direction means no
 * detail card can ever be read on a phone at all.
 */
const COMPAT_MOUSE_MS = 800;

/** Where the camera sits and what it looks at. Scene units, whatever they mean. */
export interface Pose {
  position: THREE.Vector3;
  target: THREE.Vector3;
}

/**
 * Picking, with the meaning left to the caller.
 *
 * `resolve` turns a raycast hit into whatever the caller considers picked — the
 * kit never reads `userData` itself, because it has no idea what is in there.
 */
export interface PickOptions<T> {
  /** A live array is fine; layers that rebuild theirs can pass a getter. */
  targets: THREE.Object3D[] | (() => THREE.Object3D[]);
  resolve(hit: THREE.Intersection): T | null;
  /** Fires only on change, including the change back to `null`. */
  onChange(picked: T | null): void;
}

export interface SceneKitOptions {
  scene: THREE.Scene;
  /** The element pointer coordinates are read against — the renderer's canvas. */
  dom: HTMLElement;
  fov?: number;
  near?: number;
  far?: number;
  minDistance?: number;
  maxDistance?: number;
  maxPolarAngle?: number;
  dampingFactor?: number;
  /** Shadow-camera half-extent, in scene units. */
  shadowExtent?: number;
  shadowMapSize?: number;
  shadowNear?: number;
  shadowFar?: number;
  shadowBias?: number;
  /**
   * What the shadow box is centred on, in scene units. Defaults to the origin,
   * which is almost never where the thing being lit actually is.
   *
   * A `shadowExtent` says how *big* the box is; it says nothing about where.
   * three centres a directional light's shadow camera on `light.target`, and a
   * fresh `DirectionalLight` targets a brand-new `Object3D` sitting at the
   * world origin — so without this every caller got a correctly-sized box in
   * the wrong place, and the two consumers here both have their origin off to
   * one side of what they want lit:
   *
   * - An office pack's origin is the **north-west corner of its slab**, not its
   *   middle. `lumbridge-hq` is 48 x 18 m against `shadowExtent: max(8, span *
   *   0.7)` = ±33.6 m, so a box on the origin covered x ∈ [-33.6, 33.6] of a
   *   building occupying x ∈ [0, 48]: the eastern 14.4 m — call it a third of
   *   the floor plate — fell outside the frustum entirely and neither cast a
   *   shadow nor received one. Half the box was spent on the empty ground west
   *   of the building.
   * - Scene space for a city is centred on `city.center`, and the comment on
   *   `boardRadius` in `scene.ts` already records that the Bay Area board runs
   *   forty kilometres down the peninsula from there. Same failure, one order
   *   of magnitude up.
   *
   * Pass the centre of what you want shadowed: `plan.bounds.center` for an
   * office, the mid-point of the projected board for a city.
   */
  shadowTarget?: THREE.Vector3;
  /**
   * How far along its direction the sun is placed, **from `shadowTarget`**. A
   * `LightingState` carries a unit direction and no distance, because distance
   * is a fact about the scale of the scene — 94 m per unit outdoors, 1 m per
   * unit indoors — and not about where the sun is.
   */
  sunDistance?: number;
  /** Flight rate, in fractions of the flight per second. */
  flightSpeed?: number;
  /** Cursor while something is picked. */
  hoverCursor?: string;
}

export interface SceneKit {
  camera: THREE.PerspectiveCamera;
  controls: OrbitControls;
  sun: THREE.DirectionalLight;
  hemisphere: THREE.HemisphereLight;
  ambient: THREE.AmbientLight;
  applyLighting(state: LightingState): void;
  /**
   * Move the fog planes and nothing else. See `SceneHandle.setAerialFog`.
   *
   * A no-op before the first `applyLighting`, deliberately: the fog's *colour*
   * is the rig's and this call does not carry one, so inventing a `THREE.Fog`
   * here would have to invent a colour to put in it. The rig arrives within the
   * frame either way — `createScene` applies one while it is still building —
   * and it brings the real distances with it.
   */
  setFogDistances(near: number, far: number): void;
  /** Jump. Used for the opening pose, where a flight from nowhere is nonsense. */
  setPose(pose: Pose): void;
  flyTo(pose: Pose): void;
  flying(): boolean;
  setPicking<T>(options: PickOptions<T>): void;
  /** Forget what is under the pointer and say so. */
  resetPick(): void;
  tick(dt: number): void;
  dispose(): void;
}

export function createSceneKit(options: SceneKitOptions): SceneKit {
  const { scene, dom } = options;
  const sunDistance = options.sunDistance ?? 240;
  const flightSpeed = options.flightSpeed ?? 0.65;
  const hoverCursor = options.hoverCursor ?? "pointer";

  const camera = new THREE.PerspectiveCamera(
    options.fov ?? 42,
    dom.clientWidth / Math.max(1, dom.clientHeight),
    options.near ?? 0.1,
    options.far ?? 900,
  );

  const controls = new OrbitControls(camera, dom);
  controls.enableDamping = true;
  const baseDamping = options.dampingFactor ?? 0.07;
  controls.dampingFactor = baseDamping;
  controls.maxPolarAngle = options.maxPolarAngle ?? Math.PI / 2.12; // never dip under the ground plane
  controls.minDistance = options.minDistance ?? 12;
  controls.maxDistance = options.maxDistance ?? 340;

  // ---- Input --------------------------------------------------------------

  /*
   * The gesture map is three.js's default and it is already the right one:
   * `touches = { ONE: ROTATE, TWO: DOLLY_PAN }`. One finger orbits; two fingers
   * pinch and drag *at the same time*, which is how every map on a phone
   * behaves and is why it is not split into separate two-finger modes here.
   *
   * Zoom needs nothing scaled to the board, and it is worth saying why, because
   * `scene.ts` records what happened the last time a distance was treated as a
   * constant. A pinch dollies by `(endSeparation / startSeparation) ^
   * zoomSpeed` — a *ratio* — and the wheel is `0.95 ^ delta`, also a ratio. Both
   * multiply the camera's current distance, so SoCal's 393-unit board and the
   * Bay Area's 1003-unit one zoom at the same rate per finger-millimetre with
   * no knowledge of either number. The only board-sized values in the gesture
   * path are `minDistance` and `maxDistance`, which the caller already derives.
   */

  /**
   * A mouse and a thumb are given different values for the three settings where
   * one answer cannot serve both, swapped in on `pointerdown` by `pointerType`.
   *
   * The alternative — pick the values once from a device probe — is wrong on
   * every laptop with a touchscreen, where both inputs are live at once and the
   * user switches between them mid-session. Keying off the event that is
   * actually happening is both simpler and correct, and it means the desktop
   * path is bit-for-bit what it was: the touch values do not exist until a
   * touch installs them.
   *
   * - **`screenSpacePanning`** is three's default `true`, which pans along the
   *   camera's own up vector. On a map seen from above that lifts the target
   *   off the ground as you drag, and the board slides away underneath. For two
   *   fingers it goes to `false`: pan in the ground plane, so the board tracks
   *   the fingers. Left alone for the mouse, where right-drag pan is
   *   long-standing behaviour and someone would notice it change.
   * - **`zoomToCursor`** goes on for touch so a pinch zooms toward the point
   *   between the fingers, which is the whole reason people pinch a particular
   *   neighbourhood. It moves `controls.target` as well as the camera, so the
   *   orbit centre drifts toward whatever was pinched — accepted deliberately,
   *   because on a map that drift *is* the interaction. The wheel keeps zooming
   *   to the centre of the view.
   * - **`rotateSpeed`**: see `TOUCH_ROTATE_SCALE`.
   */
  const mouseInput = {
    rotateSpeed: controls.rotateSpeed,
    screenSpacePanning: controls.screenSpacePanning,
    zoomToCursor: controls.zoomToCursor,
  };

  function applyPointerProfile(pointerType: string) {
    const touch = pointerType === "touch";
    controls.rotateSpeed = mouseInput.rotateSpeed * (touch ? TOUCH_ROTATE_SCALE : 1);
    controls.screenSpacePanning = touch ? false : mouseInput.screenSpacePanning;
    controls.zoomToCursor = touch ? true : mouseInput.zoomToCursor;
  }

  /**
   * A wheel arrives with no pointer, so it cannot announce its own type. Any
   * wheel at all means a mouse or a trackpad is in the room, and without this a
   * hybrid laptop that was last touched keeps the touch profile — and scrolls
   * toward wherever the finger happened to be, once, for no visible reason.
   *
   * `OrbitControls` registered its own wheel handler first, so the notch that
   * performs the reset is itself still anchored to the old point and only the
   * next one is centred. One notch, on a machine that has both inputs and used
   * both in the same breath; the fix for that costs finger-counting state and
   * buys a frame.
   */
  function onWheel() {
    applyPointerProfile("mouse");
  }
  dom.addEventListener("wheel", onWheel, { passive: true });

  /**
   * iOS pinches the *page* as well as the map.
   *
   * `touch-action: none` stops Safari's double-tap zoom and its scroll, but
   * WebKit's own `gesture*` events are not covered by it, and a two-finger
   * pinch that begins on the canvas can still scale the whole document —
   * leaving the UI enormous, half off-screen, and with no gesture left that
   * undoes it. Refusing the three of them costs nothing anywhere else: no other
   * engine implements the events at all.
   */
  const preventGesture = (event: Event) => event.preventDefault();
  dom.addEventListener("gesturestart", preventGesture);
  dom.addEventListener("gesturechange", preventGesture);
  dom.addEventListener("gestureend", preventGesture);

  // ---- Light rig ----------------------------------------------------------

  const sun = new THREE.DirectionalLight(0xffffff, 1);
  sun.castShadow = true;
  // The default is the device's, not a constant: a phone gets a smaller map for
  // the reasons written out in `stage.ts`. A caller that knows better — an
  // office, at a hundredth of the city's scale — passes its own.
  const mapSize = options.shadowMapSize ?? deviceProfile().shadowMapSize;
  sun.shadow.mapSize.set(mapSize, mapSize);
  sun.shadow.camera.near = options.shadowNear ?? 10;
  sun.shadow.camera.far = options.shadowFar ?? 520;
  const extent = options.shadowExtent ?? 170;
  sun.shadow.camera.left = -extent;
  sun.shadow.camera.right = extent;
  sun.shadow.camera.top = extent;
  sun.shadow.camera.bottom = -extent;
  sun.shadow.bias = options.shadowBias ?? -0.0012;
  /**
   * Without this line, none of the six numbers above exist.
   *
   * `OrthographicCamera` bakes its frustum into a projection matrix in the
   * constructor, and mutating `.left`/`.right`/`.near`/`.far` afterwards does
   * nothing until the matrix is rebuilt. three.js rebuilds it for a *spot*
   * light's shadow (`SpotLightShadow.updateMatrices` recomputes when the fov or
   * far changes) and **not** for a directional light's — `LightShadow.
   * updateMatrices` only does `lookAt` and `updateMatrixWorld`.
   *
   * So every caller here was configuring a shadow camera that was still
   * three's default `(-5, 5, 5, -5, 0.5, 500)`: a ten-unit box. The city asks
   * for `shadowExtent: boardSpan * 0.75`, which is ±752 units on the Bay Area
   * board, and got ±5 — a shadow map covering a square about the size of one
   * house, somewhere near the origin. The office asks for ±34 m and got ±5 m.
   */
  sun.shadow.camera.updateProjectionMatrix();

  /**
   * One update now, so a reader that recomputes for itself sees the right pose.
   *
   * This is **not** what makes the shadow correct — the paragraph above is: the
   * target has to be *in the scene* so `Object3D.updateMatrixWorld`'s traversal
   * reaches it, and `WebGLRenderer.render` runs that traversal before
   * `shadowMap.render()` on every frame. That is the whole mechanism.
   *
   * What this line buys is narrower and worth being honest about. Nothing that
   * reads `sun.shadow.camera` before the first render learns anything from it —
   * three only writes that camera's pose inside `LightShadow.updateMatrices`,
   * which runs during a render. It matters to a reader that recomputes from the
   * target itself: a `DirectionalLightHelper`, or a manual
   * `sun.shadow.updateMatrices(sun)` in a capture pass.
   */
  const shadowTarget = new THREE.Vector3();
  if (options.shadowTarget) shadowTarget.copy(options.shadowTarget);
  sun.target.position.copy(shadowTarget);

  const hemisphere = new THREE.HemisphereLight(0xffffff, 0x808080, 1);
  const ambient = new THREE.AmbientLight(0xffffff, 0.3);
  scene.add(sun, sun.target, hemisphere, ambient);
  // Belt and braces for anything that reads the shadow camera before the first
  // render — a capture pass, a debug helper — where the renderer's own
  // traversal has not happened yet. After that, the traversal owns it.
  sun.target.updateMatrixWorld();

  const sunDirection = new THREE.Vector3();
  const dome = makeSkyDome();
  let domeAttached = false;

  function applyLighting(state: LightingState) {
    const [dx, dy, dz] = state.sun.direction;
    sunDirection.set(dx, dy, dz);
    // A zero direction would put the sun inside the ground and black the scene
    // out; leaving it where it was is the kinder failure.
    if (sunDirection.lengthSq() > 0) {
      /*
       * `sunDistance` out from the **target**, not from the origin.
       *
       * A directional light's position is not physical — the shading only reads
       * `position - target` as a direction — but the shadow camera *is* placed
       * at it, and its `near`/`far` are measured from there along the view
       * axis. Off the origin those two facts fight: `officeScene.ts` asks for
       * `sunDistance: max(24, span * 1.4)`, so on a 12 m studio the sun sits
       * 24 units from the origin while the slab centre it is aimed at can be
       * 8 m away in some other direction — a light that is beside or behind the
       * building rather than above it, with the near plane cutting into the
       * geometry it is supposed to be shadowing.
       *
       * Anchoring to the target makes light-to-target exactly `sunDistance`
       * whatever the direction, which is the invariant every caller's
       * `shadowNear`/`shadowFar` was picked against.
       */
      sun.position.copy(sunDirection.normalize().multiplyScalar(sunDistance)).add(shadowTarget);
    }
    sun.color.setHex(state.sun.color);
    sun.intensity = state.sun.intensity;

    hemisphere.color.setHex(state.hemisphere.sky);
    hemisphere.groundColor.setHex(state.hemisphere.ground);
    hemisphere.intensity = state.hemisphere.intensity;

    ambient.color.setHex(state.ambient.color);
    ambient.intensity = state.ambient.intensity;

    // A null sky leaves the background alone entirely, which is what an office
    // wants: it has walls, and whatever is behind them is not sky.
    if (state.sky) {
      if (!domeAttached) {
        scene.add(dome);
        domeAttached = true;
      }
      const u = dome.material.uniforms;
      (u.uTop!.value as THREE.Color).setHex(state.sky.top, THREE.LinearSRGBColorSpace);
      (u.uHorizon!.value as THREE.Color).setHex(state.sky.horizon, THREE.LinearSRGBColorSpace);
      (u.uSunColor!.value as THREE.Color).setHex(state.sun.color, THREE.LinearSRGBColorSpace);
      (u.uSunDirection!.value as THREE.Vector3).set(dx, dy, dz).normalize();
      /**
       * The glow follows the *key*, not the direction, and that is what keeps
       * it off the night sky.
       *
       * `atmosphere.ts` floors the sun's direction at `shadowFloorDeg` — seven
       * degrees — so that the shadow camera stays usable, which means the
       * vector in a `LightingState` never actually sets. Taken literally it
       * would park a sunrise on the horizon all night, at the azimuth the sun
       * went down at. The intensity is the honest signal: it collapses through
       * dusk and what is left at 2 a.m. is the moon's, so scaling by it gives a
       * glow that fades out with the daylight it belongs to.
       */
      u.uSunGlow!.value = Math.min(1, Math.max(0, state.sun.intensity / SUN_GLOW_FULL_INTENSITY));

      /**
       * The moon, if this rig carries one. See `LightingMoon`.
       *
       * A drawn object and nothing else: no light is constructed here, no light
       * is modified here, and the key after dark is already `state.sun`, which
       * `atmosphere.ts` handed to the moon. Absent or `null` sets the visibility
       * to zero and the shader's branch never runs, which is what an office rig
       * and the hard-coded fallback both get.
       */
      const moon = state.moon ?? null;
      u.uMoonVisibility!.value = moon ? Math.min(1, Math.max(0, moon.visibility)) : 0;
      if (moon) {
        (u.uMoonDirection!.value as THREE.Vector3).fromArray(moon.direction).normalize();
        (u.uMoonLimb!.value as THREE.Vector3).fromArray(moon.brightLimb).normalize();
        u.uMoonRadius!.value = Math.max(1e-4, moon.angularRadius * MOON_ANGULAR_EXAGGERATION);
        u.uMoonPhase!.value = Math.min(1, Math.max(0, moon.illuminated));
      }
    } else if (domeAttached) {
      scene.remove(dome);
      domeAttached = false;
    }

    if (!state.fog) {
      scene.fog = null;
    } else if (scene.fog instanceof THREE.Fog) {
      scene.fog.color.setHex(state.fog.color);
      scene.fog.near = state.fog.near;
      scene.fog.far = state.fog.far;
    } else {
      scene.fog = new THREE.Fog(state.fog.color, state.fog.near, state.fog.far);
    }
  }

  /**
   * The camera-dependent half of `applyLighting`, on its own.
   *
   * Two assignments, no colour, no lights, no background. Everything else in
   * `applyLighting` is a consequence of the sun and the sky, which do not change
   * because somebody dragged the board — see `SceneHandle.setAerialFog` for the
   * argument and `atmosphere.ts`'s `AerialFog` for why this may never grow a
   * third parameter.
   */
  function setFogDistances(near: number, far: number) {
    if (!(scene.fog instanceof THREE.Fog)) return;
    scene.fog.near = near;
    scene.fog.far = far;
  }

  // ---- Camera flights -----------------------------------------------------

  const from: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
  const to: Pose = { position: new THREE.Vector3(), target: new THREE.Vector3() };
  let flying = false;
  let flightT = 0;

  const motionQuery =
    typeof window.matchMedia === "function"
      ? window.matchMedia("(prefers-reduced-motion: reduce)")
      : null;
  let reducedMotion = motionQuery?.matches ?? false;

  function onMotionChange(event: MediaQueryListEvent) {
    reducedMotion = event.matches;
    // Mid-flight when the preference flips: land now rather than finish the arc.
    if (reducedMotion && flying) setPose(to);
  }
  motionQuery?.addEventListener("change", onMotionChange);

  function setPose(pose: Pose) {
    flying = false;
    camera.position.copy(pose.position);
    controls.target.copy(pose.target);
    controls.update();
  }

  /**
   * A chapter flight is the largest motion this app makes: the whole field of
   * view sweeps and rotates for a second and a half, unrequested by anyone who
   * only clicked a name in a list. That is the case `prefers-reduced-motion`
   * exists for, so under it the flight becomes a cut. `main.ts` already reached
   * the same conclusion for a minimap seek and says so there.
   *
   * Damping is left alone, and the distinction is worth stating: damping only
   * ever follows a finger or a mouse that is currently moving, and it settles
   * in a few frames after it stops. It is the response to a gesture, not motion
   * the interface started on its own.
   */
  function flyTo(pose: Pose) {
    if (reducedMotion) {
      setPose(pose);
      return;
    }
    from.position.copy(camera.position);
    from.target.copy(controls.target);
    to.position.copy(pose.position);
    to.target.copy(pose.target);
    flightT = 0;
    flying = true;
  }

  // ---- Picking ------------------------------------------------------------

  const raycaster = new THREE.Raycaster();
  const pointer = new THREE.Vector2();
  let picking: PickOptions<unknown> | null = null;
  let picked: unknown = null;
  // The raycast runs at most once a frame, off the last pointer position,
  // rather than once per `pointermove` — a fast drag across the canvas fires
  // dozens of those between two frames and every one of them but the last is
  // thrown away.
  let pointerDirty = false;

  function aimAt(clientX: number, clientY: number) {
    const rect = dom.getBoundingClientRect();
    pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
    pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
    pointerDirty = true;
  }

  // A moving finger is not hovering; see the tap block below.
  function onPointerMove(event: PointerEvent) {
    if (event.pointerType === "touch") return;
    aimAt(event.clientX, event.clientY);
  }
  dom.addEventListener("pointermove", onPointerMove);

  /**
   * There is no hover on a touch screen, and pretending otherwise is how a map
   * ends up flashing a detail card for every marker a thumb happens to sweep
   * across on its way to turning the board. A finger only reports where it is
   * *while it is pressed*, which is exactly when it is doing something else.
   *
   * So touch picks on a tap and nothing else: press, lift within `TAP_SLOP` and
   * `TAP_MS`, and that point is picked. Anything longer or further is a gesture
   * and picks nothing. The pick then survives the finger leaving the glass — a
   * card raised by a tap has to stay up to be read — and is cleared by the next
   * touch anywhere, which is what makes tapping empty water the way to dismiss
   * it.
   *
   * 12 px of slop, not zero: a thumb pivots while it presses, and a tap that
   * wandered a millimetre is still a tap. Past that the camera has visibly
   * moved, and something that moved the map should not also have selected
   * something on it.
   */

  /** The pointer id of a candidate tap; -1 for none, -2 once a second finger lands. */
  let tapPointer = -1;
  let tapX = 0;
  let tapY = 0;
  let tapAt = 0;
  /** When the glass was last touched, in `event.timeStamp` units. */
  let lastTouchAt = Number.NEGATIVE_INFINITY;

  function onPointerDown(event: PointerEvent) {
    applyPointerProfile(event.pointerType);
    if (event.pointerType !== "touch") return;
    lastTouchAt = event.timeStamp;
    resetPick();
    tapPointer = tapPointer === -1 ? event.pointerId : -2;
    tapX = event.clientX;
    tapY = event.clientY;
    tapAt = event.timeStamp;
  }
  dom.addEventListener("pointerdown", onPointerDown);

  function onPointerUp(event: PointerEvent) {
    if (event.pointerType !== "touch") return;
    lastTouchAt = event.timeStamp;
    const wasTap =
      tapPointer === event.pointerId &&
      event.timeStamp - tapAt <= TAP_MS &&
      Math.hypot(event.clientX - tapX, event.clientY - tapY) <= TAP_SLOP;
    tapPointer = -1;
    if (wasTap) aimAt(event.clientX, event.clientY);
  }
  dom.addEventListener("pointerup", onPointerUp);
  dom.addEventListener("pointercancel", onPointerUp);

  function resetPick() {
    pointerDirty = false;
    if (picked === null) return;
    const wasPicking = picking;
    picked = null;
    dom.style.cursor = "";
    wasPicking?.onChange(null);
  }

  /*
   * Not for touch, and not for the compatibility mouse either.
   *
   * A finger lifting fires `pointerleave` immediately after `pointerup`, so
   * honouring that would wipe the pick a tap had just made, in the same frame,
   * every time. That much was anticipated. What was not is the *second* leave:
   * Chrome replays a finished tap as legacy mouse events, and the recorded tail
   * of a real tap on the canvas is
   *
   *   pointerdown/touch, pointerup/touch, pointerout/touch, pointerleave/touch,
   *   mousemove, click/touch, pointerout/MOUSE, pointerleave/MOUSE
   *
   * — so the last event of a tap is a `pointerleave` claiming to be a mouse,
   * about 32 ms later. Filtering on `pointerType` alone let that one through,
   * which called `resetPick()` and fired `onChange(null)`: the card was written
   * to the page and blanked before a thumb had left the glass, and no detail
   * card of any kind could be read on a phone. It is a clock that tells these
   * apart, not a type.
   */
  function onPointerLeave(event: PointerEvent) {
    if (event.pointerType === "touch") return;
    if (event.timeStamp - lastTouchAt < COMPAT_MOUSE_MS) return;
    resetPick();
  }
  dom.addEventListener("pointerleave", onPointerLeave);

  function repick() {
    if (!picking || !pointerDirty) return;
    pointerDirty = false;
    const targets = typeof picking.targets === "function" ? picking.targets() : picking.targets;
    const hit = targets.length === 0 ? undefined : raycastFirst(targets);
    const next = hit ? picking.resolve(hit) : null;
    if (next === picked) return;
    picked = next;
    dom.style.cursor = next ? hoverCursor : "";
    picking.onChange(next);
  }

  function raycastFirst(targets: THREE.Object3D[]): THREE.Intersection | undefined {
    raycaster.setFromCamera(pointer, camera);
    return raycaster.intersectObjects(targets, false)[0];
  }

  return {
    camera,
    controls,
    sun,
    hemisphere,
    ambient,
    applyLighting,
    setFogDistances,
    setPose,
    flyTo,
    flying: () => flying,
    setPicking(pick) {
      picking = pick as PickOptions<unknown>;
    },
    resetPick,
    tick(dt) {
      /**
       * `OrbitControls` damps per *frame*, not per second: every `update()`
       * moves the camera `dampingFactor` of the way to where the input asked
       * for. So the same 0.07 is a different feel on every refresh rate — twice
       * as slow on a phone that has dropped to 30 fps, and 2.4x as fast on a
       * 144 Hz monitor, which is why the settle on a laptop and the settle on a
       * handset never matched.
       *
       * Re-deriving it from the frame time fixes both ends with the same line.
       * At exactly 60 fps this returns `baseDamping` unchanged, so the desktop
       * default it was tuned at is preserved to the digit; away from 60 it
       * holds the wall-clock settle constant instead. `stage.ts` clamps `dt` to
       * 50 ms, so the exponent cannot run away after a stall and snap the
       * camera.
       */
      controls.dampingFactor =
        dt > 0 ? Math.min(1, 1 - (1 - baseDamping) ** (dt * 60)) : baseDamping;
      if (flying) {
        flightT = Math.min(1, flightT + dt * flightSpeed);
        // easeInOutCubic — a flight that starts and lands gently
        const e = flightT < 0.5 ? 4 * flightT ** 3 : 1 - (-2 * flightT + 2) ** 3 / 2;
        camera.position.lerpVectors(from.position, to.position, e);
        controls.target.lerpVectors(from.target, to.target, e);
        if (flightT >= 1) flying = false;
        // The pointer has not moved but the world under it has.
        pointerDirty = true;
      }
      controls.update();
      repick();
    },
    dispose() {
      dom.removeEventListener("pointermove", onPointerMove);
      dom.removeEventListener("pointerdown", onPointerDown);
      dom.removeEventListener("pointerup", onPointerUp);
      dom.removeEventListener("pointercancel", onPointerUp);
      dom.removeEventListener("pointerleave", onPointerLeave);
      dom.removeEventListener("wheel", onWheel);
      dom.removeEventListener("gesturestart", preventGesture);
      dom.removeEventListener("gesturechange", preventGesture);
      dom.removeEventListener("gestureend", preventGesture);
      motionQuery?.removeEventListener("change", onMotionChange);
      dom.style.cursor = "";
      picking = null;
      controls.dispose();
      // `sun.target` was added as a scene child in its own right, so removing
      // the light does not take it with it — a kit torn down and rebuilt would
      // otherwise leave one empty Object3D in the scene per cycle.
      scene.remove(sun, sun.target, hemisphere, ambient);
      sun.dispose();
      hemisphere.dispose();
      ambient.dispose();
      if (domeAttached) scene.remove(dome);
      domeAttached = false;
      dome.geometry.dispose();
      dome.material.dispose();
    },
  };
}

/**
 * The sun intensity at which the sky's glow around it is at full strength.
 *
 * `atmosphere.ts`'s day stops sit at 2.6, so this is reached a little before
 * noon and held; everything below it — the whole of dusk and all of the
 * night — scales down from there. See the note at the call site for why the
 * intensity and not the direction is what the glow is allowed to read.
 */
const SUN_GLOW_FULL_INTENSITY = 1.9;

/**
 * The moon's own colour, and how much of the dark side earthshine leaves visible.
 *
 * Not white. The full moon measures around 4100 K to the eye — a warm grey, and
 * a pure-white disc against a deep blue night sky is the single most common tell
 * of a synthetic sky. 0.045 for the earthshine is roughly what a young crescent
 * over a cloudy earth actually shows, and it is what makes the *whole* disc
 * findable at a thin phase instead of just the sliver.
 */
const MOON_COLOR = 0xf2ecdc;
const MOON_EARTHSHINE = 0.045;

/**
 * How much larger than life the disc is drawn.
 *
 * The moon is half a degree across. At this camera's 42-degree vertical field
 * over a 1000-pixel viewport that is nine pixels — smaller than the aeroplane
 * glyphs, smaller than a satellite dot at some sizes, and indistinguishable from
 * a stuck pixel. Drawn true, the four hundred lines of Meeus behind its position
 * buy a picture of nothing.
 *
 * So it is exaggerated, and this is the same trade the rest of this codebase
 * already makes and writes down: `clouds.ts` draws stratocumulus five to twelve
 * kilometres wide because an honest cumulus is two pixels, `flights.ts` draws an
 * aeroplane glyph hundreds of times its true span, and `atmosphere.ts` floors
 * visibility at 4.5 km because honest fog is a white rectangle. A map is looked
 * at from outside the sky it is depicting.
 *
 * 2.6 rather than more, because the *phase* is the information here and a phase
 * is legible well before a disc becomes a cartoon: at 2.6 the disc is about
 * 24 pixels of a 1000-pixel frame, a crescent is unmistakable, and nobody reads
 * it as a second sun.
 */
const MOON_ANGULAR_EXAGGERATION = 2.6;

/**
 * The sky, as a mesh in the world rather than a gradient on the screen.
 *
 * ## What was wrong with the gradient
 *
 * `Scene.background` with a plain 2D texture is drawn by three onto a
 * screen-filling quad: the top of the *viewport* is `skyTop` and the bottom of
 * the viewport is `skyHorizon`, whatever the camera happens to be doing. That
 * is not a sky, it is a wash, and on a map board — where the camera is almost
 * always tilted down and the true horizon sits high in the frame — it fails in
 * a way you can name from a screenshot:
 *
 *   - At dusk the warm band appeared along the **bottom** of the picture, under
 *     the board, while the actual horizon at the top of the frame stayed the
 *     deep blue of the zenith. The sunset was rendered upside down.
 *   - The world's far edge is faded out by `THREE.Fog` into `fog.color`, which
 *     `atmosphere.ts` makes the horizon colour exactly so the two meet. They
 *     could not meet, because the horizon colour was not at the horizon, so
 *     there was a visible seam wherever the ground ran out — and
 *     `interiors/daylight.ts` documents pinning its own horizon stop to the fog
 *     colour to hide it, which is the symptom stated in the source.
 *
 * ## Why a dome and not an equirectangular background
 *
 * three renders `Scene.background` in world space only for a `CubeTexture` or a
 * PMREM (`CubeUVReflectionMapping`); a 2D texture tagged
 * `EquirectangularReflectionMapping` still takes the screen-space plane path.
 * Getting a world-oriented sky out of the background slot therefore means
 * running a `PMREMGenerator` over a gradient on every colour change, which is
 * the sharpest thing in the frame put through a blur chain built to destroy
 * detail. A dome is one draw call, a thousand triangles, and it can also do the
 * two things a gradient texture cannot: put the glow **around the sun** rather
 * than uniformly around the compass, and keep the horizon band tight.
 *
 * ## How it sits in the scene
 *
 * `depthTest: false` with `renderOrder` far negative, which is exactly how
 * three's own background box works: it is drawn first, writes no depth, and
 * every other object in the scene paints over it. That makes the radius
 * irrelevant — nothing is ever compared against it — so the sphere is a unit
 * one, recentred on the camera in `onBeforeRender`, and can never be clipped by
 * a near or far plane however large the board is.
 *
 * ## Colour, and why nothing is converted
 *
 * The components are written straight out with no tone mapping and no output
 * transform, which reproduces exactly what the old texture path did: an
 * sRGB-tagged background is decoded on sample and re-encoded on write, and
 * three sets `toneMapped = false` for it. So `LightingState.sky` is displayed
 * as the number the atmosphere table wrote, which is what
 * `render/toneMapping.test.ts` asserts about those columns. Hence
 * `setHex(hex, LinearSRGBColorSpace)` at the call site: it loads the byte
 * values without a colour-space conversion, because the shader is not
 * performing one either.
 */
function makeSkyDome(): THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial> {
  const material = new THREE.ShaderMaterial({
    uniforms: {
      uTop: { value: new THREE.Color(0x8fb8d8) },
      uHorizon: { value: new THREE.Color(0xd9e6ee) },
      uSunDirection: { value: new THREE.Vector3(0, 1, 0) },
      uSunColor: { value: new THREE.Color(0xffffff) },
      uSunGlow: { value: 0 },
      uMoonDirection: { value: new THREE.Vector3(0, 1, 0) },
      uMoonLimb: { value: new THREE.Vector3(1, 0, 0) },
      uMoonRadius: { value: 0.012 },
      uMoonPhase: { value: 1 },
      /** 0 draws nothing, and is what an office and a fallback rig get. */
      uMoonVisibility: { value: 0 },
      uMoonColor: { value: new THREE.Color(MOON_COLOR) },
    },
    vertexShader: `
varying vec3 vDirection;
void main() {
  // The dome is only ever translated, never rotated or scaled, so a unit
  // sphere's own vertex position is already the world direction it stands for.
  vDirection = position;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
    fragmentShader: `
uniform vec3 uTop;
uniform vec3 uHorizon;
uniform vec3 uSunDirection;
uniform vec3 uSunColor;
uniform float uSunGlow;
uniform vec3 uMoonDirection;
uniform vec3 uMoonLimb;
uniform float uMoonRadius;
uniform float uMoonPhase;
uniform float uMoonVisibility;
uniform vec3 uMoonColor;
varying vec3 vDirection;

void main() {
  vec3 direction = normalize( vDirection );
  float height = direction.y;

  /*
   * The exponent is what makes this read as air rather than as a ramp. A
   * linear zenith-to-horizon blend spends half its colour change in the top
   * forty-five degrees of sky, where there is nothing to see; a real sky does
   * almost all of it in the first fifteen degrees above the horizon, which is
   * also the only part of it a map board ever has in frame.
   */
  vec3 color = mix( uHorizon, uTop, pow( clamp( height, 0.0, 1.0 ), 0.42 ) );

  /*
   * Below the horizon there is no sky, and what is painted there has one job:
   * be *not brighter* than the horizon it sits under, for the office floors and
   * ocean that normally cover it.
   *
   * The band immediately under the horizontal is the exception, and it is the
   * only part of this a board ever shows. The sea is a flat plane, so however
   * wide it is made the camera's own far plane cuts it off at an elevation some
   * degrees *below* the horizontal — around 12 degrees in a plan view of the
   * state. atmosphere.ts fogs it to exactly the horizon colour by the time it
   * gets there, which is what makes the water dissolve into sky rather than end
   * in an edge. So whatever the dome paints at that same angle is the other half
   * of that join, and it has to be the same number.
   *
   * It was not: the darkening used to ramp in over the first 11.5 degrees, so
   * the sea arrived at the horizon colour and met a sky already at 0.82 of it.
   * That is a hard step of a fifth of the brightness, drawn all the way across
   * the frame — 1.7% of a desktop window and **10.5% of a phone held upright**,
   * where the camera pitches down far enough to put the whole band in shot.
   *
   * Holding the horizon's own colour for the first 30 degrees costs nothing that
   * the darkening was for: a floor or an ocean covers that band whenever there
   * is one, and where there is not, matching is the entire point.
   */
  color = mix( color, uHorizon * 0.82, smoothstep( 0.50, 0.98, - height ) );

  /*
   * Two glows, one warm quarter of sky.
   *
   * The first is round and centred on the sun: the aureole, tight enough to
   * say where the sun is without drawing a disc — a hard white disc at map
   * scale reads as a rendering artefact, and the environment map already
   * carries a real sun lobe for anything reflective to catch.
   *
   * The second hugs the horizon and falls off with the *azimuth* to the sun,
   * which is the half of a sunset the atmosphere's colour table cannot
   * express: its keyframes are one horizon colour for the whole compass, so
   * without this the sky behind the viewer is as orange as the sky the sun is
   * setting into.
   */
  float toSun = max( dot( direction, uSunDirection ), 0.0 );
  float aureole = pow( toSun, 5.0 ) * 0.22 + pow( toSun, 90.0 ) * 0.30;

  vec2 flat0 = normalize( vec2( direction.x, direction.z ) + 1e-5 );
  vec2 flatSun = normalize( vec2( uSunDirection.x, uSunDirection.z ) + 1e-5 );
  float azimuth = max( dot( flat0, flatSun ), 0.0 );
  float band = exp( - abs( height ) * 6.0 ) * pow( azimuth, 2.5 ) * 0.18;

  color += uSunColor * uSunGlow * ( aureole + band );

  /*
   * The moon, drawn.
   *
   * Everything here is angular, which is what the dome preserves and the only
   * reason this can live in the sky shader at all: direction is a unit vector
   * and so is uMoonDirection, so the offset between them, projected onto the
   * two axes of the visible disc, is the position on that disc in radians. Over
   * half a degree the small-angle approximation is exact to eight decimal
   * places, so no trigonometry is needed and none is done.
   *
   * uMoonLimb is perpendicular to the moon and points at the middle of the lit
   * side — the sun's true direction with the moon's own component removed, which
   * atmosphere.ts computes because it is the one module holding an unfloored
   * sun. du is therefore measured along the phase and dv across it.
   */
  if ( uMoonVisibility > 0.0 ) {
    vec3 across = cross( uMoonDirection, uMoonLimb );
    float du = dot( direction, uMoonLimb ) / uMoonRadius;
    float dv = dot( direction, across ) / uMoonRadius;
    float onThisSide = step( 0.0, dot( direction, uMoonDirection ) );
    float rr = du * du + dv * dv;

    /*
     * The terminator is an ellipse, not a line, and the whole of it is one
     * expression: the boundary sits at du = (1 - 2k) * sqrt(1 - dv^2), which
     * is a straight line through the centre at half phase, the full limb at
     * full, and nothing at new. Softened over a fifteenth of the disc because a
     * hard step on an object sixty pixels across aliases into a staircase, and
     * because the real terminator is a lit horizon rather than an edge.
     */
    float boundary = ( 1.0 - 2.0 * uMoonPhase ) * sqrt( max( 0.0, 1.0 - dv * dv ) );
    float lit = smoothstep( boundary - 0.07, boundary + 0.07, du );

    /*
     * Limb darkening, and earthshine.
     *
     * The moon is not a flat disc: brightness falls toward the edge as the
     * surface turns away, which is what stops it reading as a sticker. And the
     * dark side is not black — it is lit by a full earth, which is why the whole
     * disc of a young crescent is visible on a clear night. Both are cheap and
     * both are the difference between a moon and a circle.
     */
    float curve = sqrt( max( 0.0, 1.0 - rr ) );
    float shading = mix( ${MOON_EARTHSHINE.toFixed(3)}, 0.55 + 0.45 * curve, lit );
    // The disc's own edge, feathered by the same amount as the terminator.
    float disc = ( 1.0 - smoothstep( 0.86, 1.0, rr ) ) * onThisSide;
    color += uMoonColor * uMoonVisibility * shading * disc;

    /*
     * A halo, because a moon with a hard edge and nothing around it reads as a
     * decal. Kept to a twentieth of the disc's own brightness and to a few
     * degrees of sky: the failure mode this file is downstream of is a night
     * layer that lifts the whole frame, and a wide bloom is exactly that.
     */
    float toMoon = max( dot( direction, uMoonDirection ), 0.0 );
    color += uMoonColor * uMoonVisibility * pow( toMoon, 400.0 ) * 0.05;
  }

  gl_FragColor = vec4( color, 1.0 );
}
`,
    side: THREE.BackSide,
    depthTest: false,
    depthWrite: false,
    fog: false,
    toneMapped: false,
  });

  const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), material);
  mesh.name = "sky";
  // First in the opaque list, before anything that could occlude it.
  mesh.renderOrder = -1000;
  // The pose is written below, after culling would have run, so culling must
  // not be allowed to run: the bounding sphere three would test is the one at
  // the origin, which is nowhere near where this is drawn.
  mesh.frustumCulled = false;
  mesh.matrixAutoUpdate = false;
  mesh.onBeforeRender = (_renderer, _scene, camera) => {
    mesh.matrixWorld.copyPosition(camera.matrixWorld);
  };
  return mesh;
}
