/**
 * The stage: one renderer, one loop, one canvas, and a scene you can swap.
 *
 * Stage owns *only* the WebGL renderer, the RAF loop and resize. It has no
 * camera, no lights and no picking — those are per-scene and live in
 * `SceneKit`, because a city and an office cannot share a `THREE.Scene` at all:
 * SF's `latScale` puts one scene unit at ~94 m with 3.6x vertical
 * exaggeration, and an office renders at 1 unit = 1 m.
 *
 * The swap **retains and pauses** the outgoing scene rather than disposing it.
 * That is a measured choice, not a preference: SF's heightfield is 484 x 696
 * lattice points and `world.ts` records a ~1.0 s build, so throwing the city
 * away every time somebody steps into an office means paying a second of
 * rebuild on the way back out. Stage therefore disposes nothing it did not
 * create — whoever built a `StageScene` disposes it, when they actually mean
 * to be rid of it. See CONTRACT.md §1.
 *
 * ## One Stage per canvas, for the life of the page
 *
 * The corollary, and it is not optional. `createScene` used to build a Stage of
 * its own per city, so every switch between the Bay Area and SoCal constructed
 * another `WebGLRenderer` on the same GL context and abandoned the last one.
 * `WebGLRenderer.dispose()` frees **no textures at all** — read it in
 * `three.module.js`: `background`, `renderLists`, `renderStates`, `properties`,
 * `objects`, `programCache` and the rest, and not `textures` — so each switch
 * orphaned the renderer's seven 1x1 defaults and its 2048² shadow map. That is
 * 16.8 MB of GPU memory per switch, invisible to a JS heap snapshot, plus about
 * ten shader programs, growing monotonically and never plateauing: ten switches
 * measured 88 live textures and 117 live programs against 0 calls to
 * `gl.deleteTexture`.
 *
 * There is no version of this that `dispose()` fixes, because the leaked
 * textures are the renderer's own and it does not free them. The only fix is
 * not to build a second renderer, so the app constructs one Stage next to the
 * canvas and hands it to every scene it builds. `createScene` takes a `Stage`
 * rather than a canvas for that reason, and the office already worked this way.
 *
 * It also decides how much machine there is to spend, because the renderer is
 * what spends it: `deviceProfile()` below is the single place that answers
 * "is this a phone", and `scenekit.ts` imports it rather than asking again, so
 * the two halves of the engine cannot end up with different opinions about the
 * same handset.
 */

import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

export interface StageScene {
  scene: THREE.Scene;
  camera: THREE.PerspectiveCamera;
  controls: OrbitControls;
  onEnter?(): void;
  onExit?(): void;
  tick(dt: number, elapsed: number): void;
  dispose(): void;
}

export interface Stage {
  renderer: THREE.WebGLRenderer;
  /**
   * Show a scene, or `null` for none at all.
   *
   * `null` is what a caller about to dispose a scene passes first: the loop
   * stops touching it that instant, which is the whole of the "nothing ticks a
   * half-disposed scene" rule, and it costs no renderer state. The stage keeps
   * running with nothing to draw, which is exactly what it does between the
   * first frame and the first city.
   */
  setScene(s: StageScene | null): void;
  current(): StageScene | null;
  /**
   * Retire the renderer and the loop.
   *
   * Called once, at the end of the page's life, by whoever built it — which is
   * **not** a scene. See the note at the top of this file about what
   * `WebGLRenderer.dispose()` does and does not free.
   */
  dispose(): void;
}

export interface StageOptions {
  antialias?: boolean;
  /** Device pixel ratio ceiling. Defaults to `deviceProfile().maxPixelRatio`. */
  maxPixelRatio?: number;
  shadows?: boolean;
  /**
   * Tone mapping exposure. Defaults to `DEFAULT_TONE_MAPPING_EXPOSURE`.
   *
   * Live afterwards as `stage.renderer.toneMappingExposure` — the renderer is
   * on the `Stage` for exactly this kind of reason, and a `setExposure` method
   * would widen the interface CONTRACT.md §1 pins down for one assignment.
   */
  exposure?: number;
}

