// world/spline.js (Lane A) — the math core of the canal. Deliberately imports NOTHING but
// core/rng.js: no three.js, so `node web/js/world/spline.js --selfcheck` runs it headless in
// qa.sh. index.js wraps these plain {x,y,z} numbers into THREE objects at the contract
// boundary (docs/TECH.md §THE WORLD CONTRACT).
//
// Model
// -----
// The centreline is a graph over the -Z axis: P(u) = (X(u), Y(u), -u). Two consequences we
// rely on everywhere: it can never self-intersect, and `u ≈ -pos.z` is a free, close seed for
// project(). X/Y are sums of three seeded low-frequency sines, smoothstep-blended across
// segment joins so the tangent never kinks.
//
// Amplitudes are NOT authored in units — they're solved from a curvature budget. A tube bends
// through itself when the centreline's turn radius drops below the tube radius, so `curviness`
// means "fraction of the tightest bend this pipe's radius can survive":
//     kappa_budget(s) = curviness(s) / (PINCH_SAFETY * baseRadius(s))
// split across axes and octaves by fixed weights, then inverted per octave (A = w*kappa/f^2).
// A wide pipe therefore straightens itself automatically and Lane C never has to think about
// curvature. selfcheck measures the result (`pinchRatio`) rather than trusting this argument —
// it caught the first version of this file, where amplitude was authored in units and a
// curviness-0.95 join into a radius-16 segment folded the tube shut.
//
// u is NOT arclength (|dP/du| >= 1). Everything public is in arclength `s`, mapped through
// LUTs built by one forward march.

import { createRng } from '../core/rng.js';

