/**
 * The city scene: layers, chapter flights, markers, and the handle the app
 * drives it all through.
 *
 * `createScene` owns a canvas and a `City` and nothing else. It knows nothing
 * about React, about any API, or about what the markers mean — the caller hands
 * it data and gets back a small imperative handle. That boundary is what lets
 * one renderer serve a private map coloured by pipeline state and a public one
 * coloured by sector without either being a fork.
 *
 * The renderer and the loop live in `Stage`, which is **handed in and not
 * built here**: one renderer serves the canvas for the life of the page, and a
 * city is a thing that is put on it and taken off again. Building a Stage per
 * city is what this function used to do, and `stage.ts` records what it cost —
 * every switch orphaned a 2048² shadow map on the GL context, because
 * `WebGLRenderer.dispose()` frees none of a renderer's own textures.
 *
 * The camera, lights, flights and picking live in a `SceneKit`. What is left
 * here — and it is the only thing that ought to be here — is the city itself:
 * which layers go in the scene, where a chapter puts the camera, and what a
 * pick means. An office builds the same two pieces with its own answers and
 * swaps in on the same `Stage`, which keeps this city alive and paused rather
 * than rebuilding it on the way back.
 * For the Bay Area that is about 2.2 s of layer construction, of which roughly
 * 730 ms is the heightfield — and the heightfield is now the only part of it
 * that happens off the main thread, so a rebuild would be 2.2 s of *frozen*
 * page rather than 2.2 s of busy one. See CONTRACT.md §1.
 */

import * as THREE from "three";
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import { solarPosition, sunDirection } from "./solar.ts";
import { createStarlinkMeshLayer, type StarlinkMeshLayer } from "./starlinkMesh.ts";
import {
  createSatelliteLayer,
  type SatelliteCatalogue,
  type SatelliteLayer,
} from "./satellites.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import {
  createRoadTrafficLayer,
  type RoadTrafficLayer,
  type RoadTrafficOptions,
  type VehicleCameraMode,
} from "./roadTraffic.ts";
import type { Stage, StageScene } from "./stage.ts";
import type {
  VehicleActionSnapshot,
  VehicleControllerState,
} from "../transport/vehicleController.ts";
import { createBridges, createFreewayWorld, createRoads } from "./structures.ts";
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
import type {
  Chapter,
  City,
  FlightSource,
  LightingState,
  Marker,
  MarkerPalette,
  ScenePalette,
} from "./types.ts";
import { World, type FieldProgress } from "./world.ts";
import { createSceneActor, type SceneActorOptions } from "../actors/sceneActor.ts";
import type {
  ActorActionSnapshot,
  ActorControllerSnapshot,
  ActorIdentity,
} from "../actors/controller.ts";
import {
  createSceneAircraft,
  type SceneAircraftOptions,
} from "../aircraft/sceneAircraft.ts";
import type {
  AircraftActionSnapshot,
  AircraftControllerSnapshot,
} from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";

export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;

