/**
 * Fire on the board: the marks, the ground extent, the hot pixels and the
 * plumes — and, far more often than any of them, nothing at all.
 *
 * ### The honest empty board is the deliverable
 *
 * On the day this was written the SoCal board's bounds held **twenty-two live
 * incident rows**, every one of them with `acres: null`, fifteen of them
 * nameless LA County dispatch numbers that open when an engine rolls and close
 * when it turns round. Nothing was burning in Los Angeles. The base SoCal frame
 * was opened and checked by eye: pale sand basin, white-blue buildings, green
 * ridges, blue ocean, pale sky, and **not one saturated warm pixel anywhere in
 * it**. One orange glyph is therefore the most salient object on that board, and
 * twenty-two of them, wrong, is a credibility loss no later polish recovers.
 *
 * So this layer draws what `promote()` gives it and never one thing more. It
 * does not filter, it does not score, and it does not promote a hot pixel into a
 * fire. Every "should this be drawn" question was answered once, in
 * `src/server/fires.ts`, where it can be tested without a GL context. The
 * division is deliberate and it is the whole architecture of the feature: a
 * statement about data that lives inside a mesh builder is a statement nobody
 * can test.
 *
 * ### Four layers, three draw calls, zero lights
 *
 *   - **The mark and its ground extent share one `InstancedMesh`.** The base
 *     geometry is a six-sided ember *and* a flat quad — eight triangles — with a
 *     per-vertex `aPart` selecting which half a vertex belongs to. So the extent
 *     disc costs two triangles a fire and **no extra draw call**, which is what
 *     lets it exist at all.
 *   - **Hot pixels are one `THREE.Points`.** Zero triangles, one draw, the
 *     `satellites.ts` pattern exactly.
 *   - **Plumes are one instanced mesh**, in `fireSmoke.ts`, which this module
 *     composes so that `scene.ts` sees one layer and one factory.
 *
 * There is **no `THREE.Light` in this file and there must never be one.**
 * CONTRACT.md §4 gives `Atmosphere` sole ownership of the rig, and a wildfire is
 * the single most tempting exception in the codebase — it is *obviously* a light
 * and a `PointLight` is one line. `nightlights.ts` records why the rule exists:
 * three.js evaluates every light per fragment and the practical ceiling is a few
 * dozen. Night glow here is emission plus an over-range colour through ACES, and
 * it is both cheaper and better.
 *
 * ### Why the ground extent is a gradient and not a circle
 *
 * **There are no perimeters.** FIRIS is declared in the upstream collector and
 * never called; there is no perimeter table and not one row from it. Every fire
 * in the store is a point. Bug Fire's 93,733 acres is 380 km² — a disc 22 km
 * across, 56 units wide on a 393-unit SoCal board — so it cannot honestly be a
 * dot either. What is honest is *extent without shape*: a radial gradient with
 * no hard edge anywhere, which reads as "about this much ground" and refuses to
 * claim a boundary. A crisp circle would claim one, and would be wrong at every
 * point on its circumference.
 *
 * ### One deliberate departure from the design, written down
 *
 * The design specified `AdditiveBlending` for the extent disc and flagged its
 * own risk beside it: additive orange over the California board's warm sand at
 * golden hour washes out rather than reads. It does. This uses normal alpha
 * blending and moves the *colour* with the night factor instead — a smoke-brown
 * that darkens the terrain by day, an over-range ember that blooms through ACES
 * by night. That satisfies the acceptance test the design actually wrote down
 * ("reads rather than washing out") and it avoids a second, quieter problem:
 * switching `material.blending` at dusk bumps `material.version` and rebuilds
 * the program.
 */

import * as THREE from "three";
import {
  FIRE_EXTENT_DAY,
  FIRE_EXTENT_NIGHT,
  FIRE_MARK_BASE,
  FIRE_MARK_TIP,
  HOT_PIXEL_COLOR,
  HOT_PIXEL_PERSISTENT_COLOR,
  hotPixelTexture,
} from "../assets/fire.ts";
import { detectionConfidence } from "../server/fires.ts";
import { nightFactor } from "./atmosphere.ts";
import { createFireSmoke, MAX_PLUMES, type SmokePlume } from "./fireSmoke.ts";
import type { LightingState } from "./types.ts";
import type { World } from "./world.ts";

// ---- The shapes this layer is handed --------------------------------------

/**
 * One promoted wildfire, as the renderer receives it.
 *
 * **Declared here rather than imported, on purpose.** The authority on this
 * shape is `FirePromotion` in `src/server/fires.ts`; `scene.ts` restates the
 * same minimum and this restates it a third time. That is not duplication for
 * its own sake — the repo is structurally typed, the three are checked against
 * each other at the one place they meet (`SceneOptions.fires`), and keeping a
 * local copy is what stops `engine/` taking a dependency on a wire module. It is
 * the same rule that keeps `Marker` in `engine/types.ts` and the marker row on
 * the server.
 *
 * `lon`, not `lng` — the wire's spelling, kept so `promote()`'s output flows in
 * with no adapter. `acres` is a plain `number` because past the gate acreage is
 * a fact; a renderer sizing a glyph never has to ask.
 */
export interface DrawnFireMark {
  id: string;
  name: string | null;
  lat: number;
  lon: number;
  acres: number;
  pctContained: number | null;
  tier: 1 | 2;
  observedAt: string | null;
}

/**
 * One satellite thermal detection — **evidence, not an incident.**
 *
 * `confidence` carries two incompatible scales in one column: MODIS publishes an
 * integer 0–100, VIIRS publishes `low`/`nominal`/`high`. Nothing here parses it;
 * `detectionConfidence()` does the branch once, upstream, and `"nominal"`
 * reaching a shader as `NaN` is a hole in the frame.
 */
