/**
 * The city's own lights: lit windows in the buildings, and lamps along the
 * streets.
 *
 * This is the other half of making a night usable. `atmosphere.ts` puts a moon
 * up so there is something to see the city *by*; this puts light *in* the city,
 * which is most of what a city at night actually is — from any distance a
 * skyline after dark is not a shape you can make out, it is a field of small
 * bright rectangles that happens to have a shape.
 *
 * Two constraints shaped everything here:
 *
 *   - **There are ~24,000 buildings in one `InstancedMesh`.** A point light per
 *     building is not a slow version of this, it is an impossible one: three.js
 *     evaluates every light in the fragment shader for every lit surface, and
 *     the practical ceiling is a few dozen. So the buildings do not emit light
 *     at all. They *are* light — an emissive term added inside the existing
 *     material, which costs one shader patch and no extra draw calls, and which
 *     the moon and the fog and the shadows all continue to work around
 *     untouched.
 *   - **Nothing may reshuffle between frames or reloads.** Which windows are on
 *     is a hash of the window's own cell index and a per-instance seed drawn
 *     from `blocks.ts`'s seeded RNG, evaluated in the fragment shader. It is a
 *     pure function of position, so it is stable across frames for free, and it
 *     costs no memory at all: 24,000 buildings' worth of individual windows
 *     would be millions of booleans and there are none of them anywhere.
 *
 * **This is not a lighting owner.** `Atmosphere` owns the rig and CONTRACT.md §4
 * is explicit that nothing else may touch it; what this module owns is
 * *emission*, which is a property of the buildings and not of the light rig, and
 * it never constructs a `THREE.Light` of any kind. The seam is one number in:
 * `setSolarElevation`, which is the same solar elevation `Atmosphere` is
 * reading. Night is data, not a mode flag, and there is nothing to switch.
 *
 * An interior gets none of this, for the same reason it gets no `Atmosphere`:
 * an office has its own fixed rig and no idea what time it is outside.
 */

import * as THREE from "three";
import { nightFactor } from "./atmosphere.ts";
import { FACADE_ATTRIBUTE } from "./blocks.ts";
import { bridgeLights } from "./bridges.ts";
import { craneLights } from "./ports.ts";
import { seededRandom, type World } from "./world.ts";

export interface NightLightsOptions {
  world: World;
  /** The buildings, exactly as `createBlocks` returned them. */
  blocks: THREE.InstancedMesh;
  /** Lamps along the road network. On by default. */
  streetLamps?: boolean;
  /** Lamps down the deck of every crossing, and lights at the tower heads. On by default. */
  bridgeLamps?: boolean;
  /** Obstruction lights on the container gantries. On by default. */
  craneLamps?: boolean;
  /** Metres between street lamps. */
  lampSpacingM?: number;
  /**
   * Ceiling on how many lamps get built, as insurance against a city pack with
   * a very dense road network. SF's twenty-five roads produce a few thousand.
   */
  maxLamps?: number;
}

export interface NightLights {
  /** Everything this layer adds to the scene. Added once, then driven. */
  group: THREE.Group;
  /**
   * The seam. Hand it the solar elevation in degrees — the same number
   * `Atmosphere` is working from — and the city switches itself on.
   */
  setSolarElevation(degrees: number): void;
  /** How on the lights currently are, 0..1. For a debug readout. */
  strength(): number;
  dispose(): void;
}

// ---- Constants ------------------------------------------------------------

/**
 * A window bay, and a storey, in metres.
 *
 * The storey is real. The bay is not: a curtain wall's mullions are nearer 1.5 m
 * apart, and at San Francisco's ~94 m per scene unit that is a third of a pixel
 * from anywhere the camera is allowed to be, so an honest bay renders as grey
 * noise and nothing else. 6.5 m is the coarsest grid that still reads as
 * windows rather than as panels, which puts about six bays across a 40 m lot
 * and gives a pane the wide flat shape of a ribbon window. Vertically there is
 * no such problem — the city's 3.6x exaggeration makes a storey four times a
 * bay on screen — so the storey stays honest.
 */
const WINDOW_PITCH_M = 6.5;
const STOREY_M = 3.6;

/**
 * Fraction of windows left on, by how commercial the building is.
 *
 * Both are lower than they look, and deliberately: the aggregate is what the
 * eye reads, and the first pass at this — half of every office window on — came
 * out as a city of solid glowing slabs with no building shapes left in it. A
 * quarter is already a *lot* of light once every pane is at nearly full
 * emission, and a house showing one window in sixteen is a street with somebody
 * still up on it.
 */
