/**
 * SHADES / HARD YARDS — WIND AUTHORING for the yard editor. Lane C owns this
 * file (SPRINT14 gate 2.2). It builds INTO Lane A's editor page through the
 * seams A published in `editor.html` / `editor.js`; it does not own a pixel of
 * either, and it must never grow a private harness on that page.
 *
 * WHAT IT ADDS
 *   · venturi authoring — drag the throat, drag either end of the axis, gain /
 *     radius / sharp on sliders, with the gizmo redrawn live
 *   · tree-shelter volumes — what a tree actually protects, at the storm second
 *     you are looking at
 *   · a wind-field overlay — `speedAt` sampled across the yard at a chosen
 *     storm + time, so you SEE where the funnel screams before D has to feel it
 *
 * ── THE FIVE THINGS THIS FILE IS CAREFUL ABOUT ─────────────────────────────
 *
 * 1. **`windForSite()` IS THE ONLY WIND THIS FILE BUILDS.** One sprint old and
 *    already load-bearing: three harnesses (B's site_audit, my own
 *    garden_bench, probe4) independently measured a yard with the funnel
 *    switched OFF, because `def.wind.venturi` off a STORM def looks right and
 *    never fires. The venturi lives in the SITE. There is exactly one call to
 *    one builder in here (`buildWind`), it takes the site and the live anchors,
 *    and no other function in this file is allowed to make a wind. If you are
 *    reading this because you want a wind for something new — call `buildWind`.
 *
 * 2. **The gizmos are drawn from what the WIND flies, not from what the JSON
 *    says.** `wind.core.venturi` and `wind.core.shelters` are read back after
 *    the build, so the arrow you see is the funnel the sim has, resolved
 *    defaults and all. Drawing from `site.wind.venturi` instead would render a
 *    funnel that is merely authored — and a gizmo that agrees with the JSON
 *    while disagreeing with the sim is worse than no gizmo, because it is
 *    evidence. This is also why a mis-wired `setVenturi` shows up here as a
 *    funnel that vanishes, rather than as a number nobody looks at.
 *
 * 3. **An axis is a LINE, not a heading, and the UI teaches that.** The venturi
 *    aligns on `Math.abs(dot(wind, axis))` — a gap funnels either way through
 *    it — so the axis is only defined mod π. A and I proved this from opposite
 *    sides in Sprint 11 and it cost two exchanges, so: the axis draws as one
 *    line through the throat with an identical handle at BOTH ends, grabbing
 *    either end rotates the same line, and the value normalises into [0, π).
 *    The readout says `120° ≡ 300°` for as long as anyone needs to believe it.
 *    There is no arrowhead anywhere on the axis, on purpose.
 *
 * 4. **Neither the venturi nor the shelter maths has a vertical term.** Both
 *    are functions of (x, z) only — a tree's shadow and a gap's funnel apply at
 *    every height. The shelter VOLUME is therefore a reading aid drawn to a
 *    nominal canopy height, and the panel says so. Drawing a finite volume
 *    without saying that would be the gizmo quietly inventing physics.
 *
 * 5. **Staleness is A's, by construction.** Every object I make is parented to
 *    `EDITOR.overlay`, which the editor CLEARS on every rebuild — A's "a stale
 *    arrow over a moved venturi is the wind cousin of the phantom sail, and one
 *    lane clearing beats two lanes remembering". `overlay.clear()` detaches but
 *    does not free GPU buffers, so I keep my own disposal list and empty it at
 *    the top of every redraw; that is my half of the deal, not a second
 *    lifetime model.
 *
 * DETERMINISM: everything sampled here is a pure function of (x, z, t) and the
 * storm seed — `speedAt`/`dirAt` have no internal clock and no RNG at call
 * time, so the same storm at the same second draws the same field on any
 * machine. Nothing in this file feeds a score; B's SCORE IT and the game read
 * the same `windForSite`, and gate 2.3 pins them equal at a probe point.
 *
 * LOADING: this module self-registers off `globalThis.EDITOR` when imported.
 * `editor.html` needs one line after `createEditor()` resolves — asked for in
 * THREADS rather than added here, because that page is A's.
 */

import * as THREE from '../vendor/three.module.js';
import { smoothstep } from './weather.core.js';
import { loadStorm, windForSite } from './weather.js';
import { stormSel, STORM_KEYS } from './editor_storm.js';

/** Re-exported from the shared selection store — ONE list, one copy. Sprint 14
 *  had this list here and a second copy in editor_score.js, which is how the
 *  page grew two pickers with two defaults. See editor_storm.js. */
export { STORM_KEYS };

/** Nominal canopy height for the shelter volume. A READING AID — see (4). */
const CANOPY_Y = 3.2;

// ---------------------------------------------------------------------------
// Pure helpers — exported so c.test.js can assert them without a DOM
// ---------------------------------------------------------------------------

