/**
 * Rain over California, as one quad and one texture.
 *
 * The whole layer is **two triangles and one draw call**. That is not thrift for
 * its own sake: `echo_cells` turned out to be a regular quarter-degree lattice
 * rather than a list of extracted features, and once the data is a raster the
 * question "geometry or texture" has already been answered by the data. Clipped
 * to the extended state board the entire statewide field is 38 x 42 = 1,596
 * texels — 6.2 KB as RGBA, rebuilt only when a scan arrives.
 *
 * ### Only on the state board, and this is a fact about the cell
 *
 * A 0.25-degree cell is **27.8 km across**. The SoCal board spans 1.08 degrees
 * of latitude, so it is 4.3 cells tall; the Bay Area board is 3.4. A per-cell
 * raster there is four enormous squares over a city — a lie about resolution,
 * told in a medium that reads as truthful. `precipFactoryFor` refuses to build
 * the layer on a board that cannot carry the cell, and returns `null` rather
 * than a layer that draws nothing, so a fine board pays no import, no material
 * and no draw call at all.
 *
 * ### The empty sky is the normal sky
 *
 * California is under rain a mean 0.596% of the time, and across the twenty-six
 * frames in the upstream store tonight the range is 0.472% to 1.95%. So the
 * ordinary state of this layer is **nothing at all**, and "nothing at all" here
 * means the mesh is not in the group: `group.children` is empty, not one
 * invisible mesh and not 1,596 transparent texels. The gate upstairs
 * (`src/server/radar.ts`) decides that from statewide coverage, and when it says
 * no this object holds a geometry and a material and contributes nothing to any
 * frame.
 *
 * ### Three values a texel can hold, and they are three different claims
 *
 *   - **Rain**: at or above `RADAR_RAIN_DBZ`, coloured from the product's own
 *     NWS ramp so anyone who has seen a weather map reads it without being
 *     taught, alpha climbing steeply off the threshold.
 *   - **Dry**: a working radar looked and saw less than 20 dBZ. Fully
 *     transparent.
 *   - **Unknown**: `null` — a radar that should be looking there is off the
 *     air and nothing else covers the hole. Drawn as a faint neutral wash,
 *     because a hole drawn as clear sky is a claim nobody made. It is claimed
 *     narrowly: on a night with all sixteen stations transmitting, which is
 *     tonight, there is not one such cell. `src/server/radar.ts` has the
 *     argument for why the wider version of that rule was wrong, and the frame
 *     that showed it.
 *
 * ### Altitude: the cloud base, and the height was chosen with a picture
 *
 * A reflectivity composite is a plan view and has no altitude of its own, so
 * where the sheet goes is a rendering decision and not a meteorological one. It
 * was made twice.
 *
 * The first answer was six kilometres — above every point of California's land
 * in real metres, so the sheet cleared the Sierra crest, which at this board's
 * `verticalExaggeration: 15` stands at **39.5 scene units**. It looked wrong,
 * and the reason is parallax: 47 units of lift under a camera looking down at
 * fifty degrees shifts the sheet about forty units across the frame, which is
 * *seventy-five kilometres* on a board at 1,919 m to the unit. A weather map
 * whose rain is drawn seventy-five kilometres from where it fell is worse than
 * one whose mountains poke through it.
 *
 * So the sheet sits at the cloud base — 1,350 m, `clouds.ts`'s own
 * `BASE_ALTITUDE_M`, 10.6 units here — and the Sierra rises through it and
 * occludes it, which is what a low rain layer under a high range actually looks
 * like. Parallax drops to about twenty kilometres and the rain reads as being on
 * the land it is on. Both frames are in `/tmp/tera-look`; the second is the one
 * that is right.
 *
 * **This layer owns no light.** CONTRACT.md §4. `setLighting` reads the sky
 * colour and dims the sheet after dark; it constructs nothing. It reads the
 * *sky colour* and not `hemisphere.intensity`, which is the trap here — see
 * `skyBrightness`.
 */

import * as THREE from "three";
import { RADAR_RAIN_DBZ } from "../server/radar.ts";
import { radarRampRgb } from "../assets/radarRamp.ts";
import type { RadarField } from "./types.ts";
import type { LightingState } from "./types.ts";
import type { World } from "./world.ts";

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