// --- tiny vec3 (plain objects; index.js converts) ---------------------------------------
const V = (x = 0, y = 0, z = 0) => ({ x, y, z });
const sub = (a, b) => V(a.x - b.x, a.y - b.y, a.z - b.z);
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
const len = (a) => Math.sqrt(dot(a, a));
const norm = (a) => { const l = len(a) || 1; return V(a.x / l, a.y / l, a.z / l); };
const cross = (a, b) => V(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
const lerpV = (a, b, t) => V(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t, a.z + (b.z - a.z) * t);
const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (e0, e1, x) => { const t = clamp((x - e0) / (e1 - e0), 0, 1); return t * t * (3 - 2 * t); };

// --- tuning -----------------------------------------------------------------------------
// OMEGA is the peristaltic contraction *rate*, global to the whole canal and constant in
// time — physiologically it's how often the muscle fires, which doesn't change because you
// crossed into a wider pipe. The wavenumber is what varies: k(s) = OMEGA / crestSpeed(s), so
// phase is the integral K(s) = ∫k ds (phaseAt), which stays continuous across flow changes —
// plain `k*s - w*t` does not, and tears the wave at every segment join.
// 3.08 = 0.22 rad/unit x 14 units/s, i.e. the stub's round-0 esophagus wave.
export const OMEGA = 3.08;

// Crest-speed law (TECH §FROZEN v1.1, round-2 ruling #1). Round 1 made a crest travel at
// exactly flow(s), which felt right and played wrong: B measured that surfing then loses to
// throttle-mashing (throttleMax 1.4 × flow > 1.0 × flow), so the level's signature mechanic
// was strictly dominated. The wave has to outrun the player. crestSpeed = 1.6 × flow beats
// 1.4 and stays "gameplay wave == visual wave" — the thing you see is the thing you ride.
// Consequence: crest spacing grows 1.6× (45.7u at flow 14, was 28.5u). Rings are further
// apart and move faster, which reads better on a speed level anyway.
export const CREST_FACTOR = 1.6;

const DU = 0.5;        // march step in curve parameter
const DS = 0.5;        // frame/phase LUT spacing in arclength
const NOISE_TAB = 1024;
const RADIUS_WAVELENGTH = 55;   // units per radius-fbm octave-0 cycle
const PINCH_SAFETY = 2.2;       // min turn radius = this x base radius (>2 covers wobble peaks)

// Blend widths (units) for smoothstep-crossfading segment params across a join. Nothing ever
// steps: a hard radius change would read as a level seam, and C's constrictions are supposed
// to feel like the body narrowing.
//
// `base`/`wobble` at ±25 => a 50-unit taper, which is what C's L2 was authored against
// (LANE_C_NOTES → A #4: "assumes a smooth blend over ~40–60 units", their biggest open
// dependency on me). Round 1 was ±12; widened here to match the design. At L2's 12→9 step
// (s 1100) that's ~3.5s of narrowing at flow 16 — you feel the body close in, not a wall.
// `curviness`/`curvBase` feed centreline *amplitude*, which has a long lever arm: ramping it
// over a short span is itself a hard lateral swerve, i.e. curvature. They get a wider ramp.
// `waveAmp` tracks radius: a segment that is calmer is calmer over the same taper it narrows.
const BLEND = { curviness: 60, curvBase: 60, base: 25, wobble: 25, flow: 12, waveAmp: 25 };

// [wavelength, share of the axis curvature budget]. Y is tamer than X on purpose: a canal that
// writhes vertically as hard as it does laterally would swing the parallel-transport frame
// around and make Lane B's chase cam seasick. Axis shares are set so that even with every
// octave peaking together, sqrt(X^2 + Y^2) lands exactly on the budget.
const OCT_X = [[240, 0.5], [110, 0.3], [60, 0.2]];
const OCT_Y = [[190, 0.5], [85, 0.3], [45, 0.2]];
const AXIS_X = 0.8, AXIS_Y = 0.6;

/** Solve each octave's amplitude from its share of a unit curvature budget: kappa ~ A*f^2. */
function octaveCoefs(octaves, axisShare) {
  return octaves.map(([wavelength, weight]) => {
    const f = (2 * Math.PI) / wavelength;
    return { f, coef: (weight * axisShare) / (f * f) };
  });
}

const GET = {
  curviness: (g) => (typeof g.curviness === 'number' ? g.curviness : 0.5),
  base: (g) => (g.radius && typeof g.radius.base === 'number' ? g.radius.base : 10),
  wobble: (g) => (g.radius && typeof g.radius.wobble === 'number' ? g.radius.wobble : 0.2),
  flow: (g) => (typeof g.flow === 'number' ? g.flow : 10),
  // schema v2 `segments[].wave: { amp }` (round-2 ruling #8, C's request): the diaphragmatic
  // hiatus is a fixed muscular ring and does NOT have big peristaltic waves, but it's also the
  // level's tightest hole — so biome-wide amplitude put the biggest wave in the smallest gap.
  // index.js fills this from the biome registry before we ever see it, so it's always a number.
  waveAmp: (g) => (g.wave && typeof g.wave.amp === 'number' ? g.wave.amp : 1.0),
};
GET.curvBase = GET.base;

/**
 * Build the canal math for a level. Deterministic: same `level.seed` (or injected rng) =>
 * byte-identical output, forever. See world.hash().
 * @param {object} level  level JSON (TECH.md §Level data schema v0)
 * @param {function} [rng]  core/rng.js createRng() instance; defaults to level.seed
 */
export function buildSpline(level, rng) {
  if (!level || !Array.isArray(level.segments) || level.segments.length === 0)
    throw new Error('[world] level.segments is required and must be non-empty');
  const R = rng || createRng((level.seed ?? 0) >>> 0);

  // --- segment table (declared arclength boundaries; C's events index this same s) -------
  const spans = [];
  let acc = 0;
  for (const seg of level.segments) {
    const length = typeof seg.length === 'number' && seg.length > 0 ? seg.length : 100;
    spans.push({ s0: acc, s1: acc + length, seg });
    acc += length;
  }
  const L = acc;

  function idxAt(s) {
    // linear scan: levels have a handful of segments, and a branchy binary search here would
    // cost more than it saves.
    for (let i = 0; i < spans.length; i++) if (s < spans[i].s1) return i;
    return spans.length - 1;
  }

  /** Segment param at s, smoothstep-blended across joins so nothing steps discontinuously. */
  function paramAt(s, key) {
    const get = GET[key], w = BLEND[key];
    const i = idxAt(s);
    const sp = spans[i];
    const v = get(sp.seg);
    if (i > 0 && s < sp.s0 + w)
      return lerp(get(spans[i - 1].seg), v, smoothstep(sp.s0 - w, sp.s0 + w, s));
    if (i < spans.length - 1 && s > sp.s1 - w)
      return lerp(v, get(spans[i + 1].seg), smoothstep(sp.s1 - w, sp.s1 + w, s));
    return v;
  }

  // --- seeded noise ---------------------------------------------------------------------
  const tab = new Float32Array(NOISE_TAB);
  { const r = R('world.radius'); for (let i = 0; i < NOISE_TAB; i++) tab[i] = r() * 2 - 1; }
  function vnoise(x) {
    const i = Math.floor(x), f = x - i;
    const a = tab[i & (NOISE_TAB - 1)], b = tab[(i + 1) & (NOISE_TAB - 1)];
    return lerp(a, b, f * f * (3 - 2 * f));
  }
  function fbm1(x) {
    let sum = 0, amp = 1, freq = 1, normSum = 0;
    for (let o = 0; o < 3; o++) { sum += amp * vnoise(x * freq + o * 17.3); normSum += amp; amp *= 0.5; freq *= 2.03; }
    return sum / normSum;              // [-1, 1]
  }
  const radiusAt = (s) => paramAt(s, 'base') * (1 + paramAt(s, 'wobble') * fbm1(s / RADIUS_WAVELENGTH));

  // --- centreline -----------------------------------------------------------------------
  const cx = octaveCoefs(OCT_X, AXIS_X), cy = octaveCoefs(OCT_Y, AXIS_Y);
  const phX = cx.map(() => R('world.spline')() * Math.PI * 2);
  const phY = cy.map(() => R('world.spline')() * Math.PI * 2);

  /** How hard the canal is allowed to bend at s: curviness as a fraction of the pinch limit. */
  const curvBudgetAt = (s) => paramAt(s, 'curviness') / (PINCH_SAFETY * Math.max(1, paramAt(s, 'curvBase')));

  /** `budget` is kappa (1/units), not a length — octave coefs turn it into lateral offsets. */
  function centreWith(u, budget) {
    let x = 0, y = 0;
    for (let i = 0; i < cx.length; i++) x += budget * cx[i].coef * Math.sin(u * cx[i].f + phX[i]);
    for (let i = 0; i < cy.length; i++) y += budget * cy[i].coef * Math.sin(u * cy[i].f + phY[i]);
    return V(x, y, -u);
  }

  // --- the march: u -> s, and the curvature budget sampled along it ----------------------
  // The budget is a function of arclength (that's where segments are declared), but the curve
  // is evaluated in u — so we resolve it during the march, where s is already known, and
  // cache the schedule per u-step. One pass, no circularity.
  const uToS = [0];
  const ampArr = [curvBudgetAt(0)];
  {
    let s = 0, prev = centreWith(0, ampArr[0]), n = 0;
    const MAXN = Math.ceil((L / DU) * 1.5) + 16;
    while (s < L && n < MAXN) {
      n++;
      const amp = curvBudgetAt(s);             // s lags one step: deterministic, sub-step error
      const p = centreWith(n * DU, amp);
      s += len(sub(p, prev));
      uToS.push(s); ampArr.push(amp);
      prev = p;
    }
  }
  const uMax = (uToS.length - 1) * DU;
  const ampAtU = (u) => {
    const t = clamp(u / DU, 0, ampArr.length - 1), i = Math.min(Math.floor(t), ampArr.length - 2);
    return lerp(ampArr[i], ampArr[i + 1], t - i);
  };
  const centre = (u) => centreWith(u, ampAtU(u));
  const sOfU = (u) => {
    const t = clamp(u / DU, 0, uToS.length - 1), i = Math.min(Math.floor(t), uToS.length - 2);
    return lerp(uToS[i], uToS[i + 1], t - i);
  };

  // uniform s -> u table, inverted by a monotone merge-walk (uToS is strictly increasing)
  const M = Math.ceil(L / DS) + 1;
  const sToU = new Float64Array(M);
  {
    let j = 0;
    for (let i = 0; i < M; i++) {
      const target = Math.min(i * DS, L);
      while (j < uToS.length - 2 && uToS[j + 1] < target) j++;
      const s0 = uToS[j], s1 = uToS[j + 1];
      const f = s1 > s0 ? (target - s0) / (s1 - s0) : 0;
      sToU[i] = (j + f) * DU;
    }
  }
  const uOfS = (s) => {
    const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
    return lerp(sToU[i], sToU[i + 1], t - i);
  };

  // --- frames: parallel transport, NOT Frenet -------------------------------------------
  // Frenet's normal flips through inflection points; on a canal that writhes that shows up as
  // the camera snapping upside-down mid-corridor (LANE_A_WORLD.md calls this out by name).
  // Parallel transport carries the previous normal forward by the minimal rotation, so it's
  // history-dependent => must be integrated once here and looked up, never recomputed ad hoc.
  const posArr = new Float64Array(M * 3);
  const tanArr = new Float64Array(M * 3);
  const norArr = new Float64Array(M * 3);
  {
    const H = 0.25;
    const tangentAtU = (u) => {
      const a = centre(Math.max(0, u - H)), b = centre(Math.min(uMax, u + H));
      return norm(sub(b, a));
    };
    const put = (arr, i, v) => { arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z; };

    let prevT = tangentAtU(uOfS(0));
    // seed the frame from world up; the canal is a graph over -Z so it can never be vertical
    let n = norm(sub(V(0, 1, 0), V(prevT.x * prevT.y, prevT.y * prevT.y, prevT.z * prevT.y)));
    put(posArr, 0, centre(uOfS(0))); put(tanArr, 0, prevT); put(norArr, 0, n);

    for (let i = 1; i < M; i++) {
      const u = uOfS(Math.min(i * DS, L));
      const p = centre(u), t = tangentAtU(u);
      const axis = cross(prevT, t);
      const sinA = len(axis), cosA = dot(prevT, t);
      if (sinA > 1e-9) {                       // Rodrigues: rotate n by the frame's own turn
        const k = norm(axis), angle = Math.atan2(sinA, cosA);
        const c = Math.cos(angle), sN = Math.sin(angle), kv = cross(k, n), kd = dot(k, n);
        n = V(n.x * c + kv.x * sN + k.x * kd * (1 - c),
              n.y * c + kv.y * sN + k.y * kd * (1 - c),
              n.z * c + kv.z * sN + k.z * kd * (1 - c));
      }
      n = norm(V(n.x - t.x * dot(n, t), n.y - t.y * dot(n, t), n.z - t.z * dot(n, t)));  // re-orthogonalize
      put(posArr, i, p); put(tanArr, i, t); put(norArr, i, n);
      prevT = t;
    }
  }
  const at = (arr, i) => V(arr[i * 3], arr[i * 3 + 1], arr[i * 3 + 2]);

  // --- peristalsis phase: K(s) = ∫ k dx, trapezoid on the same grid ----------------------
  const crestSpeedAt = (s) => CREST_FACTOR * Math.max(1, paramAt(s, 'flow'));
  const kAt = (s) => OMEGA / crestSpeedAt(s);
  const waveAmpAt = (s) => paramAt(s, 'waveAmp');
  const phaseArr = new Float64Array(M);
  for (let i = 1; i < M; i++)
    phaseArr[i] = phaseArr[i - 1] + 0.5 * (kAt((i - 1) * DS) + kAt(i * DS)) * DS;
  const phaseAt = (s) => {
    const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2);
    return lerp(phaseArr[i], phaseArr[i + 1], t - i);
  };

  /** Frame at arclength s. pos/tan/nor/bin + geometric radius. */
  function frameAt(s) {
    const t = clamp(s, 0, L) / DS, i = Math.min(Math.floor(t), M - 2), f = t - i;
    const pos = lerpV(at(posArr, i), at(posArr, i + 1), f);
    const tan = norm(lerpV(at(tanArr, i), at(tanArr, i + 1), f));
    let nor = lerpV(at(norArr, i), at(norArr, i + 1), f);
    nor = norm(V(nor.x - tan.x * dot(nor, tan), nor.y - tan.y * dot(nor, tan), nor.z - tan.z * dot(nor, tan)));
    return { pos, tan, nor, bin: cross(tan, nor), radius: radiusAt(s) };
  }

  /**
   * World -> spline space. Seeded from u = -pos.z (exact for the centreline's own axis, so
   * the seed error is only the lateral tilt), then fixed-point: s += (p - C(s))·T(s).
   * Converges at rate ~ curvature*rho (<0.5 in tube mode). Step-clamped so a point far off
   * the centreline (arena mode) walks instead of exploding.
   */
  function project(p, hint) {
    let s = typeof hint === 'number' ? clamp(hint, 0, L) : sOfU(clamp(-p.z, 0, uMax));
    for (let i = 0; i < 6; i++) {
      const f = frameAt(s);
      const d = dot(sub(p, f.pos), f.tan);
      const next = clamp(s + clamp(d, -8, 8), 0, L);
      if (Math.abs(next - s) < 1e-4) { s = next; break; }
      s = next;
    }
    const f = frameAt(s);
    const rel = sub(p, f.pos);
    const along = dot(rel, f.tan);
    return {
      s,
      theta: Math.atan2(dot(rel, f.bin), dot(rel, f.nor)),
      rho: Math.sqrt(Math.max(0, dot(rel, rel) - along * along)),   // true cross-section radius
    };
  }

  /** Golden determinism hash over sampled frames + radius + wave phase. qa.sh compares. */
  function hash() {
    let h = 2166136261 >>> 0;
    const push = (x) => { h ^= Math.round(x * 1000) & 0xffff; h = Math.imul(h, 16777619); };
    for (let s = 0; s <= L; s += 10) {
      const f = frameAt(s);
      push(f.pos.x); push(f.pos.y); push(f.pos.z); push(f.radius);
      push(f.nor.x); push(f.nor.y); push(f.nor.z); push(phaseAt(s));
    }
    return (h >>> 0).toString(16);
  }

  /**
   * Measured geometry health — the pinch guard is the one that bites.
   *
   * `pinchRatio` is LOCAL: the worst turnRadius(s)/radius(s) over the canal, i.e. the tightest
   * bend measured against the tube's radius AT THAT s. It used to be global — min turn radius
   * anywhere ÷ max radius anywhere — which is the same number whenever radius is roughly
   * constant (every corridor level: L2 and L3 are unaffected) and is nonsense the moment it
   * isn't. Round 2's swept-room prototype scored 1.30 and "failed": it was dividing a bend in
   * the radius-13 pylorus by the radius of the sea 996 units away, two places that cannot fold
   * into each other. Locally its worst point is 6.4. The old metric could only ever cry wolf,
   * never miss a real fold — but a false alarm on the pinch guard is not harmless: the guard is
   * what lets Lane C author `curviness` without thinking about curvature (round-1 decision #1),
   * and a guard that fails valid rooms takes that guarantee away exactly when C needs it.
   *
   * The change is provably one-directional — global min turn ÷ global max radius is <= the
   * local ratio everywhere by construction — so nothing that passed the round-1 guard can fail
   * this one. Real levels gained headroom, they didn't lose it: L2 3.32 -> 4.16, L3 1.91 -> 6.99.
   *
   * Neither metric has ever covered NON-LOCAL self-intersection — the canal looping back and
   * passing through itself far from where it bent. That needs a proximity test over the whole
   * centreline, not a curvature test, and no level has come close to needing one. Stated so the
   * gap is known rather than assumed away.
   */
  function stats() {
    let maxCurvature = 0, sAtMax = 0, maxRadius = 0, minRadius = Infinity;
    for (let i = 1; i < M - 1; i++) {
      const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS);
      if (k > maxCurvature) { maxCurvature = k; sAtMax = i * DS; }
    }
    for (let s = 0; s <= L; s += 1) {
      const r = radiusAt(s);
      if (r > maxRadius) maxRadius = r;
      if (r < minRadius) minRadius = r;
    }
    // The local guard, on the same grid the curvature is measured on.
    let pinchRatio = Infinity, sAtPinch = 0;
    for (let i = 1; i < M - 1; i++) {
      const k = len(sub(at(tanArr, i + 1), at(tanArr, i - 1))) / (2 * DS);
      const s = i * DS;
      const ratio = (k > 0 ? 1 / k : Infinity) / Math.max(0.001, radiusAt(s));
      if (ratio < pinchRatio) { pinchRatio = ratio; sAtPinch = s; }
    }
    const minTurnRadius = maxCurvature > 0 ? 1 / maxCurvature : Infinity;
    return {
      length: L, gridPoints: M, uMax,
      maxCurvature, sAtMaxCurvature: sAtMax, minTurnRadius,
      minRadius, maxRadius,
      pinchRatio,                              // <1 => the tube folds through itself on a bend
      sAtPinch,                                // where to look when it does
      pinchRatioGlobal: minTurnRadius / maxRadius,   // the old number, kept for comparison only
    };
  }

  return {
    length: L, spans, uMax,
    centre, sOfU, uOfS, ampAtU,
    radiusAt, frameAt, project, phaseAt, kAt, paramAt,
    crestSpeedAt, waveAmpAt,
    omega: OMEGA,
    hash, stats,
    segmentAt: (s) => spans[idxAt(clamp(s, 0, L))].seg,
  };
}

