/**
 * Nocturnal migration over California, as one drift field of light.
 *
 * One `THREE.Points`, one draw call, zero triangles — the `nightlights.ts`
 * pattern, which puts San Francisco's 12,038 street lamps on the board for the
 * cost of a single cloud. Seven hundred motes is nothing beside that.
 *
 * ### What the data is, and therefore what this may claim
 *
 * BirdCast is a **forecast raster aggregated to county-nights**. The store holds
 * 7,575 rows at ten-minute resolution across 58 counties, each carrying birds
 * aloft, a mean altitude, a direction and a ground speed — and *nothing finer*.
 * There is no track, no individual, no species and no position. So:
 *
 *   - Motes scatter inside a **disc** of the county's true `areaKm2` about its
 *     Census internal point. A disc and not the county's outline, because an
 *     outline would claim a spatial structure the data does not have. San
 *     Bernardino's 52,073 km² is a 129 km disc; San Francisco's 601 km² is a
 *     14 km one, and the difference between them is the whole picture.
 *   - A mote is **never placed by interpolating between two granules**. It is
 *     spawned once, given its county's reported direction and ground speed at
 *     that moment, and then integrated forward on its own. When its life runs
 *     out it is spawned again from whatever the newest granule says. Joining two
 *     consecutive samples into a trajectory would be inventing the ten minutes
 *     in between, which is exactly what `Vessel` refuses to do between AIS
 *     fixes.
 *   - There is no hover, no label and no identity, because there is nothing to
 *     identify.
 *
 * ### Speed is real and therefore slow, and that is left alone
 *
 * At 8.2 m/s — the state's mean tonight — and 1,919 metres to the scene unit, a
 * mote covers 3.8 units in its fifteen-minute life on a 554-unit board. That is
 * a slow drift you notice by looking away and back, like cloud. It is not
 * exaggerated. Altitude *is* exaggerated, through the same
 * `altitudeSceneUnitsPerMetre` seam the aircraft use (0.01, `main.ts`), and the
 * two are different decisions: without the altitude exaggeration a bird at 726 m
 * would sit 0.38 units above the ground and be inside the terrain, so that
 * exaggeration buys visibility. Exaggerating the speed would only buy drama.
 *
 * ### The empty sky is the layer, most of the time
 *
 * 176 of 297 granules in the store are daytime and hold 104 rows between them,
 * against 7,719 at night. BirdCast measures only after dark, so this layer is
 * absent about fourteen hours in every twenty-four **by construction** — before
 * any question of season. `setSolarElevation` is therefore not a visibility
 * preference, it is the layer's own subject: the sun is up, so nothing is aloft,
 * and `src/server/birds.ts` owns the sentence that says so and what last night
 * did instead.
 *
 * ### The crow is not this
 *
 * `src/assets/actors/crow.ts` is 4,390 triangles across **33 meshes** — 33 draw
 * calls per bird. It is the player's avatar and a hero actor; forty of them
 * would be 1,320 draw calls against a whole-board budget of 650. And a 1.02 m
 * wingspan is 0.0005 scene units at this scale. On a board a bird can only ever
 * be a point of light.
 *
 * **This layer owns no light.** CONTRACT.md §4: nothing here constructs a
 * `THREE.Light`, and the motes are additive sprites, which is emission and not
 * illumination — the same distinction `nightlights.ts` turns on.
 */

import * as THREE from "three";
import { seededRandom, type World } from "./world.ts";
import type { LightingState, MigrationCounty, MigrationField } from "./types.ts";

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

/**
 * The most motes one county contributes, and the ceiling for the whole board.
 *
 * Twelve times fifty-eight is 696, which is the number the budget was costed
 * against. The buffer is allocated once at `MIGRATION_MAX_POINTS` and never
 * grows, so a granule that arrives with sixty counties in it draws 696 motes and
 * not a reallocation.
 */
export const MIGRATION_MOTES_PER_COUNTY = 12;
export const MIGRATION_MAX_POINTS = 700;