const HOUSE_LIT = 0.06;
const OFFICE_LIT = 0.24;

/**
 * The two colours a lit window comes in.
 *
 * Warm is a domestic lamp — tungsten, or the LED everyone buys because it looks
 * like tungsten — at something like 2,700 K. Cool is an office ceiling left on
 * by the cleaners, which is the other half of any real skyline and the half
 * that makes the warm windows read as warm. Passed as `THREE.Color`, so three
 * converts them out of sRGB into the linear working space on the way to the
 * uniform and the emissive term lands in the same space as everything else in
 * the shader.
 */
const WINDOW_WARM = 0xffc178;
const WINDOW_COOL = 0xd8e4ff;

/**
 * Peak emissive radiance of a lit pane. Below 1 so a window is bright, not blown.
 *
 * 0.8 when the ground under the city was effectively black, 0.95 now that
 * `atmosphere.ts` holds a real floor under a moonless night. That floor moved
 * the terrain from about #000004 to something you can find a coastline in, and
 * a window has to stay the brightest thing in the frame by a comfortable factor
 * or the whole picture stops being a city at night and becomes a city at dusk.
 * It is the *ratio* that is being defended here, not the absolute value.
 *
 * Still under 1, and that is not an accident: at 1.0 the emissive term alone
 * saturates the channel and a lit pane clips to white, taking `WINDOW_WARM` with
 * it. A skyline whose windows have lost the difference between tungsten and a
 * ceiling fluorescent is a skyline with the character taken out of it, and there
 * is no HDR buffer here to get it back from.
 */
const WINDOW_GAIN = 0.95;

/** Sodium, because a street lamp is the one light in a city that still is. */
const LAMP_COLOR = 0xffb264;
const LAMP_HEIGHT_M = 9;
const DEFAULT_LAMP_SPACING_M = 55;
const DEFAULT_MAX_LAMPS = 24_000;

/**
 * Glow radius of a lamp, in scene units.
 *
 * Chosen against the far end of the camera's orbit rather than the near end. At
 * 100 units out — the framing the city is usually looked at from — this is a
 * few pixels, which is what a street lamp is; flying down to the 12-unit
 * minimum blooms it to something much larger than a lamp. That is the wrong way
 * round from a purist's point of view and the right way round for the frame
 * anyone actually looks at, and the alternative — a fixed pixel size — turns the
 * whole road network into a sheet of aliasing sparkle the moment you pull back.
 */
const LAMP_SIZE = 0.3;

const LAMP_SEED = 61_803;

/**
 * The bridge lamps, and why a crossing gets its own three constants.
 *
 * A street lamp is one of twelve thousand in a field of lit windows; a deck lamp
 * is one of a few hundred with *nothing else in the frame*. Both of the Bay's
 * famous crossings run over black water, so at 21:35 the whole `bay-bridge`
 * canvas measured 9.3 mean luminance and the `golden-gate` frame 8.1 — the shape
 * was there and the light was not, and the shot list already carried a note
 * saying the bridge frame had to be taken an hour earlier than every other night
 * frame to have anything in it at all.
 *
 * `BRIDGE_LAMP_SIZE` is a little smaller than a street lamp's because the deck
 * run is dense — 50 m spacing against 55 — and a bigger sprite at that pitch is
 * a fluorescent tube rather than a row of lamps. The head light is *larger* and
 * red, because two of them are the only thing marking a 227 m tower and a red
 * that reads at four pixels is what an obstruction light is for.
 */
const BRIDGE_LAMP_COLOR = 0xffc07a;
const HEAD_LIGHT_COLOR = 0xff3b30;

/**
 * Lamp sprite size as a multiple of the deck's own half-width, not in units.
 *
 * A `PointsMaterial` has one size for a whole cloud, so the number has to be
 * picked once — and picked in scene units it would be tuned at San Francisco's
 * 94 m to the unit and then be several times the width of the deck it sits on at
 * Los Angeles' 391. `bridgeLights` hands back `scale` (half the deck width on
 * this board) for exactly this.
 *
 * 2.4 is set by the *spacing*, not by what a lamp looks like: the deck run is one
 * every 50 m, which is a shade over two deck-widths, so a sprite of a bit more
 * than two deck-widths is the point at which adjacent glows meet and the run
 * stops being beads and becomes the line of light a lit crossing is from any
 * distance you can see the whole of one from. Below about 1.8 it visibly beads.
 * The head light is wider again because two of them carry a whole tower.
 */