export interface FireDetectionMark {
  sat: string;
  lat: number;
  lon: number;
  frp: number | null;
  confidence: string | null;
  persistent: boolean;
  acquiredAt: string;
}

export interface FireView {
  readonly drawn: readonly DrawnFireMark[];
  readonly detections: readonly FireDetectionMark[];
  /** ISO-8601 of the last **successful** upstream fetch. Epoch zero when never. */
  readonly fetchedAt: string;
  /** Milliseconds since `fetchedAt`, or `null` when nothing has ever answered. */
  readonly ageMs: number | null;
}

export interface FireLayerOptions {
  /** Board span in scene units — the larger projected extent of `city.bounds`. */
  span: number;
  /** Fixed instance capacity. Matches `FIRE_DRAW_LIMIT`; see the constant. */
  maxFires?: number;
  maxDetections?: number;
  /** Plumes off entirely. `setSmokeVisible` is the runtime switch. */
  smoke?: boolean;
  /** Test seam: suppress the flicker without a `matchMedia` to read. */
  reducedMotion?: boolean;
}

/**
 * The layer, as `scene.ts` uses it — plus three read-backs that are not in the
 * seam and are not meant to be. `markCount`, `plumeCount` and `inspect` exist so
 * the panel can caption what is on screen and so a test can assert what a
 * picture cannot: that a growing fire got a longer plume without the drawn set
 * changing.
 */
export interface FireLayer {
  group: THREE.Object3D;
  setFires(view: FireView | null): void;
  setSmokeVisible(visible: boolean): void;
  setLighting(state: LightingState): void;
  /**
   * The fog distances alone, for both the marks and the plumes. See
   * `SceneHandle.setAerialFog`.
   */
  setFogDistances(near: number, far: number): void;
  setSolarElevation(degrees: number): void;
  setWind(kph: number | null, fromDeg: number | null): void;
  tick(dt: number): void;
  /** 0..1 smoke load at a coordinate, for haze somewhere else. */
  smokeLoadAt(lat: number, lng: number): number;
  /** How many marks are drawn right now. */
  markCount(): number;
  /** How many hot pixels are drawn right now. */
  detectionCount(): number;
  /** How many plumes are drawn right now. */
  plumeCount(): number;
  /** What this fire was rendered as, or `null` if it is not drawn. */
  inspect(id: string): FireRenderState | null;
  dispose(): void;
}

/** What one fire ended up looking like. A debug and test read-back. */
export interface FireRenderState {
  /** 0..1 severity, from acreage and containment. Drives size and emission. */
  heat: number;
  /** 0..1, from the acreage series. Zero for every fire the store has ever held. */
  momentum: number;
  /** Fractional acreage growth per hour, unclamped. */
  growthPerHour: number;
  /** Mark base radius, scene units. */
  markUnits: number;
  /** Ground extent radius, scene units. */
  extentUnits: number;
  /** Plume length in scene units, or 0 where this fire earned no plume. */
  plumeUnits: number;
}

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

/**
 * Instance capacity. Matches `FIRE_DRAW_LIMIT` in `src/server/fires.ts`, which
 * is the cap `promote()` already applies — this is the buffer that assumes it.
 * Not a data claim: the whole state has produced five.
 */
export const FIRE_MARK_CAPACITY = 64;

/** Hot-pixel capacity. 292 of the store's 425 fall inside the California board. */
export const HOT_PIXEL_CAPACITY = 768;

/**
 * Mark base radius as a fraction of the board span.
 *
 * A symbol, not a measurement — the extent disc is the measurement. It is sized
 * in *board* units rather than in metres because its job is to be legible from
 * the pose the board is normally read at, and a mark that scaled with the map
 * would be sub-pixel on California and enormous on SoCal. About eleven screen
 * pixels across on a 1,280-wide whole-board frame.
 */
const MARK_SPAN_FRACTION = 0.0105;

/** Mark height over base radius. Tall enough to read as a spike from low angles. */
const MARK_HEIGHT_RATIO = 2.4;

/** Square metres in an acre. The extent disc's entire arithmetic. */
const SQ_M_PER_ACRE = 4046.8564224;

/**
 * The plume's length coefficient, in kilometres at a hundred acres, before wind.
 *
 * Three, not four, and the third of a kilometre matters: the design's own
 * warning is that "a 268-acre fire with a 40 km plume is a lie told in a medium
 * that reads as truthful". Under this, the live store's 268-acre Carrizo Fire
 * gets about five kilometres and its 7,591-acre Timber Fire about twenty-eight,
 * which are both roughly what those two fires actually produced.
 */
const PLUME_KM_AT_100_ACRES = 3.0;

/**
 * The hard cap, in kilometres. Forty is long, and it is *not* the number to
 * reach for when a plume looks short — the number to reach for is the picture.
 */
const PLUME_MAX_KM = 40;

/** How far a drawn fire's smoke is allowed to reach for `smokeLoadAt`, in km. */
const SMOKE_REACH_KM = 120;

/** Growth per hour, as a fraction of current acreage, that counts as full momentum. */
const FULL_MOMENTUM_GROWTH = 0.35;

/** The window `growthPerHour` looks back over, and how many samples it keeps. */
export const MOMENTUM_WINDOW_MS = 6 * 3_600_000;
const MOMENTUM_MAX_SAMPLES = 12;

/** Drawn over the smoke and over the cloud deck. A mark is not weather. */
const FIRE_RENDER_ORDER = 4;

// ---- Construction ---------------------------------------------------------