/**
 * Fold an angle into [0, π). THE mod-π rule, in one place.
 *
 * A venturi axis is a line through a gap. `axis` and `axis + π` are the same
 * gap read from opposite ends — weather.core aligns on `|dot|` precisely so
 * that a gap funnels either way through it. Normalising here is what makes
 * "drag either end of the line" produce one stable number instead of two that
 * flip-flop by π depending on which handle the author happened to grab.
 */
export function normalizeAxis(rad) {
  const a = rad % Math.PI;
  return a < 0 ? a + Math.PI : a;
}

/** Degrees, folded the same way — for the readout. */
export function axisDeg(rad) {
  return normalizeAxis(rad) * 180 / Math.PI;
}

/**
 * The fraction of wind speed a shelter REMOVES at a point, in the shelter's own
 * (along, perp) frame. Mirrors `shelterFactor` in weather.core.js, which
 * returns `1 - this`, and shares its `smoothstep` rather than easing by eye.
 *
 * This is a mirror and mirrors drift, so c.test.js measures it against the real
 * field through `speedAt` and fails if the two ever disagree. Documentation
 * cannot fail; an assert can.
 *
 * @param {{radius:number,length:number,strength:number}} s
 * @param {number} along  metres DOWNWIND of the tree (>0 is in the shadow)
 * @param {number} perp   metres to either side of the shadow's centreline
 */
export function shelterAtten(s, along, perp) {
  if (along <= 0 || along >= s.length) return 0;
  const ap = Math.abs(perp);
  if (ap >= s.radius) return 0;
  const fAlong = smoothstep(0, s.radius * 0.5, along) * (1 - smoothstep(0, s.length, along));
  const fPerp = 1 - smoothstep(0, s.radius, ap);
  return s.strength * fAlong * fPerp;
}

/**
 * Sample the wind across a yard on a grid. Pure: same wind + same args = same
 * array, so the overlay is reproducible and the numbers are assertable.
 *
 * Returns absolute speed AND the amplification ratio against the storm's
 * uniform speed at that second, because those answer different authoring
 * questions: "is this corner survivable" is absolute, "is my funnel doing
 * anything" is a ratio. The overlay colours by ratio and lengths by speed.
 *
 * @returns {{samples:Array<{x:number,z:number,speed:number,ratio:number}>,
 *            dir:number, uniform:number, max:{speed:number,x:number,z:number},
 *            min:{speed:number,x:number,z:number}}}
 */
export function sampleWindField(wind, { width, depth, step = 1.5, t = 0 }) {
  const samples = [];
  const uniform = wind.core.uniformSpeed(t);
  const dir = wind.core.dirAt(t);
  let max = { speed: -Infinity, x: 0, z: 0 };
  let min = { speed: Infinity, x: 0, z: 0 };
  const nx = Math.max(2, Math.round(width / step));
  const nz = Math.max(2, Math.round(depth / step));
  for (let i = 0; i < nx; i++) {
    for (let j = 0; j < nz; j++) {
      const x = -width / 2 + (i + 0.5) * (width / nx);
      const z = -depth / 2 + (j + 0.5) * (depth / nz);
      const speed = wind.core.speedAt(x, z, t);
      const ratio = uniform > 1e-6 ? speed / uniform : 1;
      samples.push({ x, z, speed, ratio });
      if (speed > max.speed) max = { speed, x, z };
      if (speed < min.speed) min = { speed, x, z };
    }
  }
  return { samples, dir, uniform, max, min };
}

/**
 * The storm's WORST second on this yard: argmax of `speedAt` over a coarse
 * grid × the whole storm. [SPRINT15, lane C's own-call polish]
 *
 * D authored site_03 cold and the scrubber's question was always "WHEN does
 * this yard scream?" — answered until now by dragging and watching, which is
 * measurement by patience. This is the same question asked properly: scan the
 * lattice, return the second. Pure — same wind + same args = same answer on
 * any machine (speedAt has no clock and no call-time RNG), and dt defaults to
 * the scrubber's own 0.5 s step so the answer is always a second the scrubber
 * can actually sit on.
 *
 * `probes` exist because a coarse grid can straddle a throat: the panel passes
 * every authored venturi centre so the one point most likely to BE the worst
 * is always scanned exactly.
 *
 * @returns {{t:number, speed:number, x:number, z:number}}
 */
export function worstSecond(wind, { width, depth, step = 3, dt = 0.5, probes = [] } = {}) {
  const pts = [];
  const nx = Math.max(2, Math.round(width / step));
  const nz = Math.max(2, Math.round(depth / step));
  for (let i = 0; i < nx; i++) {
    for (let j = 0; j < nz; j++) {
      pts.push({ x: -width / 2 + (i + 0.5) * (width / nx), z: -depth / 2 + (j + 0.5) * (depth / nz) });
    }
  }
  for (const p of probes) pts.push({ x: p.x, z: p.z });
  const n = Math.round(wind.duration / dt);
  let best = { t: 0, speed: -Infinity, x: 0, z: 0 };
  for (let k = 0; k <= n; k++) {
    const t = k * dt;
    for (const p of pts) {
      const s = wind.core.speedAt(p.x, p.z, t);
      if (s > best.speed) best = { t, speed: s, x: p.x, z: p.z };
    }
  }
  return best;
}