export interface SceneOptions {
  city: City;
  markerPalette?: MarkerPalette;
  /**
   * Markers available at construction time.
   *
   * Building glyphs in this first set reserve their footprints in the
   * anonymous block scatter. Later `setMarkers()` calls remain cheap and do
   * not rebuild a city, so callers should put stable destinations here and use
   * updates for genuinely live marker feeds.
   */
  markers?: Marker[];
  /** Optional deterministic road traffic for a state/corridor-scale board. */
  roadTraffic?: RoadTrafficOptions;
  /** Optional possessed procedural actor for play mode; inactive by default. */
  actor?: SceneActorOptions;
  /** Geographic spawn anchor for that actor; defaults to the board centre. */
  actorAnchor?: { lat: number; lng: number };
  /** Optional possessed fixed-wing aircraft; the city supplies its projection and terrain. */
  aircraft?: Omit<SceneAircraftOptions, "project" | "groundAt">;
  /** Optional remote authoritative entities. Nothing is imported or allocated when absent. */
  realtimePeers?: CityRealtimePeersOptions;
  flights?: FlightSource;
  /**
   * Element sets to propagate, if this deployment has any.
   *
   * A catalogue rather than a source, and the asymmetry with `flights` is the
   * point: a `FlightSource` is polled because there is no closed form for where
   * aircraft are, and a `SatelliteCatalogue` is *evaluated* because a TLE is
   * exactly that closed form. Nothing here is ever fetched on a timer.
   */
  satellites?: SatelliteCatalogue;
  /** Fires on hover/click of a marker head. */
  onMarkerPick?: (marker: Marker | null) => void;
  /**
   * Opening light rig. Comes from an `Atmosphere` when there is one; without
   * one the city gets `cityDaylight()`, because a scene that renders black
   * until somebody wires up the sun is not a scene that boots with no config.
   */
  lighting?: LightingState;
  /**
   * Fires while the heightfield builds, several times a second. The caller
   * decides what to say about it; the engine only reports a phase and a
   * fraction. See `FieldProgress`.
   */
  onProgress?: (progress: FieldProgress) => void;
  /**
   * Abandons the build. `createScene` then resolves to `null` having allocated
   * no geometry and having touched the stage not at all — the point of aborting
   * is that the next city gets the machine to itself, and a half-built scene
   * parked on the stage defeats that.
   */
  signal?: AbortSignal;
}

export interface SceneHandle {
  world: World;
  chapters: Chapter[];
  /**
   * The stage this city was built on, which it uses and does not own. An office
   * is swapped in with `stage.setScene(officeScene)` and this city back in the
   * same way; the one that steps out is paused, not thrown away.
   *
   * `dispose()` below takes the city off it and leaves it running. Whoever
   * built the stage disposes it, and on this page nobody does — it lives as
   * long as the canvas does.
   */
  stage: Stage;
  /** This city, as the thing `stage.setScene` takes. */
  stageScene: StageScene;
  /** Applies a rig computed elsewhere. The scene never works one out itself. */
  setLighting(state: LightingState): void;
  /**
   * Solar elevation in degrees, for the layers that need the sun's position
   * rather than the rig it implies. `LightingState` deliberately carries no
   * elevation, so the number has to arrive separately.
   */
  setSolarElevation(degrees: number): void;
  /**
   * How much of the sky has cloud in it, 0..1 — `WeatherObservation.cloudCover`.
   *
   * Separate from `setLighting` for the same reason `setSolarElevation` is: a
   * `LightingState` deliberately carries no cover, so the number has to arrive
   * on its own rather than be reverse-engineered out of a rig.
   */
  setCloudCover(fraction: number): void;
  /** Wind as the observation reports it: km/h, and the bearing it blows *from*. */
  setWind(kph: number | null, fromDeg: number | null): void;
  /**
   * Freeze the satellite sky at an instant, or pass `null` to follow the wall
   * clock. Exactly the shape of `main.ts`'s own time override, deliberately.
   *
   * Separate from `setSolarElevation` even though both follow the same clock,
   * because they need different things from it: the night-lights want a scalar
   * the caller has already worked out, and SGP4 wants the date itself. Handing
   * the layer an elevation would mean it could not propagate, and handing the
   * lights a `Date` would mean two modules computing the sun.
   *
   * No-op on a deployment with no catalogue.
   */
  setSkyInstant(when: Date | null): void;
  /** Draw the satellite layer, or do not. No-op with no catalogue. */
  setSatellitesVisible(visible: boolean): void;
  /**
   * How many objects were above the horizon at the end of the last complete
   * propagation pass, and how many element sets this build could read at all.
   * Both zero without a catalogue. For the godmode readout; nothing renders it.
   */
  satelliteCounts(): { visible: number; total: number };
  flyTo(chapterId: string): void;
  current(): string;
  onChapterChange(fn: (id: string) => void): void;
  /** Atomically hands local input and follow-camera ownership to one subsystem. */
  setControlMode(mode: CityControlMode): void;
  controlMode(): CityControlMode;
  onControlModeChange(fn: (mode: CityControlMode) => void): void;
  /** Device-neutral input for the corridor hero; a no-op on boards without one. */
  setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
  /** Current playable corridor state, or null on a city-scale board. */
  vehicleState(): Readonly<VehicleControllerState> | null;
  setVehicleCamera(mode: VehicleCameraMode): void;
  vehicleCamera(): VehicleCameraMode | null;
  setActorActions(actions: Partial<ActorActionSnapshot>): void;
  actorState(): Readonly<ActorControllerSnapshot> | null;
  /** Replace the visible local actor profile without disturbing its position or controls. */
  setActorIdentity(identity: ActorIdentity): void;
  /** Attach a caller-owned face texture to the local humanoid, if one is active. */
  attachActorFaceTexture(texture: THREE.Texture): boolean;
  /** Detach the current local face texture without disposing caller ownership. */
  clearActorFaceTexture(): void;
  setActorActive(active: boolean): void;
  actorActive(): boolean;
  setAircraftActions(actions: Partial<AircraftActionSnapshot>): void;
  aircraftState(): Readonly<AircraftControllerSnapshot> | null;
  setAircraftActive(active: boolean): void;
  aircraftActive(): boolean;
  /** Canonical remote snapshots only; a no-op when realtime peers were not configured. */
  upsertRemoteSnapshot(snapshot: EntityPoseSnapshot): boolean;
  removeRemoteEntity(id: string): boolean;
  clearRemoteEntities(): void;
  remoteEntityCount(): number;
  setMarkers(markers: Marker[]): void;
  /** Take this city off the stage and release everything it built. */
  dispose(): void;
}