export function createFireLayer(world: World, options: FireLayerOptions): FireLayer {
  const group = new THREE.Group();
  group.name = "fires";

  const span = options.span > 0 ? options.span : 1;
  const capacity = Math.max(1, Math.floor(options.maxFires ?? FIRE_MARK_CAPACITY));
  const detectionCapacity = Math.max(
    1,
    Math.floor(options.maxDetections ?? HOT_PIXEL_CAPACITY),
  );
  const reducedMotion = options.reducedMotion ?? prefersReducedMotion();

  const markRadius = span * MARK_SPAN_FRACTION;
  const metresPerUnit = world.metresPerUnit > 0 ? world.metresPerUnit : 1;

  // ---- The mark and its extent, in one mesh -------------------------------

  const markGeometry = buildMarkGeometry();
  const iCenter = new Float32Array(capacity * 3);
  const iSize = new Float32Array(capacity * 2);
  const iHeat = new Float32Array(capacity * 2);
  const centerAttr = new THREE.InstancedBufferAttribute(iCenter, 3);
  const sizeAttr = new THREE.InstancedBufferAttribute(iSize, 2);
  const heatAttr = new THREE.InstancedBufferAttribute(iHeat, 2);
  centerAttr.setUsage(THREE.DynamicDrawUsage);
  sizeAttr.setUsage(THREE.DynamicDrawUsage);
  heatAttr.setUsage(THREE.DynamicDrawUsage);
  markGeometry.setAttribute("iCenter", centerAttr);
  markGeometry.setAttribute("iSize", sizeAttr);
  markGeometry.setAttribute("iHeat", heatAttr);
  markGeometry.instanceCount = 0;

  const markUniforms: Record<string, THREE.IUniform> = {
    uNight: { value: 0 },
    uTime: { value: 0 },
    uFlicker: { value: reducedMotion ? 0 : 1 },
    uMarkBase: { value: new THREE.Color(FIRE_MARK_BASE) },
    uMarkTip: { value: new THREE.Color(FIRE_MARK_TIP) },
    uExtentDay: { value: new THREE.Color(FIRE_EXTENT_DAY) },
    uExtentNight: { value: new THREE.Color(FIRE_EXTENT_NIGHT) },
    uExtentAlpha: { value: new THREE.Vector2(0.44, 0.66) },
    uLift: { value: markRadius * 0.22 },
    uFogColor: { value: new THREE.Color(0.8, 0.85, 0.9) },
    uFogNear: { value: span },
    uFogFar: { value: span * 4 },
  };

  const markMaterial = new THREE.ShaderMaterial({
    uniforms: markUniforms,
    vertexShader: MARK_VERTEX_SHADER,
    fragmentShader: MARK_FRAGMENT_SHADER,
    transparent: true,
    depthTest: true,
    /**
     * Never written. The extent disc and the ember overlap each other and the
     * terrain, and a transparent surface that writes depth punches a hole in
     * whatever draws after it. Tested, though: a fire behind a ridge is behind
     * the ridge, and turning the test off would put the Sierra's fires in front
     * of the Sierra.
     */
    depthWrite: false,
    side: THREE.DoubleSide,
    /** See `clouds.ts`: without this a transparent DoubleSide material renders
     * twice and rebuilds its program every frame, forever. */
    forceSinglePass: true,
  });

  const markMesh = new THREE.Mesh(markGeometry, markMaterial);
  markMesh.name = "fire-marks";
  markMesh.frustumCulled = false;
  markMesh.castShadow = false;
  markMesh.receiveShadow = false;
  markMesh.renderOrder = FIRE_RENDER_ORDER;
  markMesh.visible = false;
  group.add(markMesh);

  // ---- Hot pixels ---------------------------------------------------------

  const hotPositions = new Float32Array(detectionCapacity * 3);
  const hotColors = new Float32Array(detectionCapacity * 4);
  const hotSizes = new Float32Array(detectionCapacity);
  const hotPositionAttr = new THREE.BufferAttribute(hotPositions, 3);
  const hotColorAttr = new THREE.BufferAttribute(hotColors, 4);
  const hotSizeAttr = new THREE.BufferAttribute(hotSizes, 1);
  hotPositionAttr.setUsage(THREE.DynamicDrawUsage);
  hotColorAttr.setUsage(THREE.DynamicDrawUsage);
  hotSizeAttr.setUsage(THREE.DynamicDrawUsage);
  const hotGeometry = new THREE.BufferGeometry();
  hotGeometry.setAttribute("position", hotPositionAttr);
  hotGeometry.setAttribute("aColor", hotColorAttr);
  hotGeometry.setAttribute("aSize", hotSizeAttr);
  hotGeometry.setDrawRange(0, 0);

  const hotTexture = hotPixelTexture();
  const hotUniforms: Record<string, THREE.IUniform> = {
    uScale: { value: 1 },
    /**
     * A plotted sample has to stay legible against whatever it is plotted on,
     * and the board it is plotted on changes colour by twelve stops between noon
     * and midnight. The hue does not move — it is cold at both ends, because
     * that is what keeps it from reading as fire — only its *value* does: a deep
     * slate against pale sand at noon, a pale ice against black terrain at
     * night. The per-point colour carries the persistent demotion and this
     * carries the time of day; the shader multiplies them.
     */
    uDayTint: { value: new THREE.Color(0.32, 0.46, 0.62) },
    uNightTint: { value: new THREE.Color(1.25, 1.45, 1.7) },
    uNight: { value: 0 },
  };
  if (hotTexture) hotUniforms.uMap = { value: hotTexture };

  const hotMaterial = new THREE.ShaderMaterial({
    uniforms: hotUniforms,
    defines: hotTexture ? { USE_HOT_MAP: "" } : {},
    vertexShader: HOT_VERTEX_SHADER,
    fragmentShader: HOT_FRAGMENT_SHADER,
    transparent: true,
    depthWrite: false,
    depthTest: true,
  });

  const hotPoints = new THREE.Points(hotGeometry, hotMaterial);
  hotPoints.name = "fire-hot-pixels";
  hotPoints.frustumCulled = false;
  hotPoints.renderOrder = FIRE_RENDER_ORDER - 1;
  hotPoints.visible = false;
  group.add(hotPoints);

  // ---- Plumes -------------------------------------------------------------

  const smoke = createFireSmoke({ span, maxPlumes: MAX_PLUMES });
  if (options.smoke === false) smoke.setVisible(false);
  group.add(smoke.group);

  // ---- State --------------------------------------------------------------

  const momentum = createMomentumTracker();
  /** One scratch colour for the whole hot-pixel loop; see `satellites.ts`. */
  const scratchColor = new THREE.Color();
  const rendered = new Map<string, FireRenderState>();
  let drawn: readonly DrawnFireMark[] = [];
  let marks = 0;
  let detections = 0;
  let windKph: number | null = null;
  let windFromDeg: number | null = null;
  let elapsed = 0;

  function rebuild(): void {
    const plumes: SmokePlume[] = [];
    rendered.clear();
    marks = 0;

    for (const fire of drawn) {
      if (marks >= capacity) break;
      const [x, z] = world.project(fire.lat, fire.lon);
      const y = world.groundAt(fire.lat, fire.lon);

      const rate = momentum.rateFor(fire.id);
      const mom = momentumOf(rate);
      const heat = fireHeat(fire.acres, fire.pctContained);
      const scale = markRadius * (0.78 + 0.5 * heat);
      const extentUnits = Math.max(
        (extentRadiusKm(fire.acres) * 1000) / metresPerUnit,
        scale * 1.15,
      );

      iCenter[marks * 3] = x;
      iCenter[marks * 3 + 1] = y;
      iCenter[marks * 3 + 2] = z;
      iSize[marks * 2] = scale;
      iSize[marks * 2 + 1] = extentUnits;
      iHeat[marks * 2] = heat;
      iHeat[marks * 2 + 1] = mom;

      let plumeUnits = 0;
      /**
       * **Tier decides, and only tier.** `promote()` set it from
       * `FIRE_TIER_PLUME_ACRES`, and re-deriving it here from acreage would put
       * the threshold in two files and make the picture stop matching the
       * caption the first time one of them moved.
       */
      if (fire.tier === 2 && plumes.length < MAX_PLUMES) {
        const lengthKm = plumeLengthKm(fire.acres, windKph, mom);
        plumeUnits = (lengthKm * 1000) / metresPerUnit;
        plumes.push({
          id: fire.id,
          x,
          y,
          z,
          length: plumeUnits,
          width: (plumeWidthKm(fire.acres) * 1000) / metresPerUnit,
          density: plumeDensity(fire.acres),
          lift: mom,
        });
      }

      rendered.set(fire.id, {
        heat,
        momentum: mom,
        growthPerHour: rate,
        markUnits: scale,
        extentUnits,
        plumeUnits,
      });
      marks += 1;
    }

    markGeometry.instanceCount = marks;
    centerAttr.needsUpdate = true;
    sizeAttr.needsUpdate = true;
    heatAttr.needsUpdate = true;
    // Not "drawing nothing" — an object the renderer never visits. This is what
    // makes the quiet board cost zero draw calls rather than three cheap ones.
    markMesh.visible = marks > 0;
    smoke.setPlumes(plumes);
  }

  return {
    group,

    setFires(view) {
      if (view === null || view === undefined) {
        drawn = [];
        momentum.retain([]);
        rebuild();
        detections = 0;
        hotGeometry.setDrawRange(0, 0);
        hotPoints.visible = false;
        return;
      }

      /**
       * Filtered before anything else touches it, and the order matters: a body
       * from a server one version behind, or a hand-built one in a test, is a
       * shape this module did not build. The only acceptable response to a row
       * it cannot read is to draw fewer rows — the consumer is a render loop and
       * a throw here is a black page.
       */
      const next = (Array.isArray(view.drawn) ? view.drawn : []).filter(
        (fire): fire is DrawnFireMark =>
          fire !== null &&
          typeof fire === "object" &&
          typeof fire.id === "string" &&
          Number.isFinite(fire.lat) &&
          Number.isFinite(fire.lon) &&
          Number.isFinite(fire.acres),
      );

      const fetchedMs = Date.parse(view.fetchedAt);
      const fallbackMs = Number.isFinite(fetchedMs) && fetchedMs > 0 ? fetchedMs : Date.now();
      for (const fire of next) {
        const observed = typeof fire.observedAt === "string" ? Date.parse(fire.observedAt) : NaN;
        momentum.observe(fire.id, Number.isFinite(observed) ? observed : fallbackMs, fire.acres);
      }
      momentum.retain(next.map((fire) => fire.id));
      drawn = next;
      rebuild();

      // ---- Hot pixels ----
      const marks2 = Array.isArray(view.detections) ? view.detections : [];
      let n = 0;
      for (const detection of marks2) {
        if (n >= detectionCapacity) break;
        if (detection === null || typeof detection !== "object") continue;
        if (!Number.isFinite(detection.lat) || !Number.isFinite(detection.lon)) continue;
        const [x, z] = world.project(detection.lat, detection.lon);
        const style = hotPixelStyle(detection);
        hotPositions[n * 3] = x;
        // Lifted off the terrain by a fraction of the mark, so a pixel on a
        // ridge is not swallowed by the ridge. It is a plotted sample and it is
        // allowed to float; pretending it sits on the ground would be claiming a
        // precision a 375 m cell does not have.
        hotPositions[n * 3 + 1] = world.groundAt(detection.lat, detection.lon) + markRadius * 0.2;
        hotPositions[n * 3 + 2] = z;
        scratchColor.set(style.color);
        hotColors[n * 4] = scratchColor.r;
        hotColors[n * 4 + 1] = scratchColor.g;
        hotColors[n * 4 + 2] = scratchColor.b;
        hotColors[n * 4 + 3] = style.opacity;
        hotSizes[n] = style.size;
        n += 1;
      }
      detections = n;
      hotGeometry.setDrawRange(0, n);
      hotPositionAttr.needsUpdate = true;
      hotColorAttr.needsUpdate = true;
      hotSizeAttr.needsUpdate = true;
      hotPoints.visible = n > 0;
    },

    setSmokeVisible(visible) {
      smoke.setVisible(visible && options.smoke !== false);
    },

    setLighting(state) {
      if (state.fog) {
        (markUniforms.uFogColor?.value as THREE.Color).set(state.fog.color);
        if (markUniforms.uFogNear) markUniforms.uFogNear.value = state.fog.near;
        if (markUniforms.uFogFar) markUniforms.uFogFar.value = state.fog.far;
      }
      smoke.setLighting(state);
    },

    setFogDistances(near, far) {
      if (markUniforms.uFogNear) markUniforms.uFogNear.value = near;
      if (markUniforms.uFogFar) markUniforms.uFogFar.value = far;
      smoke.setFogDistances(near, far);
    },

    setSolarElevation(degrees) {
      if (!Number.isFinite(degrees)) return;
      const night = nightFactor(degrees);
      if (markUniforms.uNight) markUniforms.uNight.value = night;
      if (hotUniforms.uNight) hotUniforms.uNight.value = night;
      smoke.setNight(night);
    },

    setWind(kph, fromDeg) {
      windKph = kph;
      windFromDeg = fromDeg;
      smoke.setWind(kph, fromDeg);
      // Plume length moves with wind speed, so the plumes have to be restated.
      // Cheap: it is two instance attributes and no allocation past the array.
      rebuild();
    },

    tick(dt) {
      if (!Number.isFinite(dt)) return;
      elapsed = (elapsed + dt) % 1000;
      if (markUniforms.uTime) markUniforms.uTime.value = elapsed;
      smoke.tick(dt);
    },

    smokeLoadAt(lat, lng) {
      return smokeLoad(drawn, lat, lng, windFromDeg);
    },

    markCount() {
      return marks;
    },

    detectionCount() {
      return detections;
    },

    plumeCount() {
      return smoke.plumeCount();
    },

    inspect(id) {
      return rendered.get(id) ?? null;
    },

    dispose() {
      markGeometry.dispose();
      markMaterial.dispose();
      hotGeometry.dispose();
      hotMaterial.dispose();
      hotTexture?.dispose();
      smoke.dispose();
      group.clear();
    },
  };
}