/**
 * The exposure the world is tuned at.
 *
 * ACES takes linear radiance, divides by 0.6, runs the RRT+ODT fit and saturates,
 * so `toneMappingExposure` is a photographic stop dial and not a brightness
 * slider: at 1.0 an 18% grey card lands on 0.5 sRGB, which is the definition of
 * the curve being *neutral*. 1.15 is a little over a fifth of a stop above
 * neutral, and it is there because ACES darkens the bottom of the range — a
 * linear 0.02 that used to display at 0.152 comes out at 0.080 — and this world
 * spends a third of its day at night. The lift buys most of that back at the
 * bottom while the shoulder eats it at the top, where nothing is left to lose.
 *
 * Exported because the number is a fact about the whole picture, not about this
 * file: `atmosphere.ts`'s keyframe table is tuned against this exposure and
 * `environmentRig.ts` builds its sky radiances to sit under the same shoulder.
 * Anything that changes it is changing all three.
 */
export const DEFAULT_TONE_MAPPING_EXPOSURE = 1.15;

/**
 * What kind of machine this is, to the extent a browser will say.
 *
 * There is no honest way to ask a page how fast its GPU is. The two things
 * usually reached for are both worse than useless here:
 * `navigator.hardwareConcurrency` counts CPU threads, and a phone with eight
 * of them and a phone with four tell you nothing about their fill rate —
 * Safari also rounds it and Chrome caps it, so the same handset answers
 * differently in two browsers. `navigator.deviceMemory` is Chromium-only and
 * bucketed to powers of two. Neither is a proxy for the thing being decided.
 *
 * So this does not pretend to measure performance. It asks the one question it
 * can answer correctly — *is this a phone* — out of a coarse primary pointer
 * and a short viewport edge, and applies a fixed, documented budget to that
 * answer. A tablet is not a phone: `(pointer: coarse)` is true on an iPad and
 * its short edge is 820, so it lands on the desktop budget, which is right
 * because it has the screen and usually the silicon for it.
 *
 * The 600px edge is deliberately `index.html`'s own small breakpoint, so the
 * renderer's idea of "phone" and the stylesheet's cannot drift apart.
 *
 * Sampled once, by whoever constructs. Re-deriving it on resize would let a
 * rotation or a desktop window drag change the pixel ratio mid-session, which
 * costs a full reallocation of every render target to buy nothing.
 */
export interface DeviceProfile {
  /** Coarse pointer and a short viewport edge. A phone, as far as anyone can tell. */
  handheld: boolean;
  /** Device pixel ratio ceiling. */
  maxPixelRatio: number;
  /** Default shadow map edge, in texels. Read by `scenekit.ts`. */
  shadowMapSize: number;
}

export function deviceProfile(): DeviceProfile {
  const coarse =
    typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
  const shortEdge = Math.min(window.innerWidth, window.innerHeight);
  const handheld = coarse && shortEdge <= 600;

  /**
   * 1.5, not 2, on a phone — and not 1 either.
   *
   * A 390 x 844 iPhone reports a device pixel ratio of 3. Capped at 2 that is
   * 780 x 1688, 1.3 megapixels of fragments, every one of them shaded against
   * a sun, a hemisphere, an ambient and a shadow lookup, for a scene carrying
   * about 140k building instances. At 1.5 it is 585 x 1266, 0.74 Mpx: 56% of
   * the fragments for a frame that is still supersampled relative to CSS
   * pixels. Dropping to 1 would halve it again, but then a 3x panel is
   * upscaling by three and the whole map goes soft — which reads as a cheap
   * page rather than a fast one.
   *
   * The antialias flag stays on there. MSAA on the tile-based GPUs in phones
   * resolves inside tile memory and is close to the cheapest edge quality
   * available; raising the pixel ratio to buy the same smoothing costs
   * quadratically. Spend it on MSAA, not on pixels.
   */
  return {
    handheld,
    maxPixelRatio: handheld ? 1.5 : 2,
    /*
     * Halved on a phone, and the city barely knows.
     *
     * Check what is actually in that map before defending its size. On the city
     * board the casters are the buildings, the landmarks, the bridges and —
     * since the relief started shadowing itself — a decimated copy of the
     * terrain, a quarter of its triangles, appended to its own index buffer and
     * swung in by `drawRange` for the depth pass alone (see `terrain.ts`,
     * `SHADOW_CASTER_STRIDE`). And `scene.ts` hands the kit a shadow extent of
     * 0.75 board spans, which for the Bay Area's 1003 units is a 1504-unit box:
     * at 2048 texels that is 0.73 units, about 69 m at this city's scale, and a
     * building footprint is one texel or less. The map is already quantising
     * past the things in it.
     *
     * So 1024 on a phone costs the map a resolution it was not using. An office
     * passes its own 2048 and keeps it, because at 1 unit = 1 m the same map is
     * four centimetres a texel and a desk very much does cast.
     */
    shadowMapSize: handheld ? 1024 : 2048,
  };
}