const BRIDGE_LAMP_SPREAD = 2.4;
const HEAD_LIGHT_SPREAD = 3.4;

/**
 * Glow radius of a gantry's obstruction light, in scene units.
 *
 * Not a multiple of anything, unlike every other size in this file, and the
 * reason is that there is nothing to take a multiple of: a bridge lamp is scaled
 * off its own deck's half-width and a crane has no such length. So it is picked
 * against the *rail spacing* instead. Pier 400's eighteen gantries stand over
 * 1.4 km, which is 0.20 scene units apart on this board, and 0.17 is the largest
 * sprite that still leaves eighteen separate lights rather than one bar.
 *
 * It was 0.09 first, chosen off the 0.115 rail gauge, and the frame said no: at
 * 0.09 the light is smaller than the crane it is on and disappears against
 * `ports.ts`'s own cream emissive, which is already lighting the gantry it was
 * meant to mark. Half a bridge head light (0.306) is about right — a gantry is a
 * smaller object than a 227 m tower and its light should read smaller, but not
 * invisible.
 */
const CRANE_LIGHT_SIZE = 0.17;

/**
 * When the lamps come on, in degrees of solar elevation.
 *
 * Earlier than `nightFactor`, and deliberately so: street lighting switches on
 * around sunset, an hour before the sky is dark, and offices have been lit
 * since the afternoon. What `nightFactor` then adds is not more lights but more
 * *contrast* — the same windows against a sky that has stopped competing with
 * them. Multiplying the two is what produces the real sequence, where the city
 * appears to come on gradually over an hour without anything ever switching.
 */
const LAMPS_ON_HIGH = 5;
const LAMPS_ON_LOW = -5;

/** Below this the layer is hidden outright rather than drawn at zero. */
const DARK_ENOUGH = 0.002;

// ---- The layer ------------------------------------------------------------

export function createNightLights(options: NightLightsOptions): NightLights {
  const { world, blocks } = options;

  const group = new THREE.Group();
  group.name = "nightlights";

  // Shared with the shader by reference: `onBeforeCompile` hands these exact
  // objects to the program, so writing `.value` here is what drives the frame.
  const uniforms = {
    uNight: { value: 0 },
    uWindowPitch: { value: WINDOW_PITCH_M / world.metresPerUnit },
    // A storey goes through `world.metres`, so it picks up the city's vertical
    // exaggeration exactly as the building's own height did. Without that the
    // floor count would be wrong by the exaggeration factor — a 100 m tower
    // would come out with a hundred floors in it.
    uStorey: { value: world.metres(STOREY_M) },
    uWarm: { value: new THREE.Color(WINDOW_WARM) },
    uCool: { value: new THREE.Color(WINDOW_COOL) },
    uGain: { value: WINDOW_GAIN },
    uHouseLit: { value: HOUSE_LIT },
    uOfficeLit: { value: OFFICE_LIT },
  };

  const facade = blocks.geometry.getAttribute(FACADE_ATTRIBUTE);
  const material = blocks.material;
  const patched =
    !Array.isArray(material) && material instanceof THREE.MeshLambertMaterial && facade
      ? patchFacades(material, uniforms)
      : null;

  const lamps = (options.streetLamps ?? true) ? buildLamps(world, options) : null;
  if (lamps) group.add(lamps.points);

  const crossings = (options.bridgeLamps ?? true) ? buildBridgeLamps(world) : [];
  for (const cloud of crossings) group.add(cloud.points);

  const gantries = (options.craneLamps ?? true) ? buildCraneLamps(world) : null;
  if (gantries) group.add(gantries.points);

  const clouds = [
    ...(lamps ? [lamps] : []),
    ...crossings,
    ...(gantries ? [gantries] : []),
  ];

  let strength = 0;

  function setSolarElevation(degrees: number) {
    // Two curves, multiplied: when the lights are on, and how much darker than
    // them the sky is. See `LAMPS_ON_HIGH`.
    const on = 1 - smoothstep(LAMPS_ON_LOW, LAMPS_ON_HIGH, degrees);
    strength = on * (0.35 + 0.65 * nightFactor(degrees));

    uniforms.uNight.value = strength;
    for (const cloud of clouds) {
      cloud.points.visible = strength > DARK_ENOUGH;
      cloud.material.opacity = strength;
    }
    publishNightLevel(strength);
  }

  setSolarElevation(90);

  return {
    group,
    setSolarElevation,
    strength: () => strength,
    dispose() {
      // The buildings are not ours and outlive this layer, so the material goes
      // back exactly as it was found rather than being left with a dark
      // uniform in it and a patch nobody remembers applying.
      patched?.();
      for (const cloud of clouds) {
        cloud.points.geometry.dispose();
        cloud.material.map?.dispose();
        cloud.material.dispose();
      }
      group.clear();
    },
  };
}

