/**
 * The plumes — one instanced mesh, however many fires are burning.
 *
 * ### Why this is the layer that needs the most restraint
 *
 * Smoke is what makes a fire read from a hundred kilometres up: the mark says
 * *where*, the plume says *which way* and *how hard*. It is also, for exactly
 * that reason, the layer most able to overstate. At board altitude the eye has
 * no scale reference at all, so a forty-kilometre column over a two-hundred-acre
 * fire is a lie told in a medium that reads as truthful — and nobody looking at
 * it could tell. Three things hold that down and none of them is negotiable:
 *
 *   1. **The caller decides who gets one.** This module never looks at acreage.
 *      `src/engine/fires.ts` hands it plumes only for tier 2, and tier is
 *      decided once, upstream, in `src/server/fires.ts`.
 *   2. **Length comes from acreage through a square root and then meets a hard
 *      cap.** See `plumeLengthKm` in `fires.ts`; the cap is a constant with the
 *      argument written next to it.
 *   3. **Direction is the board's own wind.** Not the fires store's — every row
 *      of that table is a single grid sample at the upstream operator's house,
 *      it is written only inside the NWS *alerts* loop so a quiet day stores no
 *      wind at all, and using it would put Corona's breeze over Los Angeles
 *      while leaking a coordinate by proxy. `WeatherBody.windDirDeg` is
 *      per-region, already fetched, and is already what `clouds.ts` drifts on,
 *      so the plume and the sky agree instead of contradicting each other.
 *
 * ### The cost model, which is `clouds.ts`'s
 *
 * One `THREE.Mesh` over an `InstancedBufferGeometry` of unit quads. Plume count
 * therefore costs **no draw calls at all** — eight plumes and one plume are the
 * same single draw, and the only thing that moves is `instanceCount`. At the
 * defaults that is 8 x 120 = 960 quads, 1,920 triangles, one draw. The per-puff
 * geometry is written once at build time and never rewritten; a fires update
 * touches two instance attributes, and a wind change touches one uniform.
 *
 * ### It owns no light
 *
 * CONTRACT.md §4: `Atmosphere` is the sole light owner. This takes a
 * `LightingState` that was computed elsewhere and mixes two colours with it,
 * exactly as `clouds.ts` does. There is no `THREE.Light` in this file and there
 * must never be one.
 */

import * as THREE from "three";
import { SMOKE_LIT, SMOKE_SHADE, smokePuffTexture } from "../assets/fire.ts";
import { deviceProfile } from "./stage.ts";
import type { LightingState } from "./types.ts";
import { seededRandom } from "./world.ts";

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

/**
 * The most plumes drawn at once.
 *
 * Not a data claim — the whole state has never produced more than two fires over
 * a hundred acres on one board — but a bound on what an upstream that has gone
 * strange can do to a fixed buffer. The caller sorts worst-first, so the cap
 * drops the smallest fires' plumes and never the largest.
 */
export const MAX_PLUMES = 8;

/** Puffs in one plume. The column is a cloud of these and nothing else. */
export const PUFFS_PER_PLUME = 120;

/**
 * Instance-count multiplier on a handheld.
 *
 * The layer is fill-rate bound rather than vertex bound — 1,920 triangles is
 * nothing and 960 overlapping translucent quads is not — so the cheap thing to
 * give up on a phone is the number of overlaps, which is the same trade
 * `clouds.ts` makes for the same reason.
 */
const HANDHELD_DENSITY = 0.55;

/** Drawn under the cloud deck and under the fire marks. Both are deliberate. */
const SMOKE_RENDER_ORDER = 1;

/** Seconds per radian of the slow size breath. Slow enough that nobody sees it. */
const BOIL_RATE = 0.42;

/** The wind used when the region reports none, km/h and the bearing it blows from. */
const DEFAULT_WIND_KPH = 14;
const DEFAULT_WIND_FROM_DEG = 250;

// ---- Shapes ---------------------------------------------------------------