/**
 * Build a city on a stage. Resolves to `null` if the build was abandoned.
 *
 * ## Why this is async, and why the handle is not
 *
 * `SceneHandle` is unchanged: every method on it is synchronous and every field
 * on it is real by the time you hold one. Only getting one takes a moment.
 *
 * The alternative was tried on paper and is worse. Handing back a handle
 * immediately means handing back a handle whose `stageScene` holds an empty
 * scene, whose `flyTo` cannot know where the ground is, and whose `world`
 * answers `groundAt` by building the heightfield on the main thread — the exact
 * block this change exists to remove, reintroduced by the first caller who
 * forgets to wait. Every one of those methods would need a "not yet" branch and
 * a queue, and the queue would be the real API.
 *
 * So the wait is where the wait actually is. The cost is that it ripples: the
 * app has to `await mountCity`, and `createMinimap` has to be constructed after
 * this resolves rather than alongside it. That is a handful of `await`s in
 * `main.ts` against an engine that cannot lie about whether its ground exists.
 */
export async function createScene(
  stage: Stage,
  options: SceneOptions,
): Promise<SceneHandle | null> {
  const { city } = options;
  const world = new World(city);
  // First, so an abandoned build has nothing to tear down: everything below
  // this line allocates, and a city that is no longer wanted should not have
  // built a single buffer.
  const ready = await world.ready({ signal: options.signal, onProgress: options.onProgress });
  if (!ready) return null;

  const pal = paletteFor(world);

  const scene = new THREE.Scene();

  /**
   * Every camera limit is derived from how big this city's board actually is.
   *
   * These were constants tuned for San Francisco — `maxDistance: 340`,
   * `far: 900`, a 170-unit shadow box. That silently made board size a fixed
   * property of the engine rather than of a city: expanding the pack from San
   * Francisco to the whole Bay Area took the board from 230 units across to
   * 1003, and the camera physically could not retreat far enough to frame it.
   * You got a close-up of the peninsula with everything else off-screen, and
   * nothing in the types said why.
   *
   * A city pack now chooses its own `latScale` freely and the camera follows.
   */
  const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
  const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
  const boardSpan = Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ));

  /**
   * How far the board reaches **from the scene origin**, which is not the same
   * as how big it is.
   *
   * Scene space is centred on `city.center` — the city — and the Bay Area board
   * runs forty kilometres down the peninsula from there, so the origin is
   * nowhere near the middle of it. The furthest corner is 0.94 spans out where
   * the half-diagonal is only 0.65, and anything sized off the half-diagonal is
   * therefore too small by half.
   *
   * The satellite dome is centred on the origin, because that is where its look
   * angles were computed for, so this is the radius it has to clear.
   */
  const boardRadius = Math.max(
    Math.hypot(westX, northZ),
    Math.hypot(eastX, northZ),
    Math.hypot(westX, southZ),
    Math.hypot(eastX, southZ),
  );

  const orbitMinDistance = Math.max(4, boardSpan * 0.02);
  const kit = createSceneKit({
    scene,
    dom: stage.renderer.domElement,
    fov: 42,
    near: 0.1,
    /**
     * Everything, at the worst pose, plus room.
     *
     * The furthest thing from the camera is the back of the satellite dome seen
     * from a chapter at the far end of the board: `maxChapterOffset` (0.94
     * spans) + `maxDistance` (2.0) + the dome radius (about 0.99) — call it 3.9.
     * At 3.0 the sky was being clipped from any off-centre chapter, which is
     * most of them.
     *
     * It also now sits at the fog's far plane, which is the honest statement of
     * the invariant: nothing should be cut off before it has fully faded out.
     */
    far: boardSpan * 4,
    minDistance: orbitMinDistance,
    /**
     * Far enough out to be **above the satellites**, which is what the extra
     * half-span buys.
     *
     * The satellite dome is at 0.7 spans (`engine/satellites.ts`), so at 1.5 the
     * camera was always inside the constellation and could only ever look up
     * through it. At 2.0 the far end of the zoom is outside it looking down, with
     * the whole dome in frame at this field of view and the board still filling
     * about two thirds of the screen.
     *
     * The far plane already covers it: the furthest thing from the camera is then
     * the back of the dome at 2.7 spans, against `far` at 3.0.
     */
    maxDistance: boardSpan * 2.0,
    shadowExtent: boardSpan * 0.75,
    /**
     * The middle of the board, which is **not** the origin.
     *
     * Scene space is centred on `city.center` — the city — and the Bay Area
     * board runs forty kilometres down the peninsula from there, so a shadow
     * box centred on the origin spends half itself on empty ocean and leaves the
     * far end of the peninsula outside the frustum entirely. `shadowExtent`
     * sizes the box and says nothing about where it is; this says where.
     */
    shadowTarget: new THREE.Vector3((westX + eastX) / 2, 0, (northZ + southZ) / 2),
    shadowFar: boardSpan * 2.2,
  });
  // Held, because the cloud layer needs the same opening rig the kit just got —
  // and it must be the same object, not a second call to `cityDaylight`, or the
  // two disagree for the one frame before the app's first `setLighting`.
  const opening = options.lighting ?? cityDaylight(pal, boardSpan);
  kit.applyLighting(opening);

  scene.add(createWater(world));
  scene.add(createShorePlates(world));
  scene.add(createTerrain(world));
  scene.add(options.roadTraffic
    ? createFreewayWorld(world, options.roadTraffic.pack)
    : createRoads(world));
  const buildingReservations: BuildingReservation[] = [];
  for (const marker of options.markers ?? []) {
    const glyph = marker.glyph;
    if (marker.located === false || glyph?.kind !== "building") continue;
    const [x, z] = world.project(marker.lat, marker.lng);
    // Five metres of breathing room keeps an anonymous wall from sitting
    // exactly on the authored façade after both reservation circles touch.
    const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
    buildingReservations.push({ x, z, radius });
  }
  const blocks = createBlocks(world, buildingReservations);
  scene.add(blocks);
  scene.add(createLandmarks(world, buildingReservations));
  scene.add(createBridges(world));

  /**
   * The city switching itself on after sunset. Built after `blocks` because it
   * patches the material that `createBlocks` made — order is load-bearing.
   */
  const nightLights: NightLights = createNightLights({ world, blocks });
  scene.add(nightLights.group);

  const clouds: CloudLayer = createCloudLayer(world, { span: boardSpan });
  clouds.setLighting(opening);
  scene.add(clouds.group);

  const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
  markerLayer.setMarkers(options.markers ?? []);
  scene.add(markerLayer.group);

  const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
    ? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
    : null;
  if (roadTraffic) scene.add(roadTraffic.group);
  const actorAnchor = options.actorAnchor ?? city.center;
  const [actorX, actorZ] = world.project(actorAnchor.lat, actorAnchor.lng);
  const sceneActor = options.actor
    ? createSceneActor({
        ...options.actor,
        sceneUnitsPerMetre: options.actor.sceneUnitsPerMetre ?? 1 / world.metresPerUnit,
        visualSceneUnitsPerMetre: options.actor.visualSceneUnitsPerMetre ??
          (city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
        sceneOrigin: options.actor.sceneOrigin ?? {
          x: actorX,
          y: world.groundAt(actorAnchor.lat, actorAnchor.lng),
          z: actorZ,
        },
      })
    : null;
  if (sceneActor) scene.add(sceneActor.root);
  const sceneAircraft = options.aircraft
    ? createSceneAircraft({
        ...options.aircraft,
        project: (lat, lng) => world.project(lat, lng),
        groundAt: (lat, lng) => world.groundAt(lat, lng),
        altitudeSceneUnitsPerMetre: options.aircraft.altitudeSceneUnitsPerMetre ??
          1 / world.metresPerUnit,
      })
    : null;
  if (sceneAircraft) scene.add(sceneAircraft.root);
  // Dynamic on purpose: the offline/default entry must not download four peer
  // prototypes merely because `createScene` can optionally host them.
  let realtimePeers: ScenePeers | null = null;
  if (options.realtimePeers) {
    const { createScenePeers } = await import("../realtime/scenePeers.ts");
    realtimePeers = createScenePeers({
      ...options.realtimePeers,
      project: (lat, lng) => world.project(lat, lng),
      groundAt: (lat, lng) => world.groundAt(lat, lng),
      geographicSceneUnitsPerMetre:
        options.realtimePeers.geographicSceneUnitsPerMetre ?? 1 / world.metresPerUnit,
      geographicVisualScale:
        options.realtimePeers.geographicVisualScale ??
        (city.id === "california" ? 0.025 : 1 / world.metresPerUnit),
    });
    scene.add(realtimePeers.root);
  }

  let flightLayer: FlightLayer | null = null;
  let flightTimer = 0;
  if (options.flights) {
    flightLayer = createFlightLayer(world);
    scene.add(flightLayer.group);
  }

  let satelliteLayer: SatelliteLayer | null = null;
  let starlinkMeshes: StarlinkMeshLayer | null = null;
  if (options.satellites) {
    satelliteLayer = createSatelliteLayer(boardRadius);
    scene.add(satelliteLayer.group);
    // Board radius, the same unit `createSatelliteLayer` takes above — the
    // mesh layer applies the dome factor itself. It used to take the *dome*
    // radius while its sibling took the *board* radius, with nothing in the
    // types to tell them apart, which is precisely the confusion that has
    // already produced one real bug in this file.
    starlinkMeshes = createStarlinkMeshLayer({ boardRadius });
    scene.add(starlinkMeshes.group);
  }

  /**
   * The instant the sky is drawn for, or `null` for the wall clock.
   *
   * Satellites are the one layer whose content is a function of *absolute* time
   * rather than of elapsed time, so `tick(dt)` cannot serve them: godmode scrubs
   * the clock to an arbitrary date and the sky has to follow it there.
   *
   * It holds the **override** rather than a resolved `Date`, which is the same
   * shape `main.ts` keeps its own clock in and is load-bearing here. A resolved
   * instant would have to be pushed in on a timer, and the only timer available
   * is `updateSun`'s — which runs about once a second, so the sky would advance
   * in one-second jumps while everything around it moved smoothly. Holding the
   * override means an unscrubbed scene reads the clock afresh every frame and a
   * scrubbed one is frozen exactly where it was put.
   */
  let skyOverride: Date | null = null;

  // ---- Chapters -----------------------------------------------------------

  const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
  const first = city.chapters[0];
  if (!first) throw new Error(`City "${city.id}" declares no chapters`);

  let currentChapter = first.id;
  const chapterListeners: ((id: string) => void)[] = [];
  let controlMode: CityControlMode = "overview";
  const controlModeListeners: ((mode: CityControlMode) => void)[] = [];

  function applyControlMode(requested: CityControlMode): CityControlMode {
    const next: CityControlMode =
      requested === "drive" && !roadTraffic ? "overview"
        : requested === "actor" && !sceneActor ? "overview"
          : requested === "aircraft" && !sceneAircraft ? "overview"
            : requested;
    const ownership = cityControlOwnership(next);
    sceneActor?.setActive(ownership.actor);
    sceneAircraft?.setActive(ownership.aircraft);
    roadTraffic?.setFollowing(ownership.drive);
    kit.controls.enabled = ownership.orbit;
    kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
    kit.controls.minDistance = next === "actor"
      ? 0.001
      : next === "aircraft"
        ? 0.01
        : orbitMinDistance;
    kit.camera.updateProjectionMatrix();
    if (next !== controlMode) {
      controlMode = next;
      for (const fn of controlModeListeners) fn(next);
    }
    return next;
  }

  function chapterPose(ch: Chapter): Pose {
    const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
    const groundY = world.groundAt(ch.focus.lat, ch.focus.lng);
    return {
      target: new THREE.Vector3(x, groundY, z),
      position: new THREE.Vector3(
        x + Math.sin(ch.focus.rotation) * ch.focus.distance,
        groundY + ch.focus.height,
        z + Math.cos(ch.focus.rotation) * ch.focus.distance,
      ),
    };
  }

  function flyTo(chapterId: string) {
    const ch = chapterById[chapterId];
    if (!ch) return;
    // Named viewpoints are observe/vehicle destinations. Possessing an actor
    // is an explicit UI action, so a chapter selection always hands the camera
    // back before it moves anywhere else.
    const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
    if (route && roadTraffic) {
      roadTraffic.setRoute(route.id);
      applyControlMode("drive");
    } else {
      applyControlMode("overview");
      roadTraffic?.setVehicleActions({});
      kit.flyTo(chapterPose(ch));
    }
    if (currentChapter !== chapterId) {
      currentChapter = chapterId;
      for (const fn of chapterListeners) fn(chapterId);
    }
  }

  kit.setPose(chapterPose(first));

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

  // `pickables` is mutated in place by the layer, so the array itself is the
  // live target list.
  kit.setPicking<Marker>({
    targets: markerLayer.pickables,
    resolve: (hit) => (hit.object.userData.marker as Marker | undefined) ?? null,
    onChange: (marker) => options.onMarkerPick?.(marker),
  });

  // ---- The scene, as the stage sees it ------------------------------------

  const stageScene: StageScene = {
    scene,
    camera: kit.camera,
    controls: kit.controls,
    // Leaving for an office should retire the hover with it; coming back to a
    // stale detail card for something the pointer is nowhere near reads as a
    // bug.
    onExit: () => kit.resetPick(),
    tick(dt) {
      // Exactly one subsystem owns the camera. In particular, OrbitControls
      // must stay disabled while the road layer writes its follow pose.
      const ownership = cityControlOwnership(controlMode);
      kit.controls.enabled = ownership.orbit;
      kit.tick(dt);
      sceneActor?.tick(dt);
      if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
      sceneAircraft?.tick(dt);
      if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
      realtimePeers?.tick(Date.now());
      roadTraffic?.tick(dt);
      clouds.tick(dt);
      if (options.flights && flightLayer) {
        flightTimer -= dt;
        if (flightTimer <= 0) {
          flightTimer = options.flights.interval;
          void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
        }
      }
      // Every frame and on no timer of its own. The catalogue's sweep is
      // time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
      // often makes it walk the catalogue sooner, never makes it cost more.
      if (options.satellites && satelliteLayer) {
        /**
         * One instant, one sweep, shared.
         *
         * `fixes()` advances the catalogue's rolling propagation, so calling it
         * twice in a frame spends twice the budget for no new information — and
         * two different `when`s would put the near-field satellites' sun
         * attitude on a different clock from the sky they are in.
         *
         * The sun comes from `solar.ts` directly rather than from the rig,
         * because `atmosphere.ts` floors the light direction at
         * `shadowFloorDeg` to keep the shadow camera usable. That floor pins the
         * sun above the horizon, and a sun ten degrees *down* is precisely the
         * dusk geometry that lights a Starlink pass.
         */
        const when = skyOverride ?? new Date();
        const fixes = options.satellites.fixes(when);
        satelliteLayer.update(fixes);
        starlinkMeshes?.update(
          fixes,
          kit.camera,
          sunDirection(solarPosition(city.center.lat, city.center.lng, when)),
        );
      }
    },
    dispose() {
      options.flights?.dispose?.();
      flightLayer?.dispose();
      satelliteLayer?.dispose();
      starlinkMeshes?.dispose();
      // Before the `scene.traverse` sweep below, and required rather than tidy:
      // the sweep reaches geometries and materials, and a `ShaderMaterial`'s
      // uniform textures are neither — the cloud texture is a canvas this layer
      // drew and only it can free.
      clouds.dispose();
      nightLights.dispose();
      markerLayer.dispose();
      roadTraffic?.dispose();
      sceneAircraft?.dispose();
      realtimePeers?.dispose();
      kit.dispose();
      scene.traverse((obj) => {
        const mesh = obj as THREE.Mesh;
        mesh.geometry?.dispose();
        const mat = mesh.material;
        if (Array.isArray(mat)) mat.forEach((m) => m.dispose());
        else if (mat) (mat as THREE.Material).dispose();
      });
    },
  };
  stage.setScene(stageScene);

  return {
    world,
    chapters: city.chapters,
    stage,
    stageScene,
    setLighting: (state) => {
      kit.applyLighting(state);
      clouds.setLighting(state);
    },
    setCloudCover: (fraction) => clouds.setCover(fraction),
    setWind: (kph, fromDeg) => clouds.setWind(kph, fromDeg),
    setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
    setSkyInstant: (when) => {
      skyOverride = when;
    },
    setSatellitesVisible: (visible) => satelliteLayer?.setVisible(visible),
    satelliteCounts: () => ({
      visible: options.satellites?.visibleCount ?? 0,
      total: options.satellites?.size ?? 0,
    }),
    flyTo,
    current: () => currentChapter,
    onChapterChange(fn) {
      chapterListeners.push(fn);
    },
    setControlMode: (mode) => { applyControlMode(mode); },
    controlMode: () => controlMode,
    onControlModeChange(fn) {
      controlModeListeners.push(fn);
    },
    setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
    vehicleState: () => roadTraffic?.hero() ?? null,
    setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
    vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
    setActorActions: (actions) => { sceneActor?.setActions(actions); },
    actorState: () => sceneActor?.state() ?? null,
    setActorIdentity: (identity) => { sceneActor?.setIdentity(identity); },
    attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
    clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
    setActorActive(active) {
      if (active) applyControlMode("actor");
      else if (controlMode === "actor") applyControlMode("overview");
    },
    actorActive: () => sceneActor?.active() ?? false,
    setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
    aircraftState: () => sceneAircraft?.state() ?? null,
    setAircraftActive(active) {
      if (active) applyControlMode("aircraft");
      else if (controlMode === "aircraft") applyControlMode("overview");
    },
    aircraftActive: () => sceneAircraft?.active() ?? false,
    upsertRemoteSnapshot: (snapshot) => {
      if (snapshot.pose.space === "local") {
        // Local office coordinates have no meaning on a geographic board.
        // Local city coordinates are accepted only for this detailed board;
        // California actors use geographic poses.
        const expected = city.id === "sf" ? "bay-area" : city.id === "socal" ? "socal" : null;
        if (snapshot.pose.cell.kind !== "city" || snapshot.pose.cell.cityId !== expected) return false;
      }
      return realtimePeers?.upsert(snapshot) ?? false;
    },
    removeRemoteEntity: (id) => realtimePeers?.remove(id) ?? false,
    clearRemoteEntities: () => { realtimePeers?.clear(); },
    remoteEntityCount: () => realtimePeers?.count() ?? 0,
    setMarkers(markers) {
      markerLayer.setMarkers(markers);
    },
    dispose() {
      sceneActor?.dispose();
      sceneAircraft?.dispose();
      realtimePeers?.dispose();
      /**
       * Off the stage, then released — and the stage itself is left running.
       *
       * The order is load-bearing and it used to be the other way round, with
       * a comment saying "Stage first, so nothing ticks a half-disposed scene".
       * The concern was real and the remedy was the bug: `stage.dispose()`
       * calls `renderer.dispose()`, which replaces the `properties` WeakMap,
       * and `stageScene.dispose()` then walks the scene disposing materials
       * that the renderer no longer has an entry for. `three` reads
       * `properties.get(material).programs`, finds `undefined`, and quietly
       * skips `gl.deleteProgram` for every one of them — so disposing in that
       * order freed nothing it was written to free.
       *
       * `setScene(null)` answers the ticking concern on its own and answers it
       * better: the loop drops this scene on the very next frame, and the
       * renderer keeps its bookkeeping so the disposals below actually land.
       */
      if (stage.current() === stageScene) stage.setScene(null);
      stageScene.dispose();
    },
  };
}