// ---- The arithmetic, all of it pure and all of it tested ------------------

/**
 * The radius of a disc of the same area, in kilometres.
 *
 * This is the *only* honest thing that can be said about a fire's ground extent
 * from the data available, and it is deliberately said as an area rather than as
 * a shape. 93,733 acres is 379 km² and an 11 km radius; 10 acres is 113 m.
 */
export function extentRadiusKm(acres: number): number {
  if (!Number.isFinite(acres) || acres <= 0) return 0;
  const areaKm2 = (acres * SQ_M_PER_ACRE) / 1_000_000;
  return Math.sqrt(areaKm2 / Math.PI);
}

/**
 * How hot a fire looks, 0..1, from acreage and containment.
 *
 * Logarithmic in acreage, because the range this has to span is four orders of
 * magnitude and a linear map makes everything under a thousand acres identical.
 * Containment contributes because a fire at 70% is a fire that is being beaten,
 * and the gate has already refused everything at 80% and over.
 */
export function fireHeat(acres: number, pctContained: number | null): number {
  const size = clamp01(Math.log10(Math.max(10, acres) / 10) / 3);
  const open = 1 - clamp01((pctContained ?? 0) / 100);
  return clamp01(0.22 + 0.56 * size + 0.22 * open);
}

/**
 * Plume length in kilometres.
 *
 * A square root in acreage and a linear term in wind speed, then a hard cap.
 * The square root is the important half: a plume is a function of the fire's
 * *perimeter* far more than of its area, and a linear map in acreage puts a
 * continental smoke column over anything above a few thousand acres.
 */