/**
 * Where the sheet sits, in metres above sea level, before the board's own
 * vertical exaggeration.
 *
 * The cloud base — `BASE_ALTITUDE_M` in `clouds.ts`, to the metre, so the rain
 * falls out of the bottom of the deck rather than out of a number of its own.
 * See the header for why the first attempt at 6,000 m was wrong.
 */
export const PRECIP_ALTITUDE_M = 1_350;

/**
 * Alpha at the rain threshold, and at `PRECIP_ALPHA_FULL_DBZ` and above.
 *
 * Steep off the threshold on purpose. The sheet is transparent over about 99% of
 * its texels on an ordinary day, so the failure mode to design against is not
 * "too loud" — it is a whole layer nobody notices is there. A cell that has just
 * crossed into rain is already clearly a mark on the board; a 50 dBZ core is
 * nearly opaque.
 */
const PRECIP_ALPHA_MIN = 0.3;
const PRECIP_ALPHA_MAX = 0.92;
const PRECIP_ALPHA_FULL_DBZ = 50;
/** Exponent on the alpha ramp. Below 1, so the rise is fastest at the threshold. */
const PRECIP_ALPHA_CURVE = 0.7;

/**
 * How a cell nobody is looking at is drawn: a faint neutral wash.
 *
 * Deliberately weak. It has to be visible enough that the Pacific corner reads
 * as "not measured" when somebody looks for it, and weak enough that a fifth of
 * the board carrying it does not read as weather. Neutral rather than tinted,
 * because every colour on the ramp already means a reflectivity.
 */
const PRECIP_UNKNOWN_RGB: readonly [number, number, number] = [150, 155, 165];
const PRECIP_UNKNOWN_ALPHA = 0.07;

/**
 * How long a new scan takes to replace the one before it, in seconds.
 *
 * Scans are ten minutes apart, and the two obvious answers are both wrong. A
 * hard cut is a whole state's weather changing between two frames, which reads
 * as a glitch. A dissolve stretched across the full ten-minute step means that
 * for nine and a half of every ten minutes the board is showing a blend of two
 * observations and not either of them — the same objection that forbids splining
 * a vessel between fixes, in a gentler costume. Thirty seconds hides the seam
 * and leaves 95% of every step showing one real scan.
 */
const PRECIP_CROSSFADE_SECONDS = 30;

/**
 * The fewest lattice cells a board must span before a raster is honest on it.
 *
 * Twelve, in the shorter axis. California spans 38 x 42 and passes; SoCal spans
 * 4.3 x 6.6 and the Bay Area 3.4 x 3.6, and both are refused. There is no
 * threshold that makes four texels over Los Angeles into a picture of rain.
 */
export const PRECIP_MIN_CELLS = 12;

/**
 * How opaque the sheet is at midnight, as a fraction of its daytime self.
 *
 * Rain at night is dark. But this is a data overlay before it is weather, and an
 * overlay that fades out at sunset is a defect rather than a mood — the boards
 * this repo photographs at night are the ones with the most going on.
 */
const PRECIP_NIGHT_OPACITY = 0.55;

/** Above the terrain and the smoke, below the cloud deck's own sort. */
const PRECIP_RENDER_ORDER = 1;

// ---- The handle -----------------------------------------------------------

export interface PrecipLayerOptions {
  /** Board span in scene units, as `scene.ts` computes it. */
  span: number;
  /** Metres above sea level. Defaults to `PRECIP_ALTITUDE_M`; see the header. */
  altitudeM?: number;
  /** Seconds a new scan takes to replace the last. Defaults to 30. */
  crossfadeSeconds?: number;
}

export interface PrecipLayer {
  group: THREE.Object3D;
  setField(field: RadarField | null): void;
  setLighting(state: LightingState): void;
  tick(dt: number): void;
  dispose(): void;
  /** Texels at or above the rain threshold in the field being drawn. Zero when empty. */
  wetTexels(): number;
  /** Whether the sheet is currently contributing anything to the frame. */
  drawing(): boolean;
}

/** A board rectangle, in degrees. Restated; see `src/server/radar.ts`. */
export interface PrecipBounds {
  minLat: number;
  maxLat: number;
  minLng: number;
  maxLng: number;
}

/**
 * Whether this board's bounds can carry a quarter-degree cell.
 *
 * The whole "which boards get a raster" decision, in a pure function a test can
 * hold, rather than an `if (city.id === "california")` buried in the wiring.
 * `cellDeg` is a parameter so that the day the composite changes resolution this
 * answers the new question rather than the old one.
 */