/**
 * Birds aloft at which a county is drawn at full strength.
 *
 * Twenty-five thousand, on a square-root scale — **a judgement, stated as one**,
 * in the manner of `FIRE_TIER_MIN_ACRES`. It was set by looking at the frame.
 *
 * The first attempt was sixty thousand and linear-ish, which allocated 300 motes
 * across the state on the granule of 2026-08-23T03:20Z and drew a board that was
 * empty except for the San Joaquin Valley — when the truth that night was that
 * every one of the 58 counties had birds over it. At twenty-five thousand the
 * same granule allocates 444, running 12 down to 1, and the state reads as a
 * state. The square root is a perceptual scale and is not a claim about numbers:
 * the count that *is* a claim is the sentence in the panel, and it comes from
 * the state row and never from these.
 */
export const MIGRATION_FULL_ALOFT = 25_000;

/**
 * How long a mote lives, in seconds, and how much that varies.
 *
 * Longer than the ten-minute granule on purpose. If every mote were reseeded the
 * instant a new granule arrived, the whole field would blink over at once every
 * ten minutes — a stutter far more noticeable than the change it was trying to
 * show. With a life longer than the step and a random phase at birth the
 * population turns over *continuously*: at any moment a few motes are fading in
 * with the newest numbers and a few are fading out with the last ones.
 */
const MOTE_LIFE_SECONDS = 900;
const MOTE_LIFE_JITTER = 0.35;

/** Fraction of a life spent fading in, and the same again fading out. */
const MOTE_FADE = 0.18;

/**
 * Solar elevation, in degrees, over which the field is gone.
 *
 * Full strength at or below civil twilight, nothing at all once the sun is up.
 * Not a preference: BirdCast does not measure by day, so a mote in a daylight
 * frame would be an invention. 104 daytime rows do exist in the store across 176
 * granules, and this is what refuses to draw them.
 */
const MIGRATION_DARK_DEG = -6;
const MIGRATION_LIGHT_DEG = 0;

/** Scene units per metre of altitude. `main.ts`'s aircraft seam; see the header. */
export const MIGRATION_ALTITUDE_UNITS_PER_METRE = 0.01;

/** A cool white with a little warmth in it, so the field is not a screen artefact. */
const MOTE_COLOR = { r: 0.82, g: 0.86, b: 1.0 };

/**
 * How big a mote is, as a fraction of the board span.
 *
 * Sized against the board rather than in metres for the reason the crow cannot
 * be drawn at all: a 1.02 m wingspan is 0.0005 scene units here, so a mote is
 * not a bird at scale, it is a mark standing for a few thousand of them. 0.9% of
 * the span is about five units on the state board — a soft point a couple of
 * pixels across from the opening pose, which is what the first shot said was
 * needed: at 0.4% the whole field was invisible against a lit Central Valley.
 */
const MOTE_SIZE_SPANS = 0.009;

/** Sprite edge, in texels. Alpha only; the colour comes from the vertex. */
const MOTE_TEXTURE_SIZE = 64;

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

export interface MigrationLayerOptions {
  /** Board span in scene units, as `scene.ts` computes it. Sets the mote size. */
  span: number;
  /** Scene units per metre of altitude. Defaults to the aircraft seam's 0.01. */
  altitudeSceneUnitsPerMetre?: number;
  /** Mote size as a fraction of the board span. See `MOTE_SIZE_SPANS`. */
  moteSpans?: number;
  /** A reload must produce the same field. See `seededRandom`. */
  seed?: number;
}

export interface MigrationLayer {
  group: THREE.Object3D;
  setField(field: MigrationField | null): void;
  setLighting(state: LightingState): void;
  setSolarElevation(degrees: number): void;
  tick(dt: number): void;
  dispose(): void;
  /** Motes currently carrying birds. Zero on a quiet sky. */
  activeCount(): number;
  /** One mote, for a test that has to prove nothing was interpolated. */
  mote(index: number): MoteReading | null;
}

/** What a mote is, read from outside. Positions are scene units. */
export interface MoteReading {
  x: number;
  y: number;
  z: number;
  /** Scene units per second. Constant for a mote's whole life, by construction. */
  vx: number;
  vz: number;
  alpha: number;
  /** Index into the field's `counties` this mote was spawned from, or -1. */
  county: number;
}

// ---- Allocation, as a pure function --------------------------------------