export function plumeLengthKm(
  acres: number,
  windKph: number | null,
  momentum: number = 0,
): number {
  if (!Number.isFinite(acres) || acres <= 0) return 0;
  const base = PLUME_KM_AT_100_ACRES * Math.sqrt(acres / 100);
  const speed = windKph === null || !Number.isFinite(windKph) ? 14 : Math.max(0, windKph);
  const windScale = 0.55 + Math.min(1.6, speed / 26);
  // Momentum lengthens a plume it did not create. A third longer at full growth
  // is deliberately modest — the term's first real execution will be in
  // production during a fire, so a miscalibration must understate.
  const growth = 1 + 0.35 * clamp01(momentum);
  return Math.min(PLUME_MAX_KM, Math.max(2, base * windScale * growth));
}

/**
 * The ember's emissive multiplier, mirrored from `MARK_FRAGMENT_SHADER`.
 *
 * Held in TypeScript as well as in GLSL so the one property that matters can be
 * asserted without a GL context: that a fire which is growing is *brighter* than
 * the same fire holding steady. The two expressions must stay identical; each
 * carries a comment pointing at the other.
 */
export function markGlow(heat: number, momentum: number, night: number): number {
  return (
    (0.72 + 0.85 * clamp01(heat)) * (1 + clamp01(night) * (0.9 + 1.1 * clamp01(momentum)))
  );
}