export function boardCarriesRaster(bounds: PrecipBounds, cellDeg = 0.25): boolean {
  if (!Number.isFinite(cellDeg) || cellDeg <= 0) return false;
  const rows = (bounds.maxLat - bounds.minLat) / cellDeg;
  const cols = (bounds.maxLng - bounds.minLng) / cellDeg;
  return Math.min(rows, cols) >= PRECIP_MIN_CELLS;
}

/**
 * The factory for this board, or `null` for a board too fine to carry the cell.
 *
 * `null` and not a layer that draws nothing: a board that refuses the raster
 * must pay no material, no geometry and no import, and the difference is
 * visible in `scripts/performance-budget.mjs` rather than only in principle.
 */
export function precipFactoryFor(
  bounds: PrecipBounds,
): ((world: World, options: { span: number }) => PrecipLayer) | null {
  if (!boardCarriesRaster(bounds)) return null;
  return (world, options) => createPrecipLayer(world, options);
}

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

export function createPrecipLayer(world: World, options: PrecipLayerOptions): PrecipLayer {
  const group = new THREE.Group();
  group.name = "precip";

  const altitudeM = options.altitudeM ?? PRECIP_ALTITUDE_M;
  const crossfade = Math.max(0, options.crossfadeSeconds ?? PRECIP_CROSSFADE_SECONDS);

  // One unit quad, scaled and placed per field. Two triangles, for ever.
  const geometry = new THREE.PlaneGeometry(1, 1, 1, 1);
  geometry.rotateX(-Math.PI / 2);

  const material = new THREE.MeshBasicMaterial({
    transparent: true,
    // Premultiplied, and it is not a detail. The raster is 1,596 texels blown
    // up across a 554-unit board with linear filtering, so every rain patch's
    // edge is a long interpolation between a coloured texel and a transparent
    // one. Straight alpha interpolates the *colour* toward black on the way, and
    // the first shot of this layer had a dark fringe round every echo and a hard
    // dark line along the board edge where the lattice is clipped. See `paint`.
    premultipliedAlpha: true,
    depthWrite: false,
    // Visible from underneath as well as from above — the drive and actor
    // cameras sit below the cloud base and look up through it.
    side: THREE.DoubleSide,
    /**
     * **One draw call, and without this it is two.**
     *
     * three.js renders a `transparent` + `DoubleSide` material in two passes by
     * default — back faces, then front — so that a closed transparent solid
     * composites correctly. Measured on the shipped board: the sheet cost 2 draw
     * calls of the 45 this round has to spend, for a single flat quad that
     * cannot overlap itself. `forceSinglePass` is the switch that says so, and
     * the number came out of `renderer.info.render.calls` and not out of
     * reasoning about it.
     */
    forceSinglePass: true,
    // Not `AdditiveBlending`: rain is not light, and additive over a pale
    // Central Valley would turn a green echo white.
    blending: THREE.NormalBlending,
  });

  const mesh = new THREE.Mesh(geometry, material);
  mesh.name = "precip-sheet";
  mesh.renderOrder = PRECIP_RENDER_ORDER;
  mesh.frustumCulled = false;

  /** The scan being faded out, the scan being faded in, and how far along. */
  let previous: Uint8Array | null = null;
  let current: Uint8Array | null = null;
  let texture: THREE.DataTexture | null = null;
  let blend = 1;
  let held: RadarField | null = null;
  let wet = 0;
  let daylight = 1;

  function releaseTexture(): void {
    if (texture === null) return;
    texture.dispose();
    if (material.map === texture) material.map = null;
    texture = null;
  }

  function detach(): void {
    if (mesh.parent === group) group.remove(mesh);
    held = null;
    previous = null;
    current = null;
    wet = 0;
    blend = 1;
    releaseTexture();
    material.needsUpdate = true;
  }

  /** Write `blend` of `current` over `previous` into the texture and upload it. */
  function compose(): void {
    if (texture === null || current === null) return;
    const data = texture.image.data as Uint8Array;
    if (previous === null || blend >= 1 || previous.length !== current.length) {
      data.set(current);
    } else {
      const t = blend;
      for (let i = 0; i < data.length; i++) {
        // `| 0` rather than Math.round: this runs over 6,384 bytes and the
        // half-step it gives away is a fortieth of one alpha level.
        data[i] = ((previous[i] as number) + ((current[i] as number) - (previous[i] as number)) * t) | 0;
      }
    }
    texture.needsUpdate = true;
  }

  function setField(field: RadarField | null): void {
    const read = readable(field);
    if (read === null) {
      detach();
      return;
    }

    const next = paint(read);
    const sameShape =
      held !== null && held.rows === read.rows && held.cols === read.cols;

    previous = sameShape && current !== null ? current : null;
    current = next.bytes;
    wet = next.wet;
    blend = previous === null || crossfade === 0 ? 1 : 0;
    held = read;

    if (texture === null || !sameShape) {
      releaseTexture();
      texture = new THREE.DataTexture(
        new Uint8Array(read.rows * read.cols * 4),
        read.cols,
        read.rows,
        THREE.RGBAFormat,
        THREE.UnsignedByteType,
      );
      // Linear, deliberately. A 27.8 km cell drawn with hard texel edges claims
      // a precision the product does not have; a soft one claims none.
      texture.magFilter = THREE.LinearFilter;
      texture.minFilter = THREE.LinearFilter;
      texture.wrapS = THREE.ClampToEdgeWrapping;
      texture.wrapT = THREE.ClampToEdgeWrapping;
      texture.colorSpace = THREE.SRGBColorSpace;
      texture.generateMipmaps = false;
      material.map = texture;
      material.needsUpdate = true;
    }

    place(read);
    compose();
    if (mesh.parent !== group) group.add(mesh);
  }

  /**
   * Sit the quad over the lattice's own footprint, not over the board's bounds.
   *
   * The lattice is the product's grid clipped to the board, so its edges are a
   * half-cell outside the outermost cell CENTRES. Getting this wrong by a half
   * cell puts every echo 14 km from where it was measured, which is invisible
   * and wrong.
   */
  function place(field: RadarField): void {
    const south = field.minLat - field.cellLat / 2;
    const north = field.minLat + (field.rows - 0.5) * field.cellLat;
    const west = field.minLng - field.cellLng / 2;
    const east = field.minLng + (field.cols - 0.5) * field.cellLng;
    const [westX, southZ] = world.project(south, west);
    const [eastX, northZ] = world.project(north, east);
    mesh.scale.set(Math.abs(eastX - westX), 1, Math.abs(southZ - northZ));
    mesh.position.set((westX + eastX) / 2, world.metres(altitudeM), (southZ + northZ) / 2);
  }

  return {
    group,

    setField,

    setLighting(state: LightingState): void {
      // Rain at night is dark, but a data overlay that disappears after sunset
      // is a defect rather than a mood. Floored well short of zero.
      daylight = PRECIP_NIGHT_OPACITY + (1 - PRECIP_NIGHT_OPACITY) * skyBrightness(state);
      material.opacity = daylight;
    },

    tick(dt: number): void {
      if (current === null || blend >= 1 || crossfade === 0) return;
      if (!Number.isFinite(dt) || dt <= 0) return;
      blend = Math.min(1, blend + dt / crossfade);
      compose();
    },

    dispose(): void {
      detach();
      geometry.dispose();
      material.dispose();
    },

    wetTexels: () => wet,
    drawing: () => mesh.parent === group,
  };
}