/**
 * How many motes each county gets, and never more than the buffer holds.
 *
 * Pure and exported because it is the layer's one claim about the numbers, and a
 * claim about numbers that lives inside a mesh builder is a claim nobody can
 * test without a GL context. Counties arrive worst-first so that a board with
 * more than 58 of them drops the quietest rather than the last.
 */
export function allocateMotes(
  counties: readonly MigrationCounty[],
  cap = MIGRATION_MAX_POINTS,
): number[] {
  const order = counties
    .map((county, index) => ({ index, aloft: finite(county?.aloft) ?? 0 }))
    .sort((a, b) => b.aloft - a.aloft);

  const counts = new Array<number>(counties.length).fill(0);
  let total = 0;
  for (const { index, aloft } of order) {
    if (aloft <= 0) continue;
    const share = Math.min(1, aloft / MIGRATION_FULL_ALOFT);
    const want = Math.max(1, Math.round(MIGRATION_MOTES_PER_COUNTY * Math.sqrt(share)));
    const take = Math.min(want, cap - total);
    if (take <= 0) break;
    counts[index] = take;
    total += take;
  }
  return counts;
}

/** Radius, in kilometres, of a disc with this area. See the header: a disc. */
export function discRadiusKm(areaKm2: number): number {
  if (!Number.isFinite(areaKm2) || areaKm2 <= 0) return 0;
  return Math.sqrt(areaKm2 / Math.PI);
}

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