/** Puff radius near the source, kilometres. Also square-root in acreage. */
export function plumeWidthKm(acres: number): number {
  if (!Number.isFinite(acres) || acres <= 0) return 0;
  return Math.min(2.8, Math.max(0.25, 0.29 * Math.sqrt(acres / 100)));
}

/** Plume opacity, 0..1. Bigger fires make *denser* smoke, not longer smoke. */
export function plumeDensity(acres: number): number {
  if (!Number.isFinite(acres) || acres <= 0) return 0;
  return clamp(0.28 + 0.45 * Math.log10(acres / 100 + 1), 0.28, 0.8);
}

/**
 * Fractional acreage growth per hour, from an acreage series.
 *
 * **Zero, for every fire the upstream store has ever held.** Across all 665
 * observations in its life, not one incident has recorded two different acreage
 * values — so this function's first real execution will be in production during
 * a fire, which is the worst possible debugging condition. That is why it is a
 * pure function with a synthetic series in the test suite rather than a closure
 * inside a mesh builder, and why everything it drives is a *modifier* on a fire
 * that already passed the gate. Momentum never promotes anything into being
 * drawn.
 *
 * First-to-last over the window rather than a regression: the samples are ten
 * minutes apart and an agency restating an acreage is a step, not a trend, so a
 * least-squares fit would smooth away the one thing worth seeing.
 */
export function growthPerHour(samples: readonly AcreageSample[]): number {
  if (samples.length < 2) return 0;
  const first = samples[0];
  const last = samples[samples.length - 1];
  if (first === undefined || last === undefined) return 0;
  if (!(first.acres > 0)) return 0;
  const hours = (last.atMs - first.atMs) / 3_600_000;
  if (!(hours > 0)) return 0;
  return Math.max(0, (last.acres - first.acres) / first.acres / hours);
}

/** Growth rate to a 0..1 uniform. `FULL_MOMENTUM_GROWTH` is the ceiling. */
export function momentumOf(growth: number): number {
  if (!Number.isFinite(growth) || growth <= 0) return 0;
  return smoothstep(0, FULL_MOMENTUM_GROWTH, growth);
}

export interface AcreageSample {
  readonly atMs: number;
  readonly acres: number;
}

export interface MomentumTracker {
  /** Record an observation. A repeat of the last timestamp is not one. */
  observe(id: string, atMs: number, acres: number): void;
  rateFor(id: string): number;
  /** Forget every fire not in this list. The gate drops fires; so does this. */
  retain(ids: readonly string[]): void;
  samples(id: string): readonly AcreageSample[];
}

export function createMomentumTracker(): MomentumTracker {
  const series = new Map<string, AcreageSample[]>();

  return {
    observe(id, atMs, acres) {
      if (typeof id !== "string" || id === "") return;
      if (!Number.isFinite(atMs) || !Number.isFinite(acres)) return;
      const existing = series.get(id) ?? [];
      const last = existing[existing.length - 1];
      // The feed is polled far more often than the agencies restate an acreage,
      // so the same observation arrives many times. Recording it repeatedly
      // would make the window cover minutes instead of hours and turn one
      // restatement into an enormous apparent growth rate.
      if (last !== undefined && atMs <= last.atMs) return;
      existing.push({ atMs, acres });
      const cutoff = atMs - MOMENTUM_WINDOW_MS;
      let trimmed = existing.filter((sample) => sample.atMs >= cutoff);
      if (trimmed.length > MOMENTUM_MAX_SAMPLES) {
        trimmed = trimmed.slice(trimmed.length - MOMENTUM_MAX_SAMPLES);
      }
      series.set(id, trimmed);
    },

    rateFor(id) {
      return growthPerHour(series.get(id) ?? []);
    },

    retain(ids) {
      const keep = new Set(ids);
      for (const id of [...series.keys()]) {
        if (!keep.has(id)) series.delete(id);
      }
    },

    samples(id) {
      return series.get(id) ?? [];
    },
  };
}

/** What one hot pixel is drawn as. Pure, so the branch can be asserted. */
export interface HotPixelStyle {
  size: number;
  opacity: number;
  color: number;
}

/**
 * A hot pixel's size, opacity and colour — **evidence styling, not fire
 * styling.**
 *
 * Two things are load-bearing here and both are data facts rather than taste:
 *
 *   - **The confidence branch.** MODIS reports an integer 0–100 and VIIRS
 *     reports `low`/`nominal`/`high` in the same column. `detectionConfidence()`
 *     does the branch once; nothing here parses the string, because `"nominal"`
 *     through `Number()` is `NaN` and `NaN` in an alpha is a hole in the frame.
 *   - **`persistent` is a demotion, not a filter.** There is a permanent
 *     industrial heat source 4.7 km from the upstream operator's house that
 *     reports at FRP ~1.0 on every pass on every day the store holds, with no
 *     incident behind it. `promote()` already splits those out of the drawn set,
 *     so in production this branch should never fire — it exists because a layer
 *     handed a raw body must not draw a flare stack at the same weight as a fire
 *     front, and because "it cannot happen" is not a rendering strategy.
 */