// ---- Lit windows ----------------------------------------------------------

type Uniforms = Record<string, { value: unknown }>;

/**
 * Add an emissive window grid to the buildings' own material.
 *
 * Patching in place rather than replacing the material, because `blocks.ts`
 * owns what a facade looks like in daylight and this has no business having an
 * opinion about that. Everything below is additive: a `totalEmissiveRadiance`
 * term, computed after the lighting has been accumulated and before fog and the
 * colour-space encode, so a lit window is correctly hazed by the marine layer
 * and correctly *not* darkened by being in shadow. Which is right — a window is
 * a hole with a light behind it, and nothing outside the building can shade it.
 *
 * Returns the undo.
 */
function patchFacades(material: THREE.MeshLambertMaterial, uniforms: Uniforms): () => void {
  const previous = material.onBeforeCompile;

  material.onBeforeCompile = (shader) => {
    for (const [name, uniform] of Object.entries(uniforms)) {
      shader.uniforms[name] = uniform as THREE.IUniform;
    }

    shader.vertexShader = shader.vertexShader
      .replace("#include <common>", `#include <common>\n${VERTEX_PARS}`)
      .replace("#include <project_vertex>", `#include <project_vertex>\n${VERTEX_BODY}`);

    shader.fragmentShader = shader.fragmentShader
      .replace("#include <common>", `#include <common>\n${FRAGMENT_PARS}`)
      .replace(
        "#include <emissivemap_fragment>",
        `#include <emissivemap_fragment>\n${FRAGMENT_BODY}`,
      );
  };
  // `Material.customProgramCacheKey` defaults to the source of
  // `onBeforeCompile`, so the renderer will not hand this material a program
  // compiled for an unpatched one. Changing the function is still a new
  // program, hence the flag.
  material.needsUpdate = true;

  return () => {
    material.onBeforeCompile = previous;
    material.needsUpdate = true;
  };
}

/**
 * The varyings are declared unconditionally in both stages — a varying present
 * in one and absent from the other is a link error — while the two things that
 * only exist under instancing are guarded. `aFacade` needs no guard: an
 * unbound vertex attribute reads as zero, which is a building with no windows
 * lit, which is a perfectly good failure.
 */
const VERTEX_PARS = /* glsl */ `
attribute vec2 aFacade;
varying vec3 vFacadeLocal;
varying vec3 vFacadeNormal;
varying vec3 vFacadeSize;
varying vec2 vFacade;
`;

const VERTEX_BODY = /* glsl */ `
vFacadeLocal = transformed;
vFacadeNormal = objectNormal;
vFacade = aFacade;
#ifdef USE_INSTANCING
  // The instance's scale, recovered from the columns of its own matrix. This is
  // what puts the window grid in scene units instead of in fractions of a
  // building: without it every tower would have the same number of floors as
  // the bungalow next door, stretched to fit.
  vFacadeSize = vec3(
    length(instanceMatrix[0].xyz),
    length(instanceMatrix[1].xyz),
    length(instanceMatrix[2].xyz)
  );
#else
  vFacadeSize = vec3(1.0);
#endif
`;

const FRAGMENT_PARS = /* glsl */ `
uniform float uNight;
uniform float uWindowPitch;
uniform float uStorey;
uniform float uGain;
uniform float uHouseLit;
uniform float uOfficeLit;
uniform vec3 uWarm;
uniform vec3 uCool;
varying vec3 vFacadeLocal;
varying vec3 vFacadeNormal;
varying vec3 vFacadeSize;
varying vec2 vFacade;

float facadeHash(vec3 p) {
  return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453123);
}
`;