export function createStage(canvas: HTMLCanvasElement, options: StageOptions = {}): Stage {
  const profile = deviceProfile();
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: options.antialias ?? true });
  renderer.setPixelRatio(
    Math.min(window.devicePixelRatio, options.maxPixelRatio ?? profile.maxPixelRatio),
  );
  renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);

  /**
   * The two lines that decide what every colour in the product looks like.
   *
   * ### Why the output colour space is written down
   *
   * `outputColorSpace` has defaulted to `SRGBColorSpace` since r152, and this
   * file relied on that default for its whole life. That is a bet on a default
   * staying put across a library this repo pins with a caret, and the same bet
   * lost once already three lines below: `PCFSoftShadowMap` was silently
   * demoted to unfiltered basic shadows by an upstream change nobody here saw.
   * A renderer's output transfer function is not a thing to inherit quietly —
   * if it ever moves, every texture, palette and keyframe in the repo is wrong
   * at once and the symptom is "the whole app looks washed out", which is the
   * least diagnosable bug shape there is. So it is stated.
   *
   * ### Why ACES, and what it fixes
   *
   * `NoToneMapping` is not "no transform". It is `saturate()`: everything above
   * linear 1.0 becomes exactly 1.0, and every distinction above that value is
   * destroyed before the sRGB encode ever runs. This world drives values well
   * past 1.0 on purpose — `atmosphere.ts` peaks the sun above 2.3, the office
   * assets set `emissiveIntensity` up to 3.2 — so the brightest and most
   * expensive third of the lighting range was being flattened into a single
   * flat white. That is the whole explanation for the two worst-looking things
   * in the product: sunlit walls with no shading gradient left in them, and
   * every light fitting and screen rendering as an identical white rectangle
   * regardless of how bright it was told to be.
   *
   * ACES filmic replaces the cliff with a shoulder. Linear 1.0 displays at
   * about 0.90, 2.0 at 0.95, 4.0 at 0.98 — still separable, all the way up — so
   * a diffuser at 0.85 and a screen at 3.2 finally look like different things.
   * It costs a little in the shadows, where the toe is steeper than a plain
   * gamma encode; the exposure above and the re-tuned night stops in
   * `atmosphere.ts` are what pay that back. Both halves of that trade are
   * required. Turning this on and leaving the intensity table alone gives a
   * world that is correctly *shaped* and too dark, which reads as worse.
   *
   * Two things this deliberately does not touch, and it is worth knowing which
   * so nobody goes hunting for a horizon seam that is not there. Three sets
   * `toneMapped = false` on the background mesh whenever the background texture
   * carries an sRGB transfer — `scenekit.ts`'s sky gradient does — and fog is
   * mixed in `fog_fragment` *after* both `tonemapping_fragment` and
   * `colorspace_fragment`, from a uniform already converted into the renderer's
   * output space. So the sky and the fog are still displayed exactly as
   * `atmosphere.ts` authored them, and they still agree with each other at the
   * horizon. Only lit geometry moves, which is precisely the surface the
   * intensity table controls.
   */
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = options.exposure ?? DEFAULT_TONE_MAPPING_EXPOSURE;

  if (options.shadows ?? true) {
    renderer.shadowMap.enabled = true;
    /**
     * `PCFShadowMap`, and phones get it too.
     *
     * This line said `PCFSoftShadowMap` and had done since the first commit,
     * which in three r182 is not soft and is not PCF. Two things happened
     * upstream. `WebGLProgram`'s define table now maps only `PCFShadowMap` and
     * `VSMShadowMap`, and everything else falls through to
     * `SHADOWMAP_TYPE_BASIC` — one unfiltered tap, hard stair-stepped edges.
     * The runtime downgrade that is supposed to catch this reads `lights.type`
     * off the light *array* rather than off the shadow map, so it is always
     * `undefined` and the deprecation warning never prints. The result was the
     * cheapest and ugliest shadows in the library, chosen by nobody, announced
     * to no one.
     *
     * `PCFShadowMap` costs five Vogel-disk samples through a hardware
     * comparison sampler, with the pattern rotated per pixel by interleaved
     * gradient noise. That is more than one tap, and it is the reason a phone
     * can be given a 1024 map (see `deviceProfile`) and still look better than
     * it did on an unfiltered 2048: filtering buys more here than resolution
     * does, because at this board's shadow extent the map quantises to a city
     * block either way.
     *
     * Switching shadows off on a phone was the other option and it cannot be
     * taken at this line. This flag is the *renderer's*, and the renderer is
     * shared: the office swaps onto the same `Stage` (CONTRACT.md §1) at a
     * hundredth of the city's scale, where the shadows under the desks are the
     * whole read of depth in the room. Killing them here to speed up a map that
     * is quantising them away anyway would gut Spaces on the one class of
     * device that most needs Spaces to be worth the download. The saving lives
     * in the map size instead, which each scene chooses for itself.
     */
    renderer.shadowMap.type = THREE.PCFShadowMap;
  }

  let currentScene: StageScene | null = null;

  // Elapsed time is tracked per scene rather than per stage. A scene that sat
  // paused for forty seconds should come back where it left off, not jump
  // forty seconds into whatever it animates.
  const elapsedByScene = new WeakMap<StageScene, number>();

  // Compared against CSS pixels, because `canvas.width` is in device pixels and
  // differs from `clientWidth` on every retina display — checking it would call
  // `setSize` on every single frame.
  //
  // This is also what keeps mobile Safari honest. The canvas is `100dvh`, and
  // the viewport grows and shrinks continuously as the URL bar collapses under
  // a scroll-like gesture; that emits no `resize` event worth relying on. The
  // per-frame comparison catches it as a size change like any other.
  let lastWidth = 0;
  let lastHeight = 0;

  function applyViewport(target: StageScene) {
    const w = canvas.clientWidth;
    const h = canvas.clientHeight;
    if (w === 0 || h === 0) return;
    target.camera.aspect = w / h;
    target.camera.updateProjectionMatrix();
  }

  function resize() {
    const w = canvas.clientWidth;
    const h = canvas.clientHeight;
    if (w === 0 || h === 0) return;
    if (w === lastWidth && h === lastHeight) return;
    lastWidth = w;
    lastHeight = h;
    renderer.setSize(w, h, false);
    if (currentScene) applyViewport(currentScene);
  }

  const clock = new THREE.Clock();
  let raf = 0;

  function tick() {
    raf = requestAnimationFrame(tick);
    // Clamped, so a backgrounded tab returning does not advance every animation
    // by however long it was gone.
    const dt = Math.min(clock.getDelta(), 0.05);
    resize();
    const active = currentScene;
    if (!active) return;
    const elapsed = (elapsedByScene.get(active) ?? 0) + dt;
    elapsedByScene.set(active, elapsed);
    active.tick(dt, elapsed);
    renderer.render(active.scene, active.camera);
  }
  tick();

  const onWindowResize = () => resize();
  window.addEventListener("resize", onWindowResize);

  return {
    renderer,
    setScene(s) {
      if (s === currentScene) return;
      currentScene?.onExit?.();
      currentScene = s;
      if (!s) return;
      // The incoming camera may never have seen this canvas, and the canvas may
      // have been resized while the scene was paused.
      applyViewport(s);
      s.onEnter?.();
    },
    current: () => currentScene,
    dispose() {
      cancelAnimationFrame(raf);
      window.removeEventListener("resize", onWindowResize);
      currentScene = null;
      renderer.dispose();
    },
  };
}