export function hotPixelStyle(detection: {
  sat: string;
  lat: number;
  lon: number;
  frp: number | null;
  confidence: string | null;
  persistent: boolean;
  acquiredAt: string;
}): HotPixelStyle {
  /**
   * `detectionConfidence` reads `sat` and `confidence` and nothing else.
   * `persistentDays` is supplied only to satisfy the wire shape without
   * importing the wire type into `engine/` — and if `FireDetection` ever grows
   * another required field, this line fails the typecheck, which is the
   * schema-drift alarm rather than a silent divergence.
   */
  const confidence = detectionConfidence({ ...detection, persistentDays: 0 });
  const frp = detection.frp;
  const power = frp === null || !Number.isFinite(frp) ? 0 : clamp01(Math.log10(1 + frp) / 2);
  const certainty = confidence === null ? 0.45 : confidence;

  const size = 2.6 + 5.6 * power;
  /**
   * The floor is not decoration. A hot pixel whose product published no
   * confidence at all is still a measurement, and at 0.16 it was invisible on
   * the SoCal board's pale sand — which turns "we looked and found seven warm
   * cells" into a frame that says nothing.
   */
  const opacity = 0.34 + 0.46 * certainty;

  if (detection.persistent) {
    return {
      // Two thirds the size, under a third the opacity, and a colour with almost
      // no hue left in it. Present, countable, and impossible to mistake for
      // something happening.
      size: size * 0.62,
      opacity: opacity * 0.3,
      color: HOT_PIXEL_PERSISTENT_COLOR,
    };
  }
  return { size, opacity, color: HOT_PIXEL_COLOR };
}

/**
 * 0..1 smoke load at a coordinate, from the drawn set and the board's wind.
 *
 * This is the number that couples the LA courtyard to the real sky. A fire sixty
 * kilometres up the San Gabriels is not a flame seen from a courtyard — it is a
 * brown horizon, a dimmed sun and air that stops being clear closer in — so what
 * travels is a scalar, not geometry.
 *
 * Alignment matters more than distance and that is the physical truth of it: a
 * fire twenty kilometres upwind is smoke overhead, and the same fire twenty
 * kilometres downwind is a clear day with something orange on the skyline. The
 * floor of 0.25 exists because wind is a ten-minute average and smoke is not.
 */
export function smokeLoad(
  drawn: readonly DrawnFireMark[],
  lat: number,
  lng: number,
  windFromDeg: number | null,
): number {
  if (drawn.length === 0) return 0;
  let load = 0;
  const travel = windFromDeg === null || !Number.isFinite(windFromDeg) ? null : windFromDeg + 180;

  for (const fire of drawn) {
    const dLat = lat - fire.lat;
    const dLng = (lng - fire.lon) * Math.cos((lat * Math.PI) / 180);
    const km = Math.hypot(dLat, dLng) * 111.32;
    if (km > SMOKE_REACH_KM) continue;

    const strength = clamp01(Math.log10(fire.acres / 100 + 1) / 2);
    if (strength <= 0) continue;
    const near = (1 - km / SMOKE_REACH_KM) ** 1.5;

    let alignment = 1;
    if (travel !== null && km > 0.5) {
      // Bearing from the fire to the point, clockwise from north.
      const toPoint = (Math.atan2(-dLng, -dLat) * 180) / Math.PI + 180;
      const delta = (((toPoint - travel) % 360) + 540) % 360 - 180;
      alignment = clamp01(Math.cos((delta * Math.PI) / 180));
    }
    load += strength * near * (0.25 + 0.75 * alignment);
  }
  return clamp01(load);
}

// ---- Geometry -------------------------------------------------------------

/**
 * A six-sided ember **and** a flat quad, in one buffer, distinguished by
 * `aPart`.
 *
 * This is the trick that makes the ground extent free. An `InstancedMesh` draws
 * one geometry per instance, so two shapes normally means two meshes and two
 * draw calls — and the extent disc, which is the honest answer to having no
 * perimeter data, would then be paying a draw call to say "about this much
 * ground". Packing both into one eight-triangle geometry and branching in the
 * vertex shader costs two triangles a fire and nothing else.
 *
 * Six sides rather than four or eight: four reads as a paper dart from directly
 * above, and eight is indistinguishable from six at every distance a board is
 * ever viewed from while costing a third more triangles.
 */
function buildMarkGeometry(): THREE.InstancedBufferGeometry {
  const sides = 6;
  const positions: number[] = [0, 1, 0];
  const parts: number[] = [0];
  for (let i = 0; i < sides; i++) {
    const angle = (i / sides) * Math.PI * 2;
    positions.push(Math.cos(angle), 0, Math.sin(angle));
    parts.push(0);
  }
  const quadBase = positions.length / 3;
  for (const [x, z] of [
    [-1, -1],
    [1, -1],
    [1, 1],
    [-1, 1],
  ] as const) {
    positions.push(x, 0, z);
    parts.push(1);
  }

  const index: number[] = [];
  for (let i = 0; i < sides; i++) {
    index.push(0, 1 + i, 1 + ((i + 1) % sides));
  }
  index.push(quadBase, quadBase + 1, quadBase + 2, quadBase, quadBase + 2, quadBase + 3);

  const geometry = new THREE.InstancedBufferGeometry();
  geometry.setAttribute("position", new THREE.BufferAttribute(Float32Array.from(positions), 3));
  geometry.setAttribute("aPart", new THREE.BufferAttribute(Float32Array.from(parts), 1));
  geometry.setIndex(index);
  return geometry;
}

// ---- Shaders --------------------------------------------------------------