/**
 * One plume, in scene space, already decided.
 *
 * Everything here is a length in scene units rather than in metres or acres,
 * because the conversion is a fact about the board and the board's owner is
 * `fires.ts`. This module knows nothing about fire.
 */
export interface SmokePlume {
  /** Stable across updates, so a plume is not rebuilt when its fire is restated. */
  id: string;
  /** Scene x of the fire. */
  x: number;
  /** Scene y — the ground under the fire, not sea level. */
  y: number;
  /** Scene z of the fire. */
  z: number;
  /** How far downwind the column reaches, scene units. */
  length: number;
  /** Puff radius near the source, scene units. The column widens from here. */
  width: number;
  /** Opacity scale, 0..1. Bigger fires make more smoke, not longer smoke. */
  density: number;
  /**
   * 0..1: how steeply the column climbs before the wind lays it over.
   *
   * This is where momentum lands. A fire that is growing has a stronger
   * convection column and stands its smoke up; a fire holding steady trails it.
   * It changes the *shape* of a plume that was already earned, never whether
   * there is one.
   */
  lift: number;
}

export interface FireSmokeOptions {
  /** Board span in scene units, for the fog fallback. */
  span: number;
  maxPlumes?: number;
  puffsPerPlume?: number;
  /** Deterministic puff scatter. A reload must produce the same plume. */
  seed?: number;
  /** Override the handheld reduction. Tests pass 1 so the count is predictable. */
  density?: number;
}

export interface FireSmokeLayer {
  group: THREE.Group;
  /** Replace every plume. Worst-first; the tail past `maxPlumes` is dropped. */
  setPlumes(plumes: readonly SmokePlume[]): void;
  /** Wind as the observation reports it: km/h and the bearing it blows *from*. */
  setWind(kph: number | null, fromDeg: number | null): void;
  /** Applies a rig computed elsewhere. This layer never works one out itself. */
  setLighting(state: LightingState): void;
  /** The two fog uniforms alone. See `SceneHandle.setAerialFog`. */
  setFogDistances(near: number, far: number): void;
  /**
   * 0..1 night, from the same `nightFactor` seam `nightlights.ts` uses.
   *
   * Two things change with it and both are physical. Smoke is *dark* after
   * sunset — the first version stayed a pale cream because the colour was
   * interpolated toward the key rather than scaled by it, and a bright grey
   * plume over a black board at midnight is a searchlight. And the bottom of a
   * column above a burning fire is lit from *underneath*, by the fire, which is
   * the one light source in this scene that is not the sky.
   */
  setNight(night: number): void;
  setVisible(visible: boolean): void;
  tick(dt: number): void;
  /** How many plumes are currently drawn. For the panel and for the tests. */
  plumeCount(): number;
  dispose(): void;
}

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