export function createMigrationLayer(
  world: World,
  options: MigrationLayerOptions,
): MigrationLayer {
  const group = new THREE.Group();
  group.name = "migration";

  const perMetre = options.altitudeSceneUnitsPerMetre ?? MIGRATION_ALTITUDE_UNITS_PER_METRE;
  const random = seededRandom(options.seed ?? 0x5b_1d_c0de);

  const positions = new Float32Array(MIGRATION_MAX_POINTS * 3);
  const colors = new Float32Array(MIGRATION_MAX_POINTS * 4);
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  // itemSize 4, which is what makes three define `USE_COLOR_ALPHA` and give the
  // cloud a per-mote alpha. A `PointsMaterial` has one opacity for the whole
  // buffer, so without this a mote could not fade in or out and the field would
  // have to blink — see `MOTE_LIFE_SECONDS`.
  geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4));
  geometry.setDrawRange(0, 0);

  const material = new THREE.PointsMaterial({
    size: Math.max(0.4, options.span * (options.moteSpans ?? MOTE_SIZE_SPANS)),
    sizeAttenuation: true,
    vertexColors: true,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
    opacity: 0,
  });
  const map = moteTexture();
  if (map !== null) material.map = map;

  const points = new THREE.Points(geometry, material);
  points.name = "migration-motes";
  points.frustumCulled = false;
  points.visible = false;
  group.add(points);

  // Per-mote state. Parallel arrays rather than objects: 700 of them, touched
  // every frame, and the whole point of the layer is that it costs nothing.
  const vx = new Float32Array(MIGRATION_MAX_POINTS);
  const vz = new Float32Array(MIGRATION_MAX_POINTS);
  const age = new Float32Array(MIGRATION_MAX_POINTS);
  const life = new Float32Array(MIGRATION_MAX_POINTS);
  const county = new Int16Array(MIGRATION_MAX_POINTS).fill(-1);

  let field: MigrationField | null = null;
  let counts: number[] = [];
  let slots: number[] = [];
  let active = 0;
  let night = 0;
  const tint = { ...MOTE_COLOR };

  /**
   * Which county a mote slot belongs to, flattened once per granule.
   *
   * A slot keeps its index for the layer's whole life and simply re-reads this
   * table when it respawns, which is what lets a mote outlive the granule it was
   * born from without ever mixing two of them.
   */
  function reslot(): void {
    slots = [];
    for (let i = 0; i < counts.length; i++) {
      for (let n = 0; n < (counts[i] as number); n++) slots.push(i);
    }
    active = Math.min(slots.length, MIGRATION_MAX_POINTS);
    geometry.setDrawRange(0, active);
  }

  /** Spawn one mote from the newest granule. Never from two. */
  function spawn(index: number, phase: number): void {
    const which = slots[index];
    const source = which === undefined ? undefined : field?.counties[which];
    if (source === undefined) {
      county[index] = -1;
      colors[index * 4 + 3] = 0;
      return;
    }

    const radiusKm = discRadiusKm(finite(source.areaKm2) ?? 0);
    // Uniform over the disc, which needs the square root — without it every
    // county would be a dot with a halo instead of a spread.
    const r = radiusKm * Math.sqrt(random());
    const theta = random() * Math.PI * 2;
    const dLat = (r * Math.cos(theta)) / 111.32;
    const dLng = (r * Math.sin(theta)) / (111.32 * Math.cos((source.lat * Math.PI) / 180));
    const lat = source.lat + dLat;
    const lng = source.lng + dLng;

    const [x, z] = world.project(lat, lng);
    // Ground is sampled once, at birth, and carried for the mote's whole life.
    // `height_mean_m` is above ground level, so a bird over Inyo County is above
    // the White Mountains and not inside them.
    const y = world.groundAt(lat, lng) + (finite(source.altitude) ?? 0) * perMetre;

    const speed = Math.max(0, finite(source.speed) ?? 0) / world.metresPerUnit;
    const heading = ((finite(source.direction) ?? 0) * Math.PI) / 180;
    positions[index * 3] = x;
    positions[index * 3 + 1] = y;
    positions[index * 3 + 2] = z;
    // North is -Z: `World.project` negates latitude. A field that drifts
    // north-west when the feed says south-east is the one bug here nobody would
    // see, because a cloud of dots has no other way to be wrong.
    vx[index] = speed * Math.sin(heading);
    vz[index] = -speed * Math.cos(heading);
    age[index] = phase * MOTE_LIFE_SECONDS;
    life[index] = MOTE_LIFE_SECONDS * (1 - MOTE_LIFE_JITTER + random() * MOTE_LIFE_JITTER * 2);
    county[index] = which as number;
    colors[index * 4] = tint.r;
    colors[index * 4 + 1] = tint.g;
    colors[index * 4 + 2] = tint.b;
    colors[index * 4 + 3] = 0;
  }

  function fadeOf(index: number): number {
    const t = life[index] === 0 ? 1 : (age[index] as number) / (life[index] as number);
    if (t <= 0 || t >= 1) return 0;
    if (t < MOTE_FADE) return t / MOTE_FADE;
    if (t > 1 - MOTE_FADE) return (1 - t) / MOTE_FADE;
    return 1;
  }

  function refreshVisibility(): void {
    material.opacity = night;
    points.visible = active > 0 && night > 0.01;
  }

  return {
    group,

    setField(next: MigrationField | null): void {
      const read = readable(next);
      field = read;
      counts = read === null ? [] : allocateMotes(read.counties);
      reslot();
      if (read === null) {
        // `null` is "nothing has answered", so every mote in the air is
        // retired rather than left drifting on a granule that is no longer
        // being claimed. Without this, a feed that dropped out and came back
        // would put the old night's motes over the new night's counties.
        county.fill(-1);
        refreshVisibility();
        return;
      }
      // Only slots with nothing in them are spawned here. A mote already in the
      // air keeps its own velocity and its own remaining life, and will pick the
      // new granule up when it dies — which is the whole reason two granules
      // never meet inside one position.
      for (let i = 0; i < active; i++) {
        if (county[i] === -1 || (age[i] as number) >= (life[i] as number)) spawn(i, random());
      }
      geometry.attributes.position!.needsUpdate = true;
      geometry.attributes.color!.needsUpdate = true;
      refreshVisibility();
    },

    setLighting(state: LightingState): void {
      /**
       * Colour only. Whether the field is drawn at all is `setSolarElevation`'s
       * business, because the layer's subject *is* the night.
       *
       * And explicitly **not** `hemisphere.intensity`: measured on the shipped
       * rig it is 1.33 at 21 degrees below the horizon and 0.95 at noon, because
       * `atmosphere.ts` raises the fill to compensate a moonlit scene. The first
       * draft of this layer read it as a day/night signal, computed zero
       * strength at midnight, and drew nothing at all on the one board it was
       * built for — while passing a test written against invented numbers. The
       * picture is the instrument that found it.
       */
      const packed = state.hemisphere?.sky ?? 0xffffff;
      const mix = 0.25;
      tint.r = MOTE_COLOR.r * (1 - mix) + (((packed >> 16) & 0xff) / 255) * mix;
      tint.g = MOTE_COLOR.g * (1 - mix) + (((packed >> 8) & 0xff) / 255) * mix;
      tint.b = MOTE_COLOR.b * (1 - mix) + ((packed & 0xff) / 255) * mix;
    },

    setSolarElevation(degrees: number): void {
      night = 1 - smoothstep(MIGRATION_DARK_DEG, MIGRATION_LIGHT_DEG, degrees);
      refreshVisibility();
    },

    tick(dt: number): void {
      if (!Number.isFinite(dt) || dt <= 0 || active === 0 || field === null) return;
      for (let i = 0; i < active; i++) {
        if (county[i] === -1) {
          spawn(i, 0);
          continue;
        }
        age[i] = (age[i] as number) + dt;
        if ((age[i] as number) >= (life[i] as number)) {
          spawn(i, 0);
          continue;
        }
        positions[i * 3] = (positions[i * 3] as number) + (vx[i] as number) * dt;
        positions[i * 3 + 2] = (positions[i * 3 + 2] as number) + (vz[i] as number) * dt;
        colors[i * 4 + 3] = fadeOf(i);
      }
      geometry.attributes.position!.needsUpdate = true;
      geometry.attributes.color!.needsUpdate = true;
    },

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

    activeCount: () => active,

    mote(index: number): MoteReading | null {
      if (!Number.isInteger(index) || index < 0 || index >= active) return null;
      return {
        x: positions[index * 3] as number,
        y: positions[index * 3 + 1] as number,
        z: positions[index * 3 + 2] as number,
        vx: vx[index] as number,
        vz: vz[index] as number,
        alpha: colors[index * 4 + 3] as number,
        county: county[index] as number,
      };
    },
  };
}