const MARK_VERTEX_SHADER = /* glsl */ `
attribute float aPart;   // 0 = ember, 1 = ground extent
attribute vec3 iCenter;
attribute vec2 iSize;    // mark radius, extent radius
attribute vec2 iHeat;    // heat 0..1, momentum 0..1

uniform float uLift;

varying float vPart;
varying float vUp;
varying vec2 vLocal;
varying vec2 vHeat;
varying float vFogDepth;

void main() {
  vPart = aPart;
  vHeat = iHeat;

  vec3 local;
  if (aPart < 0.5) {
    // A growing fire stands taller. The base radius is untouched, so momentum
    // changes the ember's proportions rather than its footprint — the footprint
    // is a claim about ground and momentum is not.
    float height = iSize.x * ${MARK_HEIGHT_RATIO.toFixed(2)} * (1.0 + 0.45 * iHeat.y);
    local = vec3(position.x * iSize.x, position.y * height, position.z * iSize.x);
    vUp = position.y;
    vLocal = vec2(0.0);
  } else {
    // Lifted clear of the terrain. The disc is flat and the ground is not, so
    // without this it z-fights across every slope it lies on.
    local = vec3(position.x * iSize.y, uLift, position.z * iSize.y);
    vUp = 0.0;
    vLocal = position.xz;
  }

  vec4 mv = modelViewMatrix * vec4(iCenter + local, 1.0);
  vFogDepth = -mv.z;
  gl_Position = projectionMatrix * mv;
}
`;

const MARK_FRAGMENT_SHADER = /* glsl */ `
precision highp float;

uniform float uNight;
uniform float uTime;
uniform float uFlicker;
uniform vec3 uMarkBase;
uniform vec3 uMarkTip;
uniform vec3 uExtentDay;
uniform vec3 uExtentNight;
uniform vec2 uExtentAlpha;  // day, night
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;

varying float vPart;
varying float vUp;
varying vec2 vLocal;
varying vec2 vHeat;
varying float vFogDepth;

void main() {
  vec3 color;
  float alpha;
  float fogBite;

  if (vPart < 0.5) {
    // The ember. Deep red at the base, pale yellow at the tip: the hue travel is
    // most of what makes a small shape read as combustion rather than as a cone.
    color = mix(uMarkBase, uMarkTip, smoothstep(0.0, 1.0, vUp));

    // Over-range on purpose. The renderer runs ACES, so a value above 1 lands as
    // a hot core that rolls off into its own surround instead of clipping to a
    // flat patch of paint. This is the whole of "night glow" and it constructs
    // no light: CONTRACT.md §4.
    // Mirrored in \`markGlow()\` above; the two must stay identical.
    float glow = (0.72 + 0.85 * vHeat.x) * (1.0 + uNight * (0.9 + 1.1 * vHeat.y));
    // A slight, slow flicker — six percent, on a per-fire phase taken from the
    // heat so no two marks pulse together. Zeroed under prefers-reduced-motion.
    glow *= 1.0 + uFlicker * 0.06 * sin(uTime * 3.1 + vHeat.x * 17.0);
    color *= glow;
    alpha = 0.94;
    // A mark is a symbol and symbols are not in the weather — but a symbol that
    // ignores fog entirely floats off the board, so it takes a little.
    fogBite = 0.35;
  } else {
    // The ground extent: a radial gradient that reaches zero with zero gradient
    // at its own rim, so there is no edge anywhere on it. A crisp circle would
    // claim a perimeter, and there is no perimeter data in this feed at all.
    float r = length(vLocal);
    float f = 1.0 - smoothstep(0.0, 1.0, r);
    f = f * f;
    color = mix(uExtentDay, uExtentNight, uNight);
    alpha = f * mix(uExtentAlpha.x, uExtentAlpha.y, uNight) * (0.5 + 0.5 * vHeat.x);
    fogBite = 0.85;
  }

  if (alpha < 0.004) discard;

  float fog = smoothstep(uFogNear, uFogFar, vFogDepth) * fogBite;
  color = mix(color, uFogColor, fog);

  gl_FragColor = vec4(color, alpha);
}
`;

const HOT_VERTEX_SHADER = /* glsl */ `
attribute vec4 aColor;
attribute float aSize;

uniform float uScale;
uniform vec3 uDayTint;
uniform vec3 uNightTint;
uniform float uNight;

varying vec4 vColor;

void main() {
  vColor = vec4(aColor.rgb * mix(uDayTint, uNightTint, uNight), aColor.a);
  vec4 mv = modelViewMatrix * vec4(position, 1.0);
  // Screen-space size, never attenuated by distance. A detection is a report,
  // not an object: it has no size in the world and pretending it has one would
  // make a 375 m cell grow into a lake as the camera came down.
  gl_PointSize = aSize * uScale;
  gl_Position = projectionMatrix * mv;
}
`;

const HOT_FRAGMENT_SHADER = /* glsl */ `
precision highp float;

#ifdef USE_HOT_MAP
uniform sampler2D uMap;
#endif

varying vec4 vColor;

void main() {
  float a;
  #ifdef USE_HOT_MAP
    a = texture2D(uMap, gl_PointCoord).a;
  #else
    a = 1.0 - smoothstep(0.32, 0.5, length(gl_PointCoord - 0.5));
  #endif
  a *= vColor.a;
  if (a < 0.01) discard;
  gl_FragColor = vec4(vColor.rgb, a);
}
`;

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

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

function clamp01(v: number): number {
  return Number.isFinite(v) ? (v < 0 ? 0 : v > 1 ? 1 : v) : 0;
}

/** 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 = clamp01((x - edge0) / (edge1 - edge0));
  return t * t * (3 - 2 * t);
}

/** Read once at construction. There is no DOM in `node --test`. */
function prefersReducedMotion(): boolean {
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