export function createFireSmoke(options: FireSmokeOptions): FireSmokeLayer {
  const group = new THREE.Group();
  group.name = "fire-smoke";

  const maxPlumes = Math.max(1, Math.floor(options.maxPlumes ?? MAX_PLUMES));
  const density = options.density ?? (isHandheld() ? HANDHELD_DENSITY : 1);
  const puffs = Math.max(
    8,
    Math.round((options.puffsPerPlume ?? PUFFS_PER_PLUME) * clamp(density, 0.1, 1)),
  );
  const capacity = maxPlumes * puffs;

  const geometry = new THREE.InstancedBufferGeometry();
  /**
   * A hand-built unit quad rather than a `PlaneGeometry` we then throw away.
   * Disposing the plane fires the event the renderer uses to delete the GL
   * buffers *for the attribute objects still bound here*; four vertices are
   * cheaper than the comment explaining that crash. `clouds.ts` has the twin.
   */
  geometry.setAttribute(
    "position",
    new THREE.BufferAttribute(Float32Array.of(-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0), 3),
  );
  geometry.setIndex([0, 1, 2, 0, 2, 3]);

  /**
   * The per-puff scatter, written once and never again.
   *
   * `t` is stratified rather than random: puff `k` of `n` sits at
   * `(k + jitter) / n` along the column, so a plume is evenly populated instead
   * of clumpy-by-luck. A uniform draw over `[0,1]` leaves visible gaps at 120
   * samples and the gaps move when the seed changes, which is the kind of
   * artefact that gets blamed on the data.
   */
  const seedRandom = seededRandom(options.seed ?? 0xf17e5);
  const scatter = new Float32Array(capacity * 4);
  for (let p = 0; p < maxPlumes; p++) {
    for (let k = 0; k < puffs; k++) {
      const i = (p * puffs + k) * 4;
      const t = (k + seedRandom() * 0.9) / puffs;
      // A disc rather than a square: a square scatter gives a column with
      // corners on it, which is visible on the silhouette and on nothing else.
      const angle = seedRandom() * Math.PI * 2;
      const radius = Math.sqrt(seedRandom());
      scatter[i] = Math.min(1, t);
      scatter[i + 1] = Math.cos(angle) * radius;
      scatter[i + 2] = Math.sin(angle) * radius;
      scatter[i + 3] = seedRandom();
    }
  }
  const scatterAttr = new THREE.InstancedBufferAttribute(scatter, 4);
  geometry.setAttribute("iScatter", scatterAttr);

  const origins = new Float32Array(capacity * 3);
  const shapes = new Float32Array(capacity * 4);
  const originAttr = new THREE.InstancedBufferAttribute(origins, 3);
  const shapeAttr = new THREE.InstancedBufferAttribute(shapes, 4);
  originAttr.setUsage(THREE.DynamicDrawUsage);
  shapeAttr.setUsage(THREE.DynamicDrawUsage);
  geometry.setAttribute("iOrigin", originAttr);
  geometry.setAttribute("iShape", shapeAttr);
  geometry.instanceCount = 0;

  const texture = smokePuffTexture();

  const uniforms: Record<string, THREE.IUniform> = {
    uWind: { value: new THREE.Vector2(0, 1) },
    uTime: { value: 0 },
    uLit: { value: new THREE.Color(SMOKE_LIT) },
    uShade: { value: new THREE.Color(SMOKE_SHADE) },
    uBase: { value: new THREE.Color(0x2b211c) },
    uKey: { value: 1 },
    uFogColor: { value: new THREE.Color(0.8, 0.85, 0.9) },
    uFogNear: { value: options.span },
    uFogFar: { value: options.span * 4 },
  };
  if (texture) uniforms.uMap = { value: texture };

  const material = new THREE.ShaderMaterial({
    uniforms,
    defines: texture ? { USE_PUFF_MAP: "" } : {},
    vertexShader: VERTEX_SHADER,
    fragmentShader: FRAGMENT_SHADER,
    transparent: true,
    /**
     * Depth tested, never written. Tested because a plume behind a ridge is
     * behind the ridge; not written because these are overlapping translucent
     * quads and the first one drawn would punch a hole in every one behind it,
     * which at 120 puffs to a column is most of the column.
     */
    depthTest: true,
    depthWrite: false,
    side: THREE.DoubleSide,
    /**
     * Without this a `transparent` + `DoubleSide` material renders **twice** and
     * sets `needsUpdate` on each flip, rebuilding the program cache key every
     * frame forever. `clouds.ts` carries the full autopsy.
     */
    forceSinglePass: true,
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.name = "fire-smoke-puffs";
  /**
   * Never culled and never a shadow caster. The bounding sphere is computed
   * from the unit quad, so the CPU thinks this object is two units across and
   * would cull the whole plume from almost everywhere.
   */
  mesh.frustumCulled = false;
  mesh.castShadow = false;
  mesh.receiveShadow = false;
  mesh.renderOrder = SMOKE_RENDER_ORDER;
  mesh.visible = false;
  group.add(mesh);

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

  let plumes = 0;
  let visible = true;
  let elapsed = 0;
  const wind = uniforms.uWind?.value as THREE.Vector2;

  function applyWind(kph: number | null, fromDeg: number | null): void {
    const speed = kph === null || !Number.isFinite(kph) ? DEFAULT_WIND_KPH : Math.max(0, kph);
    const from =
      fromDeg === null || !Number.isFinite(fromDeg) ? DEFAULT_WIND_FROM_DEG : fromDeg;
    /**
     * `fromDeg` is where the wind comes *from*, so smoke travels toward
     * `from + 180`. Scene north is −Z and east is +X, which makes a bearing
     * `b` the vector `(sin b, −cos b)` in x/z — the same convention
     * `world.project` and `satellites.ts` use, stated once more because getting
     * it wrong here points every plume into the wind and looks plausible.
     */
    const toward = ((from + 180) * Math.PI) / 180;
    wind.set(Math.sin(toward), -Math.cos(toward));
    // Speed is not used as a direction, only as the caller's business: a plume
    // that got longer in a gale is `fires.ts`'s decision, made from this same
    // number. Kept in the signature because the layer that owns the wind should
    // be handed all of it, and unused here rather than silently absent.
    void speed;
  }

  applyWind(null, null);

  return {
    group,

    setPlumes(next) {
      const capped = next.slice(0, maxPlumes);
      for (let p = 0; p < capped.length; p++) {
        const plume = capped[p];
        if (plume === undefined) continue;
        for (let k = 0; k < puffs; k++) {
          const i = p * puffs + k;
          origins[i * 3] = plume.x;
          origins[i * 3 + 1] = plume.y;
          origins[i * 3 + 2] = plume.z;
          shapes[i * 4] = plume.length;
          shapes[i * 4 + 1] = plume.width;
          shapes[i * 4 + 2] = clamp(plume.density, 0, 1);
          shapes[i * 4 + 3] = clamp(plume.lift, 0, 1);
        }
      }
      plumes = capped.length;
      geometry.instanceCount = plumes * puffs;
      originAttr.needsUpdate = true;
      shapeAttr.needsUpdate = true;
      // An empty layer is not a layer drawing nothing — it is an object the
      // renderer never visits, which is what makes the quiet board cost zero
      // draw calls rather than one cheap one.
      mesh.visible = visible && plumes > 0;
    },

    setWind: applyWind,

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

    setLighting(state) {
      const key = clamp(state.sun.intensity / 3, 0, 1);
      if (uniforms.uKey) uniforms.uKey.value = key;
      const sun = new THREE.Color(state.sun.color);
      const lit = uniforms.uLit?.value as THREE.Color;
      const shade = uniforms.uShade?.value as THREE.Color;
      // The sun's own colour, half-strength. Smoke at golden hour is orange and
      // smoke at noon is grey, and the rig already knows which one it is. Then
      // *scaled* by the key rather than merely tinted by it: smoke is not a
      // light source and it cannot be brighter than what is falling on it.
      lit.set(SMOKE_LIT).lerp(sun, 0.45 * key).multiplyScalar(0.24 + 0.76 * key);
      shade
        .set(SMOKE_SHADE)
        .lerp(new THREE.Color(state.hemisphere.sky), 0.3)
        .multiplyScalar(0.2 + 0.8 * key);
      if (state.fog) {
        (uniforms.uFogColor?.value as THREE.Color).set(state.fog.color);
        if (uniforms.uFogNear) uniforms.uFogNear.value = state.fog.near;
        if (uniforms.uFogFar) uniforms.uFogFar.value = state.fog.far;
      }
    },

    setNight(night) {
      if (!Number.isFinite(night)) return;
      const n = clamp(night, 0, 1);
      // The column's own base, lit from below by the fire once the sky stops
      // doing it. Deliberately dim: a plume that glows is a plume claiming to be
      // flame, and the flame is the mark.
      (uniforms.uBase?.value as THREE.Color).set(0x2b211c).lerp(new THREE.Color(0x5a2a10), n);
    },

    setVisible(next) {
      visible = next;
      mesh.visible = next && plumes > 0;
    },

    tick(dt) {
      if (!Number.isFinite(dt)) return;
      elapsed = (elapsed + dt) % ((Math.PI * 2) / BOIL_RATE);
      if (uniforms.uTime) uniforms.uTime.value = elapsed;
    },

    plumeCount() {
      return plumes;
    },

    dispose() {
      geometry.dispose();
      material.dispose();
      texture?.dispose();
      group.clear();
    },
  };
}

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

const VERTEX_SHADER = /* glsl */ `
attribute vec3 iOrigin;
attribute vec4 iShape;   // length, width, density, lift
attribute vec4 iScatter; // t, discX, discZ, phase

uniform vec2 uWind;
uniform float uTime;

varying vec2 vLocal;
varying float vAlpha;
varying float vT;
varying float vFogDepth;

void main() {
  float t = iScatter.x;
  float len = iShape.x;
  float wid = iShape.y;
  float dens = iShape.z;
  float lift = iShape.w;

  vec3 p = iOrigin;

  // Downwind travel. Slightly super-linear so the column is dense at the source
  // and thins out along its length, which is what a real plume does and what
  // makes the head read as the fire rather than as the middle of a stripe.
  float along = len * pow(t, 1.15);
  p.x += uWind.x * along;
  p.z += uWind.y * along;

  // The rise. Steep at the base, flattening downwind; \`lift\` stands it up.
  // A plume is a *column* first and a streak second — the first version of this
  // rose about a puff radius over its whole length and read as a smear lying on
  // the ground rather than as smoke coming off a fire.
  p.y += wid * (4.6 + 9.0 * lift) * pow(t, 0.5);

  // The column widens as it travels, and the scatter is scaled with it. Tight at
  // the source: a plume that starts as wide as it ends has no origin in it, and
  // the origin is the whole point — it is where the fire is.
  float spread = wid * (0.2 + 2.05 * t);
  p.x += iScatter.y * spread;
  p.z += iScatter.z * spread;
  p.y += (iScatter.w - 0.5) * spread * 0.9;

  // A slow breath on a per-puff phase, so nothing in the column is ever frozen
  // and no two puffs breathe together.
  float boil = sin(uTime * ${BOIL_RATE.toFixed(3)} * 6.28318 + iScatter.w * 6.28318) * 0.07;
  float size = spread * (1.0 + boil);

  // Fades in over the first twentieth so there is no disc sitting on the fire,
  // and out over the last half so the plume has no end on it.
  vAlpha = dens * smoothstep(0.0, 0.04, t) * (1.0 - smoothstep(0.5, 1.0, t));
  vT = t;
  vLocal = position.xy;

  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  // The billboard: offset in view space, so every puff faces the camera and
  // keeps its screen orientation however the board is orbited.
  mv.xy += position.xy * size;
  vFogDepth = -mv.z;
  gl_Position = projectionMatrix * mv;
}
`;

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

uniform vec3 uLit;
uniform vec3 uShade;
uniform vec3 uBase;
uniform float uKey;
uniform vec3 uFogColor;
uniform float uFogNear;
uniform float uFogFar;
#ifdef USE_PUFF_MAP
uniform sampler2D uMap;
#endif

varying vec2 vLocal;
varying float vAlpha;
varying float vT;
varying float vFogDepth;

void main() {
  float a;
  #ifdef USE_PUFF_MAP
    a = texture2D(uMap, vLocal * 0.5 + 0.5).a;
  #else
    a = 1.0 - smoothstep(0.25, 1.0, length(vLocal));
  #endif
  a *= vAlpha;
  if (a < 0.004) discard;

  // Dark and brown at the source, pale grey by the time it has travelled. The
  // key collapses the modelling toward flat as the sun goes down, which is the
  // same thing \`clouds.ts\` does to a deck.
  vec3 body = mix(uShade, uLit, mix(0.5, 0.82, uKey));
  vec3 color = mix(uBase, body, smoothstep(0.01, 0.38, vT));

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

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

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

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

/** `deviceProfile()` reads `window`; the typecheck and the tests do not have one. */
function isHandheld(): boolean {
  if (typeof window === "undefined") return false;
  return deviceProfile().handheld;
}
