/**
 * The two sprites the fire feed draws with, both painted on a canvas at load.
 *
 * CONTRACT.md §3 is the reason there is no `.png` here and never will be: this
 * repo commits no binary art, so nothing in it has a provenance anyone has to
 * take on trust, and `scripts/check-no-binaries.mjs` enforces it over `src/**`.
 * Everything below is arithmetic into an `ImageData`.
 *
 * The interesting thing about these two is that **they are drawn to look
 * unalike on purpose**, and that is a truth-telling decision rather than a
 * stylistic one.
 *
 * A satellite hot pixel is not a fire. It is a report that one cell of one
 * overpass was warm — and the upstream store contains a permanent industrial
 * heat source 4.7 km from the operator's house that reports at FRP ~1.0 on
 * every single pass, on every day the store holds, with no matching incident
 * behind it. If the evidence layer and the incident layer shared a look, that
 * flare stack would be a wildfire on somebody's house, drawn by us, forever.
 * So the hot pixel is deliberately *instrumental*: a small hard dot with a
 * narrow ring, cold at the centre, closer to a plotted sample than to a flame.
 * The smoke puff is the opposite — no edge anywhere, all silhouette.
 *
 * Both builders return `null` where there is no DOM. `npm test` is `node --test`
 * with type stripping and the server workspace typechecks under Node, so an
 * asset module that threw on import there would take the zero-config boot
 * CONTRACT.md §5.1 tests with it. Every consumer has an analytic fallback; see
 * `src/engine/fires.ts`.
 */

import * as THREE from "three";
import { fbm } from "../engine/world.ts";

// ---- The palette ----------------------------------------------------------

/**
 * The fire mark's own two colours, base and tip, as linear-ish sRGB triples.
 *
 * Held here rather than in the engine because they are the one part of the fire
 * layer a self-hoster reskinning a world would want to find, and `src/assets`
 * is where this repo puts "what it looks like" (see `palette.ts`). The engine
 * owns *when* they are used; this owns what they are.
 *
 * Deep red at the base and a pale yellow at the tip, not orange-to-orange. A
 * single-hue glyph reads as a plastic cone at board altitude; the hue *travel*
 * is most of what makes a small shape read as combustion.
 */
export const FIRE_MARK_BASE = 0xb1240c;
export const FIRE_MARK_TIP = 0xffd98a;

/**
 * The ground extent, by day and by night.
 *
 * The day colour is a smoke-brown that *darkens* the terrain under it, and that
 * is the honest direction: from orbit in daylight a burn is a dark smudge, not
 * a bright one. The night colour is deliberately over-range — the renderer runs
 * ACES tone mapping, so a value above 1 lands as a hot core that rolls off into
 * the surrounding orange instead of clipping to a flat disc of paint.
 */
export const FIRE_EXTENT_DAY = 0x3a2a20;
export const FIRE_EXTENT_NIGHT = 0xff6a1e;

/**
 * The hot-pixel colour: a cold steel blue, and the whole point of it.
 *
 * Evidence is drawn in the colour of an instrument, never in the colour of the
 * thing it is evidence for. On the SoCal board — pale sand, white-blue
 * buildings, green ridges, blue ocean — a warm dot is the most salient object
 * in the frame, which is exactly the salience a satellite pass has not earned.
 */
export const HOT_PIXEL_COLOR = 0x8fc4e8;

/** The demoted colour for a cell that is known furniture. Barely a hue at all. */
export const HOT_PIXEL_PERSISTENT_COLOR = 0x7f8892;

/** Smoke, lit and shaded. The plume borrows the rig's key rather than a light. */
export const SMOKE_LIT = 0xd8cec3;
export const SMOKE_SHADE = 0x6a615a;

// ---- Sprites --------------------------------------------------------------

const HOT_PIXEL_SIZE = 32;
const SMOKE_PUFF_SIZE = 128;

/**
 * The hot-pixel sprite: a hard dot inside a thin ring, on transparent.
 *
 * White throughout — the layer tints per point, because a `persistent` cell and
 * a fresh one are the same drawing in two colours and a second texture would be
 * a second upload for a hue.
 *
 * The ring is what stops a field of these reading as a soft haze. Detections
 * cluster — an active fire is ~100 pixels across nine cells — and a hundred
 * overlapping Gaussians is a smear, whereas a hundred overlapping *rings* still
 * has countable structure in it. That structure is the signal: the reader is
 * meant to see a cluster and think "that is a lot of passes over one place",
 * which is a thought a smear cannot produce.
 */