/**
 * The window grid, and the reason it does not sparkle.
 *
 * A window bay is about 4 m, which at San Francisco's ~94 m per scene unit is
 * 0.042 units — and from the distance the city is normally looked at, that is
 * well under a pixel. Drawn honestly it would be a sheet of moiré that crawls
 * whenever the camera moves, which is the classic failure of any procedural
 * pattern with no mip chain behind it. `fwidth` gives the pattern's own
 * footprint in pixels, and past about one cell per pixel the grid is replaced
 * by its average — which is exactly what a mip level would have contained. So
 * the far city is a smooth glow whose brightness is the density of its lit
 * windows, downtown reads brighter than the avenues because it genuinely has
 * more of them on, and flying in resolves individual windows out of it.
 */
const FRAGMENT_BODY = /* glsl */ `
if (uNight > 0.002) {
  vec3 faceNormal = normalize(vFacadeNormal);
  // Roofs have no windows in them, and this is a box.
  float wall = 1.0 - smoothstep(0.55, 0.95, abs(faceNormal.y));
  if (wall > 0.0) {
    float across = abs(faceNormal.x) > 0.5
      ? vFacadeLocal.z * vFacadeSize.z
      : vFacadeLocal.x * vFacadeSize.x;
    float up = vFacadeLocal.y * vFacadeSize.y;
    vec2 grid = vec2(across / uWindowPitch, up / uStorey);

    vec2 cell = fract(grid);
    vec2 pane = step(vec2(0.22, 0.34), cell) * step(cell, vec2(0.78, 0.72));
    float coverage = pane.x * pane.y;

    // Which windows are on: a hash of the cell and the building's own seed, so
    // it is a pure function of where you are looking and never has to be
    // stored, animated or reconciled.
    float roll = facadeHash(vec3(floor(grid), vFacade.y * 137.0));

    // How lit this particular building is, on top of what its district says.
    // Without it every tower downtown has the same window density, and from a
    // distance the whole financial district smears into one flat brown
    // rectangle — which is the one thing a night skyline never looks like. The
    // curve is squared so most buildings are dim and a few blaze, and scaled so
    // that the mean of it is exactly 1 and the district's own figure still
    // means what it says.
    float variation = 0.15 + 2.55 * vFacade.y * vFacade.y;
    float chance = clamp(mix(uHouseLit, uOfficeLit, vFacade.x) * variation, 0.0, 0.9);
    float on = step(1.0 - chance, roll);

    float footprint = max(fwidth(grid.x), fwidth(grid.y));
    float detail = 1.0 - smoothstep(0.5, 1.4, footprint);
    // 0.56 x 0.38 is the pane inside its cell, so 0.2128 x chance is the grid's
    // own mean — and 2.6 times that is what is actually used, which is a lie
    // worth being explicit about. The mean is the right answer for a display
    // whose response is linear, and no display's is: a pixel that in reality
    // contains one small blazing window and three dark ones does not read to
    // the eye as the average of the four, it reads as lit. With no HDR buffer
    // and no bloom to arrive at that honestly, the multiplier is the cheap way
    // to keep the far city as bright as the near city says it ought to be.
    //
    // 1.8 for as long as the ground was black, because against black anything
    // reads. This is the branch the whole-board framing takes — every pixel of
    // the city is past the fwidth cutoff from up there — so it is also the
    // branch that had to answer when the atmosphere's night floor brought the
    // terrain up to meet it. At 1.8 against the new floor the lit grid and the
    // bare ground came out at the same luminance and downtown stopped being
    // findable, which is a worse bug than the one being fixed.
    float glow = mix(2.6 * 0.2128 * chance, coverage * on, detail);

    // Roughly seven windows in ten warm. A skyline is mostly people's lamps and
    // only partly the floors the cleaners are still on.
    //
    // The colour needs the same averaging the mask got, and forgetting it is a
    // subtle and very visible bug: a mask correctly resolved to its mean, tinted
    // by a hard per-cell choice between two colours at a frequency far below one
    // pixel, gives a distant city that is the right brightness and crawling with
    // orange and white confetti.
    vec3 tint = mix(mix(uWarm, uCool, 0.3), mix(uWarm, uCool, step(0.7, fract(roll * 7.13))), detail);
    totalEmissiveRadiance += tint * (glow * wall * uNight * uGain);
  }
}
`;

// ---- Street lamps ---------------------------------------------------------

interface Lamps {
  points: THREE.Points;
  material: THREE.PointsMaterial;
}