// ---------------------------------------------------------------------------------------
// headless selfcheck: `node web/js/world/spline.js --selfcheck`   (qa.sh gate)
// Browsers never reach this (no `process`).
// ---------------------------------------------------------------------------------------
async function selfcheck() {
  const { FIXTURE } = await import('./_fixture.js');
  let fail = 0;
  const ok = (name, cond, detail = '') => {
    if (cond) console.log(`  \x1b[32m✓\x1b[0m ${name}${detail ? ' — ' + detail : ''}`);
    else { fail++; console.log(`  \x1b[31m✗\x1b[0m ${name}${detail ? ' — ' + detail : ''}`); }
  };
  console.log('world/spline selfcheck (fixture: %s)', FIXTURE.id);

  const a = buildSpline(FIXTURE);
  const b = buildSpline(FIXTURE);
  const c = buildSpline({ ...FIXTURE, seed: FIXTURE.seed + 1 });
  const st = a.stats();

  ok('determinism: same seed => same hash', a.hash() === b.hash(), a.hash());
  ok('determinism: different seed => different hash', a.hash() !== c.hash(), `${a.hash()} vs ${c.hash()}`);

  const declared = FIXTURE.segments.reduce((n, s) => n + s.length, 0);
  ok('arclength: length == declared total', Math.abs(a.length - declared) < 1e-6, `${a.length}`);

  let mono = true, prev = -1;
  for (let s = 0; s <= a.length; s += 1) { const u = a.uOfS(s); if (u < prev) mono = false; prev = u; }
  ok('arclength: s -> u monotone', mono);

  let maxRt = 0;
  for (let s = 0; s <= a.length; s += 3) maxRt = Math.max(maxRt, Math.abs(a.sOfU(a.uOfS(s)) - s));
  ok('arclength: s -> u -> s round-trip < 0.05', maxRt < 0.05, `max err ${maxRt.toExponential(2)}`);

  let orth = 0, unit = 0;
  for (let s = 0; s <= a.length; s += 2) {
    const f = a.frameAt(s);
    orth = Math.max(orth, Math.abs(f.tan.x * f.nor.x + f.tan.y * f.nor.y + f.tan.z * f.nor.z));
    unit = Math.max(unit, Math.abs(Math.hypot(f.nor.x, f.nor.y, f.nor.z) - 1), Math.abs(Math.hypot(f.bin.x, f.bin.y, f.bin.z) - 1));
  }
  ok('frames: orthonormal (tan·nor ~ 0, |nor| = |bin| = 1)', orth < 1e-9 && unit < 1e-9,
     `tan·nor ${orth.toExponential(1)}, unit err ${unit.toExponential(1)}`);

  // parallel transport must not spin the frame up: total twist stays bounded & smooth
  let maxTwist = 0;
  for (let s = 1; s <= a.length; s += 1) {
    const p = a.frameAt(s - 1).nor, q = a.frameAt(s).nor;
    maxTwist = Math.max(maxTwist, Math.acos(Math.min(1, Math.abs(p.x * q.x + p.y * q.y + p.z * q.z))));
  }
  ok('frames: no roll snap (max per-unit normal turn < 0.2 rad)', maxTwist < 0.2, `${maxTwist.toFixed(4)} rad`);

  ok('geometry: tube does not pinch (LOCAL turnRadius(s) / radius(s) > 1.5)', st.pinchRatio > 1.5,
     `worst at s=${st.sAtPinch.toFixed(0)} => ${st.pinchRatio.toFixed(2)}x` +
     ` (global min/max ratio, the round-1 metric, would say ${st.pinchRatioGlobal.toFixed(2)}x)`);

  // project() round-trip: place a point at a known (s, theta, rho), recover it
  let maxSerr = 0, maxTerr = 0, maxRerr = 0;
  for (let s = 5; s < a.length - 5; s += 7) {
    for (const theta of [0, 1.3, -2.4, 3.0]) {
      const rho = a.radiusAt(s) * 0.6;
      const f = a.frameAt(s);
      const p = {
        x: f.pos.x + (f.nor.x * Math.cos(theta) + f.bin.x * Math.sin(theta)) * rho,
        y: f.pos.y + (f.nor.y * Math.cos(theta) + f.bin.y * Math.sin(theta)) * rho,
        z: f.pos.z + (f.nor.z * Math.cos(theta) + f.bin.z * Math.sin(theta)) * rho,
      };
      const q = a.project(p);
      maxSerr = Math.max(maxSerr, Math.abs(q.s - s));
      let dt = Math.abs(q.theta - theta) % (Math.PI * 2);
      if (dt > Math.PI) dt = Math.PI * 2 - dt;
      maxTerr = Math.max(maxTerr, dt);
      maxRerr = Math.max(maxRerr, Math.abs(q.rho - rho));
    }
  }
  ok('project: recovers s within 0.25', maxSerr < 0.25, `max ${maxSerr.toFixed(4)}`);
  ok('project: recovers theta within 0.02 rad', maxTerr < 0.02, `max ${maxTerr.toFixed(5)}`);
  ok('project: recovers rho within 0.05', maxRerr < 0.05, `max ${maxRerr.toFixed(4)}`);

  // wave: phase monotone, and the crest-speed law (TECH FROZEN v1.1)
  let phaseMono = true;
  for (let s = 1; s <= a.length; s += 1) if (a.phaseAt(s) <= a.phaseAt(s - 1)) phaseMono = false;
  ok('wave: phase K(s) strictly increasing', phaseMono);
  const kEso = a.kAt(50), kSto = a.kAt(a.length - 20);
  ok('wave: k == OMEGA/(CREST_FACTOR*flow)', Math.abs(kEso - OMEGA / (CREST_FACTOR * 14)) < 1e-6, `k=${kEso.toFixed(4)}`);
  ok('wave: crestSpeed == 1.6 x local flow, in every biome',
     Math.abs(a.crestSpeedAt(50) - 1.6 * 14) < 0.01 && Math.abs(a.crestSpeedAt(a.length - 20) - 1.6 * 4) < 0.01,
     `${a.crestSpeedAt(50).toFixed(2)} u/s eso (flow 14), ${a.crestSpeedAt(a.length - 20).toFixed(2)} u/s stomach (flow 4)`);
  // the law exists to make surfing the fast line — assert the thing B actually depends on
  ok('wave: crest outruns a throttle-mashing player (crestSpeed > 1.4 x flow)',
     a.crestSpeedAt(50) > 1.4 * 14, `${a.crestSpeedAt(50).toFixed(1)} > ${(1.4 * 14).toFixed(1)} u/s`);
  ok('wave: OMEGA/kAt(s) == crestSpeedAt(s) (phase and speed agree)',
     Math.abs(OMEGA / a.kAt(300) - a.crestSpeedAt(300)) < 1e-9);

  // per-segment wave override (schema v2): fixture segment 3 declares wave.amp 0.55
  const segWave = buildSpline({
    ...FIXTURE,
    segments: FIXTURE.segments.map((s, i) => (i === 2 ? { ...s, wave: { amp: 0.55 } } : { ...s, wave: { amp: 1.4 } })),
  });
  ok('wave: per-segment amp override honoured', Math.abs(segWave.waveAmpAt(650) - 0.55) < 1e-6,
     `amp at s=650 => ${segWave.waveAmpAt(650).toFixed(3)}`);
  ok('wave: amp blends across the join (no step)',
     Math.abs(segWave.waveAmpAt(540) - (1.4 + 0.55) / 2) < 0.02,
     `amp at the join => ${segWave.waveAmpAt(540).toFixed(3)} (midpoint of 1.4 and 0.55)`);

  // C's biggest dependency: radius must taper, not cliff (LANE_C_NOTES → A #4)
  let maxRadiusStep = 0;
  for (let s = 1; s <= a.length; s += 0.5) maxRadiusStep = Math.max(maxRadiusStep, Math.abs(a.radiusAt(s) - a.radiusAt(s - 0.5)));
  ok('radius: no cliff at segment joins (max step < 0.15 u per 0.5 u)', maxRadiusStep < 0.15,
     `max ${maxRadiusStep.toFixed(4)} u/step`);

  console.log(`  stats: L=${st.length} grid=${st.gridPoints} maxK=${st.maxCurvature.toFixed(4)} (s=${st.sAtMaxCurvature}) radius=${st.minRadius.toFixed(1)}..${st.maxRadius.toFixed(1)} hash=${a.hash()}`);
  console.log(fail === 0 ? '\x1b[32mworld/spline: OK\x1b[0m' : `\x1b[31mworld/spline: ${fail} FAILED\x1b[0m`);
  return fail;
}

if (typeof process !== 'undefined' && process.argv && process.argv.includes('--selfcheck')) {
  selfcheck().then((f) => process.exit(f === 0 ? 0 : 1));
}