// ---- Painting -------------------------------------------------------------

/**
 * One field as RGBA bytes, row-major from the south-west cell centre.
 *
 * Row 0 is the southern row and lands at `v = 0`, which is the bottom edge of
 * the plane; after the geometry's `rotateX(-PI/2)` the plane's `-Y` edge is the
 * one at `+Z`, and `+Z` is south because `World.project` negates latitude. The
 * mapping is therefore the identity and needs no flip — which is worth stating,
 * because a texture that is upside down over a state this shape looks plausible.
 */
export function paint(field: RadarField): { bytes: Uint8Array; wet: number } {
  const bytes = new Uint8Array(field.rows * field.cols * 4);
  let wet = 0;
  for (let i = 0; i < field.dbz.length; i++) {
    const value = field.dbz[i];
    const at = i * 4;
    if (value === null || value === undefined || !Number.isFinite(value)) {
      write(bytes, at, PRECIP_UNKNOWN_RGB, PRECIP_UNKNOWN_ALPHA);
      continue;
    }
    if (value < RADAR_RAIN_DBZ) continue; // zeroed already: dry is nothing at all
    write(bytes, at, radarRampRgb(value), alphaForDbz(value));
    wet += 1;
  }
  return { bytes, wet };
}

/**
 * One texel, **premultiplied**.
 *
 * The colour is scaled by its own alpha before it is written, which is what the
 * material is told to expect. Without it, linear filtering across the long
 * interpolation between a 50 dBZ texel and a transparent one walks the colour
 * down to black rather than fading it out, and every echo on the board gets a
 * dark halo and the clipped edge of the lattice gets a hard dark line. That was
 * visible in the first frame this layer ever produced and invisible in every
 * test.
 */