/**
 * A run of lamps, as one additive point cloud.
 *
 * Everything that is a *lamp* in this module comes through here — the street
 * grid, a bridge deck, a tower head — because they differ only in where the
 * points are, what colour they are and how big. One draw call each, no lighting,
 * no shadows, and no per-frame work beyond the opacity `setSolarElevation`
 * writes.
 *
 * Additive is the load-bearing choice and not a stylistic one: it is what makes
 * a hundred lamps down one street, or fifty down a bridge deck, saturate into
 * the continuous line of light such a thing actually is at this distance,
 * instead of staying a hundred separate dots however far away they are.
 */
function glowCloud(
  name: string,
  positions: number[],
  color: number,
  size: number,
): Lamps | null {
  if (positions.length === 0) return null;
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));

  const material = new THREE.PointsMaterial({
    color,
    map: lampTexture(),
    size,
    sizeAttenuation: true,
    transparent: true,
    opacity: 0,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  });

  const points = new THREE.Points(geometry, material);
  points.name = name;
  points.visible = false;
  return { points, material };
}

// ---- Bridge lamps ---------------------------------------------------------

/**
 * Every crossing on the board, lit.
 *
 * Two clouds and not one per bridge: `bridges.ts` gives back bare positions, so
 * five Bay Area crossings' deck lamps concatenate into a single buffer and the
 * whole board's bridge lighting is **two draw calls** — one warm run down every
 * deck, one red set of obstruction lights at every tower head. The alternative,
 * a `Points` per crossing, is the trap this repo has hit before with a mesh per
 * hanger.
 *
 * The positions are a pure function of the pack's path, so nothing here reshuffles
 * between frames or between the two resolutions the capture scripts compare.
 */
function buildBridgeLamps(world: World): Lamps[] {
  const deck: number[] = [];
  const heads: number[] = [];
  let scale = 0;
  for (const bridge of world.city.bridges) {
    const lit = bridgeLights(world, bridge);
    for (const value of lit.deck) deck.push(value);
    for (const value of lit.heads) heads.push(value);
    scale = lit.scale;
  }
  const out: Lamps[] = [];
  const deckCloud = glowCloud(
    "bridgelamps",
    deck,
    BRIDGE_LAMP_COLOR,
    scale * BRIDGE_LAMP_SPREAD,
  );
  if (deckCloud) out.push(deckCloud);
  const headCloud = glowCloud("bridgeheads", heads, HEAD_LIGHT_COLOR, scale * HEAD_LIGHT_SPREAD);
  if (headCloud) out.push(headCloud);
  return out;
}

/**
 * The container terminals' obstruction lights: one red point per gantry apex.
 *
 * `ports.ts` decides where they are; this decides what they look like, which is
 * the same seam `buildBridgeLamps` sits on. They deliberately share
 * `HEAD_LIGHT_COLOR` with the bridge tower heads — the fixture on a 143 m crane
 * and the fixture on a 227 m tower are the same fixture doing the same job, and
 * a board with two different reds on it for that reason would be a board that
 * had stopped meaning anything by its reds.
 *
 * They do not share the *cloud*, because a `PointsMaterial` carries one size for
 * the whole of one and the two want different ones: a bridge head is sized off
 * the deck it stands over, and a gantry has no deck. `CRANE_LIGHT_SIZE` is in
 * scene units and is the one number here that is not derived, because a crane
 * row's spacing is authored in the pack rather than implied by anything this
 * module can see. 0.09 is a shade under the 0.115 rail gauge, so a row of
 * eighteen at Pier 400 reads as eighteen lights rather than as one bar.
 *
 * The whole board is fifty-six points in one draw call. If a pack ever declares
 * a terminal with a thousand gantries in it, it is still one draw call.
 */
function buildCraneLamps(world: World): Lamps | null {
  const heads: number[] = [];
  for (const port of world.city.ports ?? []) {
    for (const value of craneLights(world, port).heads) heads.push(value);
  }
  return glowCloud("craneheads", heads, HEAD_LIGHT_COLOR, CRANE_LIGHT_SIZE);
}

// ---- The night level, for anything that is not part of the city -----------

/**
 * How far into night it is, 0..1, published to whoever else needs it.
 *
 * There is exactly one consumer and it is `roadTraffic.ts`: the EV's headlamps
 * throw a pool of light on the road ahead, and a pool of light on a road in
 * broad daylight is a bug. So it needs the same number this module is already
 * computing, and there is no other route to it — `scene.ts` hands
 * `setSolarElevation` to this module and to nothing else, and a vehicle layer
 * has no clock.
 *
 * A module-level value rather than a per-scene one, deliberately: **the sun is a
 * property of the clock, not of a board.** Every retained scene in the app —
 * Bay Area, California, SoCal — is under the same sun at the same instant, so
 * one number is not an approximation of three, it is the thing all three are
 * reading. The value a listener sees is whatever the last driven scene wrote,
 * and `main.ts` drives the visible one every tick.
 *
 * This is still not a lighting owner and does not become one. What crosses here
 * is a *derived observation* — one float, downhill, no write-backs — which is
 * exactly the shape CONTRACT.md §4 asks of everything downstream of
 * `Atmosphere`.
 */