// ---- Bits -----------------------------------------------------------------

/**
 * The mote's glow, drawn on a canvas rather than shipped as a file.
 *
 * `null` with no DOM, which is what lets a headless test build the whole layer.
 * Without a sprite a `Points` cloud is a field of hard squares, so the fallback
 * is deliberately a missing map and not a substitute one.
 */
function moteTexture(): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const canvas = document.createElement("canvas");
  canvas.width = MOTE_TEXTURE_SIZE;
  canvas.height = MOTE_TEXTURE_SIZE;
  const context = canvas.getContext("2d");
  if (!context) return null;

  const half = MOTE_TEXTURE_SIZE / 2;
  const gradient = context.createRadialGradient(half, half, 0, half, half, half);
  gradient.addColorStop(0, "rgba(255,255,255,1)");
  gradient.addColorStop(0.3, "rgba(255,255,255,0.5)");
  gradient.addColorStop(0.7, "rgba(255,255,255,0.09)");
  gradient.addColorStop(1, "rgba(255,255,255,0)");
  context.fillStyle = gradient;
  context.fillRect(0, 0, MOTE_TEXTURE_SIZE, MOTE_TEXTURE_SIZE);

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

/**
 * A field worth drawing, or `null`.
 *
 * Total: the consumer is a render loop. A county with no coordinate is dropped
 * rather than placed at the origin — which is what the `US-CA` state row would
 * do, and it carries 793,141 birds aloft tonight against the largest county's
 * 82,549. `src/server/birds.ts` takes it out first; this is the second line.
 */
function readable(field: MigrationField | null | undefined): MigrationField | null {
  if (field === null || field === undefined || typeof field !== "object") return null;
  if (!Array.isArray(field.counties)) return null;
  const counties = field.counties.filter(
    (county) =>
      county !== null &&
      typeof county === "object" &&
      finite(county.lat) !== null &&
      finite(county.lng) !== null &&
      (finite(county.aloft) ?? 0) > 0,
  );
  if (counties.length === 0) return null;
  return { ...field, counties };
}

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);
}

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

function finite(value: unknown): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}