export function hotPixelTexture(): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const size = HOT_PIXEL_SIZE;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const image = ctx.createImageData(size, size);
  const data = image.data;
  for (let y = 0; y < size; y++) {
    const v = ((y + 0.5) / size) * 2 - 1;
    for (let x = 0; x < size; x++) {
      const u = ((x + 0.5) / size) * 2 - 1;
      const r = Math.hypot(u, v);
      // A solid core out to 0.30, a gap, then a ring centred at 0.66. Both ends
      // feather over about a tenth of the radius, which is a pixel and a half at
      // this resolution — enough to stop the aliasing, not enough to blur.
      const core = 1 - smoothstep(0.24, 0.36, r);
      const ring = (1 - smoothstep(0.72, 0.86, r)) * smoothstep(0.5, 0.62, r) * 0.5;
      const a = Math.min(1, core + ring);
      const i = (y * size + x) * 4;
      data[i] = 255;
      data[i + 1] = 255;
      data[i + 2] = 255;
      data[i + 3] = Math.round(a * 255);
    }
  }
  ctx.putImageData(image, 0, 0);

  const texture = new THREE.CanvasTexture(canvas);
  texture.name = "fireHotPixel";
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  texture.minFilter = THREE.LinearMipmapLinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.generateMipmaps = true;
  texture.needsUpdate = true;
  return texture;
}

/**
 * One puff of smoke: a lumpy body with no edge on it anywhere.
 *
 * The same shape `clouds.ts` arrived at and for the same measured reason — a
 * plain Gaussian halves its own alpha a third of the way out, so a column of
 * them never contributes full opacity anywhere and reads as fog rather than as
 * smoke. Alpha is held flat out to `r = 0.30` and the remaining radius is spent
 * on the rim, which is where all of a plume's character lives.
 *
 * Smoke is noisier than cloud: the noise is turned up and centred lower, so it
 * bites into the body as well as the rim and a plume has holes in it.
 */
export function smokePuffTexture(): THREE.Texture | null {
  if (typeof document === "undefined") return null;
  const size = SMOKE_PUFF_SIZE;
  const canvas = document.createElement("canvas");
  canvas.width = size;
  canvas.height = size;
  const ctx = canvas.getContext("2d");
  if (!ctx) return null;

  const image = ctx.createImageData(size, size);
  const data = image.data;
  for (let y = 0; y < size; y++) {
    const v = ((y + 0.5) / size) * 2 - 1;
    for (let x = 0; x < size; x++) {
      const u = ((x + 0.5) / size) * 2 - 1;
      const r = Math.hypot(u, v);
      let a = 0;
      if (r < 1) {
        const core = 1 - smoothstep(0.3, 0.98, r);
        const n = fbm(u * 3.4 + 41.3, v * 3.4 + 17.9) / 0.94;
        const lump = 0.28 + 1.0 * clamp01(n);
        // The rim guarantee: without it the noise can hold alpha out to r = 1,
        // where the quad's own edge cuts the puff off along a straight line.
        a = clamp01(core * lump) * (1 - smoothstep(0.84, 1, r));
        a = a ** 0.85;
      }
      const i = (y * size + x) * 4;
      data[i] = 255;
      data[i + 1] = 255;
      data[i + 2] = 255;
      data[i + 3] = Math.round(a * 255);
    }
  }
  ctx.putImageData(image, 0, 0);

  const texture = new THREE.CanvasTexture(canvas);
  texture.name = "fireSmokePuff";
  texture.wrapS = THREE.ClampToEdgeWrapping;
  texture.wrapT = THREE.ClampToEdgeWrapping;
  texture.minFilter = THREE.LinearMipmapLinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.generateMipmaps = true;
  texture.needsUpdate = true;
  return texture;
}

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

function smoothstep(edge0: number, edge1: number, x: number): number {
  if (edge1 === edge0) return x < edge0 ? 0 : 1;
  const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
  return t * t * (3 - 2 * t);
}

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