type NightListener = (level: number) => void;
const nightListeners = new Set<NightListener>();
let nightLevel = 0;

function publishNightLevel(level: number): void {
  if (level === nightLevel) return;
  nightLevel = level;
  for (const listener of nightListeners) listener(level);
}

/** The current night level, for a consumer built mid-evening. */
export function currentNightLevel(): number {
  return nightLevel;
}

/** Subscribe to the night level. Returns the unsubscribe. */
export function onNightLevel(listener: NightListener): () => void {
  nightListeners.add(listener);
  listener(nightLevel);
  return () => {
    nightListeners.delete(listener);
  };
}

/**
 * Lamps along the road network, as one additive point cloud.
 *
 * Cheap enough to be worth it: a few thousand points in a single draw call,
 * with no lighting, no shadows and no per-frame work beyond an opacity. What
 * they buy is the thing the buildings cannot — the *ground* has light on it, so
 * the street grid is still legible at night and the city keeps the shape that
 * makes it recognisable from above. In San Francisco that shape is the 46°
 * between the grid north of Market and the grid south of it, and losing it
 * after dark would lose the city.
 *
 * They emit nothing, of course. A real street lamp pooling light on the road
 * under it is a second set of lights and a second shadow problem, and the
 * pooling would be invisible at any framing where the lamp itself is a pixel.
 */
function buildLamps(world: World, options: NightLightsOptions): Lamps | null {
  const spacing = (options.lampSpacingM ?? DEFAULT_LAMP_SPACING_M) / world.metresPerUnit;
  const lift = world.metres(LAMP_HEIGHT_M);
  const limit = options.maxLamps ?? DEFAULT_MAX_LAMPS;
  const rand = seededRandom(LAMP_SEED);

  /**
   * A freeway is lit where it runs through a built-up district, and dark where
   * it does not. A street is lit everywhere.
   *
   * This is the difference between a city freeway and an interurban corridor,
   * and it is the whole of the difference: the interchange lighting on US-101
   * through San Jose is a fact about San Jose, not about US-101. Before this
   * rule, the California board — which is *two* freeways and no streets at all —
   * put a lamp every 55 m down 700 km of the Salinas Valley, and what actually
   * arrived in the frame was not a lit road: at 1,919 m to the scene unit and
   * thirteen times vertical exaggeration, a lamp standing 9 m over a *sampled*
   * ground height sinks inside the rendered terrain wherever the two disagree,
   * so the depth test ate most of them and left random orange clumps sitting on
   * the carriageway. That is worse than either answer — it is neither a lit road
   * nor a dark one, and there is nothing a reader can learn from where the
   * clumps happen to be.
   *
   * What a night driver on that road actually sees is the subject of this whole
   * layer's corridor work: retroreflective paint returning the car's own light,
   * cat's eyes, and lights at the towns. So the corridor gets the towns.
   */
  const districts = world.city.districts.map((district) => district.polygon);
  const lit = (kind: string, lat: number, lng: number): boolean =>
    kind !== "freeway" || world.pointInAny(lat, lng, districts);

  const positions: number[] = [];
  let index = 0;

  for (const road of world.city.roads) {
    // Carried across segment joins, so the spacing is even along the whole
    // street rather than restarting at every corner — which would cluster
    // lamps wherever a road was written with a lot of vertices in it, and
    // those are exactly the bends.
    let carry = 0;

    for (let i = 0; i < road.path.length - 1; i++) {
      const from = road.path[i];
      const to = road.path[i + 1];
      if (!from || !to) continue;

      const [lat0, lng0] = from;
      const [lat1, lng1] = to;
      const [x0, z0] = world.project(lat0, lng0);
      const [x1, z1] = world.project(lat1, lng1);
      const dx = x1 - x0;
      const dz = z1 - z0;
      const length = Math.hypot(dx, dz);
      if (length <= 0) continue;

      // Unit normal to the street, for the kerb offset.
      const nx = -dz / length;
      const nz = dx / length;

      let s = carry;
      for (; s < length; s += spacing) {
        if (index >= limit) break;
        const t = s / length;
        const lat = lat0 + (lat1 - lat0) * t;
        const lng = lng0 + (lng1 - lng0) * t;
        if (!lit(road.kind, lat, lng)) continue;
        // Alternating kerbs, jittered, because a street lit by a perfect ruler
        // of identical dots reads as a dashed line and not as lighting.
        const side = index % 2 === 0 ? 1 : -1;
        const offset = road.width * 0.55 * side * (0.8 + rand() * 0.4);
        positions.push(
          x0 + dx * t + nx * offset,
          world.groundAt(lat, lng) + lift,
          z0 + dz * t + nz * offset,
        );
        index++;
      }
      carry = Math.max(0, s - length);
    }
  }

  return glowCloud("streetlamps", positions, LAMP_COLOR, LAMP_SIZE);
}