/** Amplification → colour. Cool/dim = sheltered, white = untouched, hot = funnelled. */
function ratioColor(ratio, out) {
  // 0.55 …… 1.0 …… 1.6  mapped blue → pale → red. The neutral band is
  // deliberately pale rather than green: an author should read "nothing
  // happening here" as absence, and see only the two things a site DOES.
  const k = Math.max(0, Math.min(1, (ratio - 0.55) / (1.6 - 0.55)));
  const mid = 0.43;                          // where ratio == 1 lands on k
  if (k < mid) {
    const u = k / mid;
    return out.setRGB(0.20 + 0.65 * u, 0.45 + 0.45 * u, 0.85 + 0.10 * u);
  }
  const u = (k - mid) / (1 - mid);
  return out.setRGB(0.85 + 0.15 * u, 0.90 - 0.72 * u, 0.95 - 0.80 * u);
}

// ---------------------------------------------------------------------------
// The panel
// ---------------------------------------------------------------------------

export async function mountWindPanel(EDITOR) {
  const { root, body } = EDITOR.mountPanel({ id: 'wind', title: 'WIND', order: 50 });

  // --- state -------------------------------------------------------------
  // Storm + second live in the SHARED selection store, not here — gate 2.2.
  // This panel is the picker UI (the owner's controls), but the value it edits
  // is THE page selection, the same one SCORE IT scores. Local `stormKey`/
  // `time` are gone on purpose: state this panel privately held is exactly how
  // D scored one storm while looking at another.
  const SEL = stormSel();
  const storms = {};
  /** Last worst-second scan, or null. Cleared on any site change — the claim
   *  "this is the storm's worst second on this yard" dies the moment the yard
   *  moves (B's stale-score instinct, borrowed). Keyed by storm as well. */
  let worstNote = null;
  let showField = true;
  let showShelters = true;
  let gridStep = 1.5;
  let wind = null;
  let selVenturi = 0;

  /** Everything I ever allocate on the GPU, so redraw can free it. See (5). */
  const mine = [];

  // --- the ONE wind builder ----------------------------------------------
  /**
   * The site's wind for the picked storm. THE only wind this file makes — see
   * (1). Anchors come from the LIVE world so tree shelters are the ones the
   * game would fly, not ones re-derived from the JSON by hand.
   */
  function buildWind() {
    const def = storms[SEL.storm];
    if (!def) return null;
    return windForSite(def, EDITOR.site, EDITOR.world?.anchors ?? []);
  }

  function refreshWind() {
    wind = buildWind();
    // Clamp an over-long second back into the storm THROUGH the store, so the
    // clamped value is the selection everyone shares, not a private display
    // fix. The store's no-op guard is what keeps this from ringing: the emit
    // this triggers re-enters here, finds time ≤ duration, and writes nothing.
    if (wind && SEL.time > wind.duration) SEL.setTime(wind.duration, 'wind-panel:clamp');
  }

  // --- gizmo drawing ------------------------------------------------------

  function freeMine() {
    for (const o of mine) {
      o.geometry?.dispose?.();
      if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose());
      else o.material?.dispose?.();
    }
    mine.length = 0;
  }

  function add(obj) {
    mine.push(obj);
    EDITOR.overlay.add(obj);
    return obj;
  }

  const groundY = (x, z) => EDITOR.HEIGHT_AT(x, z);

  function lineSegs(points, color, opacity = 1, width = 1) {
    const g = new THREE.BufferGeometry().setFromPoints(points);
    const m = new THREE.LineBasicMaterial({
      color, transparent: opacity < 1, opacity, depthTest: true, linewidth: width,
    });
    return add(new THREE.LineSegments(g, m));
  }

  /** A flat ring on the ground — throat reach, throat core. */
  function ringAt(x, z, r, color, opacity) {
    const pts = [];
    const N = 64;
    for (let i = 0; i < N; i++) {
      const a0 = (i / N) * Math.PI * 2;
      const a1 = ((i + 1) / N) * Math.PI * 2;
      const p0x = x + Math.cos(a0) * r, p0z = z + Math.sin(a0) * r;
      const p1x = x + Math.cos(a1) * r, p1z = z + Math.sin(a1) * r;
      pts.push(new THREE.Vector3(p0x, groundY(p0x, p0z) + 0.07, p0z));
      pts.push(new THREE.Vector3(p1x, groundY(p1x, p1z) + 0.07, p1z));
    }
    return lineSegs(pts, color, opacity);
  }

  /**
   * The venturi gizmo. The axis is ONE LINE through the throat, drawn to the
   * same length in both directions with an identical square handle at each end
   * — see (3). No arrowhead: an arrow would say "the wind goes this way", and
   * the wind goes whichever way the STORM sends it; this line is the gap's
   * geometry, which is the site's business and does not move when the wind does.
   */
  function drawVenturi(v, i, isSel) {
    const { x, z } = v;
    const axis = Math.atan2(v.axisZ, v.axisX);
    const L = v.radius * 1.45;
    const ax = Math.cos(axis), az = Math.sin(axis);
    const hot = isSel ? 0xffd166 : 0xff9f43;

    // reach + core rings (radial falloff is full inside 0.4r, gone at r)
    ringAt(x, z, v.radius, hot, isSel ? 0.85 : 0.45);
    ringAt(x, z, v.radius * 0.4, hot, isSel ? 0.55 : 0.28);

    // the axis line, both ways from the throat
    const pts = [];
    const SEG = 24;
    for (let s = 0; s < SEG; s++) {
      const u0 = -1 + (2 * s) / SEG, u1 = -1 + (2 * (s + 1)) / SEG;
      const x0 = x + ax * L * u0, z0 = z + az * L * u0;
      const x1 = x + ax * L * u1, z1 = z + az * L * u1;
      pts.push(new THREE.Vector3(x0, groundY(x0, z0) + 0.1, z0));
      pts.push(new THREE.Vector3(x1, groundY(x1, z1) + 0.1, z1));
    }
    lineSegs(pts, hot, 1);

    // a mast at the throat so the funnel is findable in a 3D orbit
    lineSegs([
      new THREE.Vector3(x, groundY(x, z) + 0.05, z),
      new THREE.Vector3(x, groundY(x, z) + 2.2, z),
    ], hot, 0.7);

    // identical handles at BOTH ends — the mod-π teaching, in geometry
    for (const sgn of [-1, 1]) {
      const hx = x + ax * L * sgn, hz = z + az * L * sgn;
      const g = new THREE.SphereGeometry(0.34, 12, 8);
      const m = new THREE.MeshBasicMaterial({ color: hot, transparent: true, opacity: 0.95 });
      const s = new THREE.Mesh(g, m);
      s.position.set(hx, groundY(hx, hz) + 0.1, hz);
      add(s);
    }
    // the throat handle, visually distinct (you drag it to move the gap)
    {
      const g = new THREE.SphereGeometry(0.42, 14, 10);
      const m = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: isSel ? 0.95 : 0.5 });
      const s = new THREE.Mesh(g, m);
      s.position.set(x, groundY(x, z) + 0.1, z);
      add(s);
    }
  }

  /**
   * A tree's shelter, as a translucent volume. Built in the shelter's own
   * (along, perp) frame and rotated downwind of the CURRENT storm second — the
   * shadow points where the wind is going, so scrubbing time swings it, which
   * is the honest picture: a tree shelters a different patch of yard at 20 s
   * than at 60 s.
   *
   * Alpha carries the strength (there is no hard edge in the maths, so there is
   * no hard edge here). Three stacked layers at equal alpha, because the maths
   * has NO vertical term — see (4). The height is a reading aid; the panel says
   * so in words as well.
   */
  function drawShelter(s, dir) {
    const dx = Math.cos(dir), dz = Math.sin(dir);
    const NA = 22, NP = 12;
    const layers = [0.12, 1.25, CANOPY_Y];
    for (const y of layers) {
      const pos = [];
      const col = [];
      const idx = [];
      for (let ia = 0; ia <= NA; ia++) {
        const along = (ia / NA) * s.length;
        for (let ip = 0; ip <= NP; ip++) {
          const perp = -s.radius + (ip / NP) * (2 * s.radius);
          const wx = s.x + dx * along - dz * perp;
          const wz = s.z + dz * along + dx * perp;
          pos.push(wx, groundY(wx, wz) + y, wz);
          const a = shelterAtten(s, along, perp);
          // colour is constant; the ALPHA is the physics. Vertex alpha needs a
          // 4-component colour attribute and a material that reads it.
          //
          // DARK cool blue, so the volume DARKENS the grass. Two earlier cuts
          // were light blue (0.42,0.72,1.0 at 0.75, then 0.24,0.48,0.95 at 0.38)
          // and both read as a floodlight on the lawn no matter how far the
          // alpha came down — light over grass is light. A wind SHADOW that
          // glows is a gizmo arguing against its own name, so the fix was the
          // colour, not the opacity. Verified by looking, twice.
          col.push(0.05, 0.11, 0.28, a * 0.5);
        }
      }
      for (let ia = 0; ia < NA; ia++) {
        for (let ip = 0; ip < NP; ip++) {
          const a = ia * (NP + 1) + ip;
          const b = a + (NP + 1);
          idx.push(a, b, a + 1, b, b + 1, a + 1);
        }
      }
      const g = new THREE.BufferGeometry();
      g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
      g.setAttribute('color', new THREE.Float32BufferAttribute(col, 4));
      g.setIndex(idx);
      const m = new THREE.MeshBasicMaterial({
        vertexColors: true, transparent: true, side: THREE.DoubleSide,
        depthWrite: false, blending: THREE.NormalBlending,
      });
      const mesh = new THREE.Mesh(g, m);
      // The volume is CONTEXT for the arrows, so it draws under them. Two of the
      // three layers sit above arrow height (0.55 m), so without this the shadow
      // paints over the very samples that prove it.
      mesh.renderOrder = 1;
      add(mesh);
    }
    // the trunk tick, so you can tell which tree owns which shadow
    lineSegs([
      new THREE.Vector3(s.x, groundY(s.x, s.z) + 0.05, s.z),
      new THREE.Vector3(s.x, groundY(s.x, s.z) + CANOPY_Y, s.z),
    ], 0x6bb8ff, 0.6);
  }

  /**
   * The wind field: one arrow per grid cell. All arrows are PARALLEL and that
   * is not a simplification — `vecAt` takes the direction from `dirAt(t)`
   * alone, so the yard's local effects change the SPEED of the wind and never
   * its heading. Length reads absolute speed, colour reads amplification.
   * One LineSegments for the lot: 250-ish arrows as 250 Object3Ds would cost
   * more than the whole rest of this panel.
   */
  function drawField(field) {
    const dx = Math.cos(field.dir), dz = Math.sin(field.dir);
    const pos = [];
    const col = [];
    const c = new THREE.Color();
    const ref = Math.max(1e-3, field.max.speed);
    const LEN = gridStep * 0.92;
    for (const sm of field.samples) {
      const len = LEN * Math.max(0.12, sm.speed / ref);
      const hx = sm.x + dx * len * 0.5, hz = sm.z + dz * len * 0.5;   // head
      const tx = sm.x - dx * len * 0.5, tz = sm.z - dz * len * 0.5;   // tail
      ratioColor(sm.ratio, c);
      const push = (x0, z0, x1, z1) => {
        pos.push(x0, groundY(x0, z0) + 0.55, z0, x1, groundY(x1, z1) + 0.55, z1);
        col.push(c.r, c.g, c.b, c.r, c.g, c.b);
      };
      push(tx, tz, hx, hz);
      // barbs — a heading needs a head; this one is the WIND, which really does
      // have a direction (unlike the axis above).
      const bl = len * 0.34;
      const ca = Math.cos(2.5), sa = Math.sin(2.5);
      push(hx, hz, hx + (dx * ca - dz * sa) * bl, hz + (dz * ca + dx * sa) * bl);
      push(hx, hz, hx + (dx * ca + dz * sa) * bl, hz + (dz * ca - dx * sa) * bl);
    }
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
    g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
    const m = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.95 });
    const seg = new THREE.LineSegments(g, m);
    seg.renderOrder = 2;                    // over the shelter volumes — see drawShelter
    add(seg);
  }

  /** Rebuild every gizmo from the current site + storm + second. */
  function redraw() {
    freeMine();
    if (!wind) return null;
    const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
    const field = sampleWindField(wind, {
      width: yard.width, depth: yard.depth, step: gridStep, t: SEL.time,
    });
    if (showField) drawField(field);
    if (showShelters) for (const s of wind.core.shelters) drawShelter(s, field.dir);
    wind.core.venturi.forEach((v, i) => drawVenturi(v, i, i === selVenturi));
    return field;
  }

  // --- the venturi tool ---------------------------------------------------
  /**
   * Registered through `EDITOR.registerTool` rather than listening on the
   * canvas, so my drag and A's select/drag never compete for the same click —
   * A's seam, used as offered.
   */
  const HIT = 0.95;                       // metres; handles are ~0.4 m spheres

  let drag = null;

  function venturiList() {
    EDITOR.site.wind ??= {};
    EDITOR.site.wind.venturi ??= [];
    return EDITOR.site.wind.venturi;
  }

  /** Which handle (if any) is under the cursor. Pure XZ distance — the gizmo is
   *  ground-plane geometry, so a ground-plane test is the honest pick. */
  function hitHandle(gx, gz) {
    const list = venturiList();
    for (let i = 0; i < list.length; i++) {
      const v = list[i];
      const axis = normalizeAxis(v.axis ?? 0);
      const r = v.radius ?? 4;
      const L = r * 1.45;
      if (Math.hypot(gx - v.x, gz - v.z) < HIT) return { i, mode: 'throat' };
      for (const sgn of [-1, 1]) {
        const hx = v.x + Math.cos(axis) * L * sgn;
        const hz = v.z + Math.sin(axis) * L * sgn;
        if (Math.hypot(gx - hx, gz - hz) < HIT) return { i, mode: 'axis' };
      }
    }
    return null;
  }

  EDITOR.registerTool({
    id: 'venturi',
    label: 'VENTURI',
    cursor: 'crosshair',
    onPointerDown(ev, ground) {
      if (!ground || ev.button !== 0 || ev.shiftKey) return false;
      const hit = hitHandle(ground.x, ground.z);
      if (hit) {
        drag = hit;
        selVenturi = hit.i;
        render();
        return true;
      }
      // empty ground under the venturi tool = author a new gap here
      const list = venturiList();
      list.push({ x: round2(ground.x), z: round2(ground.z), axis: 0, gain: 1.4, radius: 4, sharp: 3 });
      selVenturi = list.length - 1;
      drag = { i: selVenturi, mode: 'throat' };
      commit();
      return true;
    },
    onPointerMove(ev, ground) {
      if (!drag || !ground) return false;
      const v = venturiList()[drag.i];
      if (!v) { drag = null; return false; }
      if (drag.mode === 'throat') {
        v.x = round2(ground.x);
        v.z = round2(ground.z);
      } else {
        // Grab either end: both ends are the same line, so we fold into [0, π)
        // and the gizmo mirrors. THIS is the mod-π lesson as a gesture.
        v.axis = round3(normalizeAxis(Math.atan2(ground.z - v.z, ground.x - v.x)));
      }
      commit();
      return true;
    },
    onPointerUp() {
      if (!drag) return false;
      drag = null;
      commit();
      return true;
    },
  });

  const round2 = (n) => Math.round(n * 100) / 100;
  const round3 = (n) => Math.round(n * 1000) / 1000;

  /**
   * Site changed: tell the editor (it revalidates — `validateSiteWind` is
   * already inside A's `validateSite`, so a bad gain lands in A's panel and I
   * do NOT write a second wind validator), then rebuild my wind and gizmos.
   *
   * `markDirty()` deliberately, not `rebuild()`: a venturi edit changes no
   * geometry and no anchor, so re-dressing the world would be a 40-90 ms hitch
   * per slider tick for nothing.
   */
  function commit() {
    // Just say the site changed. The 'change' listener rebuilds the wind and
    // redraws — see the note there for why the refresh lives at the LISTENER
    // and not here.
    EDITOR.markDirty();
  }

  // --- DOM ----------------------------------------------------------------

  const el = (tag, cls, txt) => {
    const n = document.createElement(tag);
    if (cls) n.className = cls;
    if (txt != null) n.textContent = txt;
    return n;
  };

  function row(labelText, ...controls) {
    const r = el('div', 'ed-row');
    r.append(el('span', 'ed-label', labelText));
    r.append(...controls);
    return r;
  }

  /** A labelled slider that reads its own value. `.ed-num` per A's class kit —
   *  a range is a numeric control and the contract says don't invent. */
  function slider(min, max, stepv, value, fmt, onInput) {
    const wrap = el('div', 'ed-row');
    const i = document.createElement('input');
    i.type = 'range';
    i.className = 'ed-num';
    i.min = min; i.max = max; i.step = stepv; i.value = value;
    i.style.flex = '1 1 auto';
    i.style.width = 'auto';
    const out = el('span', null, fmt(value));
    out.style.flex = '0 0 78px';
    out.style.textAlign = 'right';
    i.addEventListener('input', () => {
      const v = parseFloat(i.value);
      out.textContent = fmt(v);
      onInput(v);
    });
    wrap.append(i, out);
    return wrap;
  }

  function render() {
    body.textContent = '';
    const field = redraw();

    // --- storm + second — views of the SHARED selection, gate 2.2 ---
    const sel = el('select', 'ed-sel');
    for (const k of STORM_KEYS) {
      const o = el('option', null, k.replace(/^storm_/, ''));
      o.value = k;
      if (k === SEL.storm) o.selected = true;
      sel.append(o);
    }
    sel.addEventListener('change', () => {
      // Write the store, nothing else — the store's change is what re-renders,
      // so a storm picked ANYWHERE (this select, B's panel, the console) walks
      // the identical path through this panel.
      SEL.setStorm(sel.value, 'wind-panel');
    });
    body.append(row('storm', sel));

    const dur = wind?.duration ?? 90;
    body.append(row('second', el('span', null, `${Math.min(SEL.time, dur).toFixed(1)} s / ${dur.toFixed(0)} s`)));
    body.append(slider(0, dur, 0.5, Math.min(SEL.time, dur), (v) => `${v.toFixed(1)} s`, (v) => {
      SEL.setTime(v, 'wind-panel');
    }));

    // Jump the SHARED second to the storm's worst moment on this yard — the
    // scrubber question D kept answering by patience, answered by scan. Writes
    // through the store like every other control, so SCORE IT and any future
    // subscriber see the same landing.
    const bPeak = el('button', 'ed-btn', 'JUMP TO WORST SECOND');
    bPeak.addEventListener('click', () => {
      if (!wind) return;
      const yard = EDITOR.site.yard ?? { width: 30, depth: 20 };
      const probes = (wind.core.venturi ?? []).map((v) => ({ x: v.x, z: v.z }));
      worstNote = {
        ...worstSecond(wind, { width: yard.width, depth: yard.depth, probes }),
        storm: SEL.storm,
      };
      SEL.setTime(worstNote.t, 'wind-panel:worst');
      render();                  // covers the no-op case (already sitting on it)
    });
    body.append(row('', bPeak));
    if (worstNote && worstNote.storm === SEL.storm) {
      body.append(row('worst', el('span', worstNote.speed > (field?.uniform ?? 0) * 1.15 ? 'ed-warn' : null,
        `${worstNote.speed.toFixed(2)} m/s @ ${worstNote.t.toFixed(1)} s · (${worstNote.x.toFixed(1)}, ${worstNote.z.toFixed(1)})`)));
    }

    if (field) {
      const card = el('div', 'ed-card');
      const head = el('div', 'ed-card-head');
      head.append(el('span', null, 'FIELD'), el('span', 'ed-kv', `${axisDeg(field.dir).toFixed(0)}° axis of flow`));
      card.append(head);
      const kv = (k, v, cls) => {
        const r = el('div', 'ed-card-row');
        r.append(el('span', 'ed-kv', k), el('span', cls, v));
        card.append(r);
      };
      kv('uniform', `${field.uniform.toFixed(2)} m/s`);
      kv('fastest', `${field.max.speed.toFixed(2)} m/s`,
        field.max.speed > field.uniform * 1.15 ? 'ed-warn' : null);
      kv('  at', `${field.max.x.toFixed(1)}, ${field.max.z.toFixed(1)}`, 'ed-kv');
      kv('calmest', `${field.min.speed.toFixed(2)} m/s`);
      kv('  at', `${field.min.x.toFixed(1)}, ${field.min.z.toFixed(1)}`, 'ed-kv');
      const amp = field.uniform > 1e-6 ? field.max.speed / field.uniform : 1;
      kv('peak gain', `×${amp.toFixed(2)}`, amp > 1.15 ? 'ed-warn' : 'ed-kv');
      body.append(card);
    }

    // --- overlays ---
    const bField = el('button', `ed-btn${showField ? ' on' : ''}`, 'FIELD');
    bField.addEventListener('click', () => { showField = !showField; render(); });
    const bShel = el('button', `ed-btn${showShelters ? ' on' : ''}`, 'SHELTERS');
    bShel.addEventListener('click', () => { showShelters = !showShelters; render(); });
    body.append(row('show', bField, bShel));

    const gsel = el('select', 'ed-sel');
    for (const g of [1, 1.5, 2, 3]) {
      const o = el('option', null, `${g} m`);
      o.value = g;
      if (g === gridStep) o.selected = true;
      gsel.append(o);
    }
    gsel.addEventListener('change', () => { gridStep = parseFloat(gsel.value); render(); });
    body.append(row('grid', gsel));

    body.append(el('p', 'ed-note',
      'Colour is amplification against the storm’s uniform speed — blue is sheltered, '
      + 'red is funnelled, pale is untouched. Length is absolute speed. Every arrow is '
      + 'parallel because a yard changes the wind’s SPEED, never its heading.'));

    // --- venturi ---
    const list = venturiList();
    const bAdd = el('button', 'ed-btn primary', 'PLACE VENTURI');
    const armed = EDITOR.tool === 'venturi';
    if (armed) bAdd.classList.add('on');
    bAdd.textContent = armed ? 'PLACING — click the gap' : 'PLACE VENTURI';
    bAdd.addEventListener('click', () => {
      EDITOR.setTool(armed ? null : 'venturi');
      render();
    });
    body.append(row('venturi', bAdd));

    if (!list.length) {
      body.append(el('p', 'ed-note',
        'No funnel on this site. A venturi is a GAP between buildings that speeds the '
        + 'wind up when the storm swings to run along it — the corner block is calm until '
        + 'the southerly arrives, then it screams.'));
    }

    list.forEach((v, i) => {
      const card = el('div', 'ed-card');
      const head = el('div', 'ed-card-head');
      const name = el('span', null, `gap ${i + 1}`);
      name.style.cursor = 'pointer';
      name.addEventListener('click', () => { selVenturi = i; render(); });
      if (i === selVenturi) name.classList.add('ed-ok');
      const del = el('button', 'ed-btn danger', '×');
      del.addEventListener('click', () => {
        list.splice(i, 1);
        selVenturi = Math.max(0, selVenturi - 1);
        commit();
      });
      head.append(name, del);
      card.append(head);

      const at = el('div', 'ed-card-row');
      at.append(el('span', 'ed-kv', 'throat'), el('span', null, `${v.x.toFixed(1)}, ${v.z.toFixed(1)}`));
      card.append(at);

      // THE AXIS ROW — the mod-π teaching in words as well as geometry.
      const a = normalizeAxis(v.axis ?? 0);
      const aRow = el('div', 'ed-card-row');
      aRow.append(el('span', 'ed-kv', 'axis'),
        el('span', null, `${(a * 180 / Math.PI).toFixed(0)}° ≡ ${((a * 180 / Math.PI) + 180).toFixed(0)}°`));
      card.append(aRow);

      card.append(slider(0, 180, 1, a * 180 / Math.PI, (x) => `${x.toFixed(0)}°`, (x) => {
        v.axis = round3(normalizeAxis(x * Math.PI / 180));
        commit();
      }));
      card.append(slider(1, 2, 0.05, v.gain ?? 1.4, (x) => `gain ×${x.toFixed(2)}`, (x) => {
        v.gain = round2(x);
        commit();
      }));
      card.append(slider(1, 12, 0.5, v.radius ?? 4, (x) => `r ${x.toFixed(1)} m`, (x) => {
        v.radius = round2(x);
        commit();
      }));
      card.append(slider(1, 8, 1, v.sharp ?? 3, (x) => `sharp ${x.toFixed(0)}`, (x) => {
        v.sharp = x;
        commit();
      }));

      // What this gap is doing RIGHT NOW, at the second on the scrubber — the
      // number an author actually wants: not "is it authored" but "is it firing".
      if (wind) {
        const core = wind.core.venturi[i];
        if (core) {
          const d = wind.core.dirAt(SEL.time);
          const align = Math.abs(Math.cos(d) * core.axisX + Math.sin(d) * core.axisZ);
          const fired = Math.pow(align, core.sharp);
          const at0 = wind.core.speedAt(core.x, core.z, SEL.time);
          const r = el('div', 'ed-card-row');
          r.append(el('span', 'ed-kv', 'now'),
            el('span', fired > 0.5 ? 'ed-warn' : 'ed-kv',
              `${at0.toFixed(1)} m/s · align ${(fired * 100).toFixed(0)}%`));
          card.append(r);
        }
      }
      body.append(card);
    });

    body.append(el('p', 'ed-note',
      'An axis is a LINE, not a heading: the gap funnels either way through it, so '
      + '120° and 300° are the same gap and the slider stops at 180°. Drag either end '
      + 'handle — both are the same line. The throat handle moves the gap.'));

    body.append(el('p', 'ed-note',
      'Neither funnels nor shelters have a height term — they apply at every height. '
      + 'The shelter volume is drawn to canopy height as a reading aid only.'));
  }

  // --- storms are fetched, so the panel boots async -----------------------
  async function ensureStorm(k) {
    if (!storms[k]) storms[k] = await loadStorm(k);
  }

  /**
   * React to the shared selection. Async because a newly-picked storm may need
   * fetching; the token drops a stale response — two quick switches must not
   * let the slower fetch paint the panel with the loser (the def cache makes
   * this near-impossible to hit, and near-impossible is not a guard).
   */
  let selGen = 0;
  async function applySel() {
    const gen = ++selGen;
    await ensureStorm(SEL.storm);
    if (gen !== selGen) return;
    refreshWind();
    render();
  }

  await applySel();
  SEL.onChange(() => { applySel(); });

  // A clears the overlay on every rebuild — so every rebuild, I redraw. My
  // gizmos are a pure function of (site, storm, second), which is exactly why
  // this is one line instead of a lifetime problem.
  EDITOR.on('rebuild', () => { worstNote = null; refreshWind(); render(); });
  EDITOR.on('siteload', () => { selVenturi = 0; worstNote = null; });
  /**
   * The wind is rebuilt HERE, on any 'change', rather than in my own commit().
   *
   * Caught by looking: with the refresh in commit(), my sliders were correct and
   * everything else was a lie. Setting `site.wind.venturi[0].axis` from outside
   * and calling `markDirty()` — which is A's documented way for ANY lane to
   * change the site, and what an undo or a scripted edit does — re-rendered the
   * panel against the wind built from the PREVIOUS site, so the funnel readout
   * kept insisting the gap was 24% aligned after it had been put back to 100%.
   * A stale wind behind a fresh panel is the exact failure mode A parented the
   * overlay to `rebuild` to kill, reintroduced one layer up by me.
   *
   * Refreshing on the event instead of in the mutator means the overlay is a
   * function of the site as it IS, no matter who changed it. render() never
   * calls markDirty(), so this cannot loop.
   */
  EDITOR.on('change', () => { worstNote = null; refreshWind(); render(); });

  return { root, body, redraw, buildWind, get wind() { return wind; } };
}

// Self-register. `editor.html` imports this after `createEditor()` resolves, so
// `globalThis.EDITOR` is up by the time this runs (asked for in THREADS — that
// page is A's file and I do not edit it).
if (globalThis.EDITOR) {
  await mountWindPanel(globalThis.EDITOR);
} else if (globalThis.document?.getElementById('ed-canvas')) {
  // On the editor page with no EDITOR = the import landed in the wrong place.
  // Anywhere else (c.test.js importing the pure helpers) this is not a problem
  // and must not print — a selftest that warns every run trains people to
  // ignore warnings.
  console.warn('[editor.wind] no globalThis.EDITOR — import me AFTER createEditor() resolves');
}