function write(
  bytes: Uint8Array,
  at: number,
  rgb: readonly [number, number, number],
  alpha: number,
): void {
  const a = clamp01(alpha);
  bytes[at] = Math.round(rgb[0] * a);
  bytes[at + 1] = Math.round(rgb[1] * a);
  bytes[at + 2] = Math.round(rgb[2] * a);
  bytes[at + 3] = Math.round(a * 255);
}

/** The alpha ramp, exported so a test can argue with the curve rather than the pixels. */
export function alphaForDbz(dbz: number): number {
  if (!Number.isFinite(dbz) || dbz < RADAR_RAIN_DBZ) return 0;
  const t = clamp01((dbz - RADAR_RAIN_DBZ) / (PRECIP_ALPHA_FULL_DBZ - RADAR_RAIN_DBZ));
  return PRECIP_ALPHA_MIN + (PRECIP_ALPHA_MAX - PRECIP_ALPHA_MIN) * Math.pow(t, PRECIP_ALPHA_CURVE);
}

/**
 * A field this layer will draw, or `null`.
 *
 * Total by design: the consumer is a render loop, so a body from a server one
 * version ahead, a `dbz` array of the wrong length, or a field with nothing wet
 * in it all produce an empty sky rather than a throw or a rotated raster.
 */
function readable(field: RadarField | null | undefined): RadarField | null {
  if (field === null || field === undefined || typeof field !== "object") return null;
  const { rows, cols, dbz } = field;
  if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) return null;
  if (!Number.isFinite(field.cellLat) || !Number.isFinite(field.cellLng)) return null;
  if (field.cellLat <= 0 || field.cellLng <= 0) return null;
  if (!Number.isFinite(field.minLat) || !Number.isFinite(field.minLng)) return null;
  if (!Array.isArray(dbz) || dbz.length !== rows * cols) return null;
  for (const value of dbz) {
    if (typeof value === "number" && Number.isFinite(value) && value >= RADAR_RAIN_DBZ) return field;
  }
  // Nothing wet. Not an error and not a fault — it is nine days in ten.
  return null;
}

/**
 * How bright the sky is, 0 at midnight and 1 in daylight, from the rig alone.
 *
 * **Not `hemisphere.intensity`, which is the trap here.** It measures 1.33 at
 * 21 degrees below the horizon and 0.95 at noon — it goes *up* at night, because
 * `atmosphere.ts` compensates a moonlit scene by raising the fill. A layer that
 * read it as a day/night signal would run at full strength in the dark and dim
 * at midday, and it would pass a test written against invented numbers. Measured
 * on the real rig before this was written: `sky.top` is 0x0d1730 at 04:35Z and
 * 0x77a1cb at 20:00Z, which is the honest signal and the one the eye reads too.
 *
 * `PrecipLayer` has no `setSolarElevation` — unlike the migration field, whose
 * subject *is* the night — so the rig is the only clock it gets.
 */
function skyBrightness(state: LightingState): number {
  const packed = state.sky?.top ?? state.hemisphere?.sky ?? 0xffffff;
  const r = ((packed >> 16) & 0xff) / 255;
  const g = ((packed >> 8) & 0xff) / 255;
  const b = (packed & 0xff) / 255;
  const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
  return clamp01((luminance - SKY_NIGHT_LUMINANCE) / (SKY_DAY_LUMINANCE - SKY_NIGHT_LUMINANCE));
}

/** Measured off the shipped rig: 0x0d1730 at 04:35Z, 0x77a1cb at 20:00Z. */
const SKY_NIGHT_LUMINANCE = 0.09;
const SKY_DAY_LUMINANCE = 0.58;

function clamp01(value: number): number {
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
}