/**
 * The lamp's glow, drawn on a canvas rather than shipped as a file. No binary
 * assets is a licensing rule and not a stylistic one; see ARCHITECTURE.md.
 */
function lampTexture(): THREE.Texture {
  const canvas = document.createElement("canvas");
  canvas.width = 64;
  canvas.height = 64;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("2D canvas context unavailable");

  const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
  gradient.addColorStop(0, "rgba(255,255,255,1)");
  gradient.addColorStop(0.22, "rgba(255,232,190,0.7)");
  gradient.addColorStop(0.55, "rgba(255,190,110,0.18)");
  gradient.addColorStop(1, "rgba(255,170,80,0)");
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, 64, 64);

  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}

// ---- Helpers --------------------------------------------------------------

/** Hermite ease over a span, flat at both ends. `atmosphere.ts` has the twin. */
function smoothstep(edge0: number, edge1: number, x: number): number {
  if (edge1 === edge0) return x < edge0 ? 0 : 1;
  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
}

// ---- Sanity checks --------------------------------------------------------

/**
 * What this produces for San Francisco, so the numbers above can be argued with.
 *
 * At `metresPerUnit` 94.34 and a vertical exaggeration of 3.6, a window bay is
 * 0.0424 scene units across and a storey is 0.1374 units tall — so a 260 m
 * tower gets 72 floors and a 40 m lot's frontage gets ten bays, both of which
 * are about right. The camera orbits between 12 and 340 units, and at 100 units
 * out with a 42° field of view a bay covers roughly half a pixel, which is why
 * `FRAGMENT_BODY` spends four lines on `fwidth` and would be unusable without
 * them.
 *
 * The switch-on sequence, by solar elevation:
 *
 *   - **+5° and above**: 0. The lamps are not drawn at all.
 *   - **+2°**: 0.076. The first offices, barely findable against the sky.
 *   - **0°, sunset**: 0.178.
 *   - **-2°**: 0.381.
 *   - **-5°, most of the way through civil twilight**: 0.814.
 *   - **-8° and below**: 1.0. The lights stopped changing some minutes ago;
 *     what changed after that was the sky behind them.
 *
 * Downtown's mean emission at distance is 2.6 x 0.2128 x 0.24 x 0.95 = 0.126,
 * against the avenues' 0.043 — a ratio of just under 3:1, which is the whole
 * picture, since the thing that makes a night skyline is not that the towers
 * are taller but that they are the part of the city with all its lights still
 * on. Around each of those figures the per-building variation spans 0.15x to
 * 2.7x, so a run of towers has dark ones in it and the odd one blazing, and the
 * financial district does not smear into a single rectangle when you pull back.
 *
 * On a moonless night, whole-board framing, measured off the render: the Bay
 * Area board puts downtown at about y34 mean and its brightest windows past
 * y130, against land at y23, bay water at y15 and sky at y29; the SoCal board,
 * which has no marine layer over it, comes out at y36 / y137 against land y34,
 * ocean y13 and sky y15. The city is comfortably the brightest thing in the
 * frame in both, which is the relationship that has to hold — and it stopped
 * holding, briefly, when `atmosphere.ts` first raised the ground under it.
 * That is what the 2.6 and the 0.95 are for.
 *
 * SF's twenty-nine roads at 55 m spacing come to 12,038 lamps in one draw call,
 * comfortably under the 24,000 ceiling. The ceiling exists for the city pack
 * that arrives with a full street network in it rather than twenty-nine
 * arterials, where the same spacing would produce a point cloud in the millions.
 */