/**
 * The committed default rig: a late-afternoon sun from the west, which throws
 * the hills' shadows east across the flats.
 *
 * Not an `Atmosphere` and not a substitute for one — it computes nothing from
 * time or weather, it is a constant with the city's own sky colours poured in.
 * It exists so the engine renders with no server, no clock and no config, which
 * is the acceptance test the whole repo is held to.
 */
export function cityDaylight(palette: ScenePalette, boardSpan = 230): LightingState {
  return {
    sun: { direction: [-0.632, 0.717, 0.295], color: 0xfff3e0, intensity: 2.1 },
    hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 1.05 },
    ambient: { color: 0xffffff, intensity: 0.32 },
    sky: { top: palette.skyTop, horizon: palette.skyHorizon },
    // Scaled to the board for the same reason the camera is, and it was the same
    // bug: 210/460 were tuned when San Francisco's 230-unit board was the only
    // one, and on the Bay Area's 1003 units they close the fog well inside the
    // city. The camera sits about 0.6 spans out on a whole-board view, so a fog
    // that starts nearer than that is behind the viewer's own shoulder.
    //
    // The default keeps the original numbers exactly, for a caller that has a
    // palette but no world.
    fog: {
      color: palette.skyHorizon,
      near: boardSpan * 0.91,
      far: boardSpan * 2,
    },
  };
}
