// Thin renderer: post controls+calibration -> RenderPlan; render the mood grade,
// the live Right DREAM (a deterministic luminous haze, NOT a baked variant — it
// holds still across the loop), and the live Left overlay + affect. All alteration
// math stays in Python. The scale RING (endless encoder) is navigated via
// /api/ring/advance — Python owns the step/wrap/transition-selection math; the
// browser only plays the returned transition clip(s) then settles on the target.
const $ = (id) => document.getElementById(id);
const vid = $("vid"), paint = $("paint"), tint = $("tint"), overlay = $("overlay"),
      black = $("black"), readout = $("readout");
const loopVid = $("vid-loop");   // steady-state base loop element (double-buffer; #vid carries the scrub morphs)
const affectLayer = $("affect");

// The currently-displayed source element: the morph (#vid) while scrubbing (busy),
// else the preloaded base loop (#vid-loop). The paint shader + fallback read this so
// a landing is a source swap, never a reload of the on-screen element.
function displayVid() { return busy ? vid : loopVid; }

// Show the active source, hide the other (matters only in the WebGL-less fallback;
// with the paint canvas it composites the active source anyway).
function showActiveSource() {
  const a = displayVid();
  a.style.opacity = "1";
  (a === vid ? loopVid : vid).style.opacity = "0";
}

// Right-dream state, read by the painterly render loop each frame. update() (debounced
// on knob moves) writes these; the WebGL loop renders the live video continuously.
let dreamIntensity = 0;    // 0..1 — painterly strength (and dream pastel/luminous)
let gradeFilter = "none";  // CSS filter string for the Dark/Light mood grade

// Surface any uncaught JS error on-screen (a silent throw in update() otherwise
// looks like "nothing is changing" — the render dies but the page sits there).
function showError(msg) {
  let b = document.getElementById("err-banner");
  if (!b) {
    b = document.createElement("div");
    b.id = "err-banner";
    b.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:9999;background:#a00;" +
      "color:#fff;font:12px/1.4 monospace;padding:6px 10px;white-space:pre-wrap;";
    document.body.appendChild(b);
  }
  b.textContent = "⚠ " + msg;
}
window.addEventListener("error", (e) =>
  showError(`${e.message}  @ ${(e.filename || "").split("/").pop()}:${e.lineno}`));

let clipsById = {};        // id -> clip manifest entry
let ring = null;           // {scales:[{id,clip_id,title,pool:[...]}], transitions:[...]} or null
let serverRing = false;    // true when /api/ring served a real ring (vs the synthesized fallback)
let ringIndex = 0;         // current scale position on the ring
let activeClipId = null;   // the pool member chosen for the current scale landing (LOCKED until the altitude changes)
let currentClipId = null;  // base media currently loaded (reset to force a reload)
let busy = false;          // true while a ring transition is playing
let morphByPair = {};      // "fromClip→toClip" -> directed morph file (built from the ring)
let lastOverlay = null;    // {level, intensity} from the most-recent renderOverlay call; drives per-frame track animation
let activeLang = "en";     // session-only; no persistence (resets to en each load)
let lastAffect = null;     // {strength, intensity, right} from the last renderAffect, for re-render on language switch

async function loadData() {
  const clips = (await (await fetch("/api/clips")).json()).clips || [];
  clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
  // Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
  // re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
  // stale. Best-effort: an empty map just yields un-versioned URLs.
  try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
  catch (_) { mediaVersions = {}; }
  const r = await fetch("/api/ring");
  serverRing = r.ok;
  ring = r.ok ? await r.json() : null;
  if (!ring && clips.length) {
    // No ring: single-clip mode — synthesize a 1-scale ring (pool of one) over clip 0.
    ring = { scales: [{ id: clips[0].id, clip_id: clips[0].id, title: clips[0].title, pool: [{ clip_id: clips[0].id, title: clips[0].title }] }], transitions: [] };
  }
  // Directed (fromClip -> toClip) -> morph file lookup. The manifest holds both
  // directions explicitly (forward zoom-in + .rev zoom-out), so no reverse logic
  // is needed at play time. A fallback ring (no transitions) yields an empty map.
  morphByPair = {};
  if (ring && ring.transitions)
    for (const t of ring.transitions) if (t && t.file) morphByPair[`${t.from}→${t.to}`] = t.file;
  ringIndex = 0;
}

// Server-canonical random pick of a pool member for the current scale: a delta=0
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
// fallback ring has a pool of one, so it just returns that member. Returns a
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
async function pickRandomMember() {
  const scale = ring && ring.scales[ringIndex];
  if (!scale) return null;
  if (serverRing) {
    try {
      const resp = await fetch("/api/ring/advance", {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify({ from_index: ringIndex, delta: 0 }),
      });
      if (resp.ok) return (await resp.json()).target_clip_id;
    } catch (_) { /* fall through to the primary member */ }
  }
  return scale.clip_id;
}

// Land on the current scale: pick a random pool member and force its media to load.
async function landScale() {
  activeClipId = await pickRandomMember();
  currentClipId = null;
}

function activeClip() {
  return clipsById[activeClipId] || null;
}

// In-memory preload cache: media file path -> blob object URL. Once a clip's bytes
// are cached, mediaUrl() hands the <video> an in-memory blob instead of a network
// path, so swapping the altitude scale loads near-instantly (no fetch round-trip).
// Falls back to the network path for anything not yet (or never) cached.
const mediaBlobs = {};
let mediaVersions = {};   // file -> content-hash token (from /api/media-versions)
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
// changes with its bytes (permanent cache-bust). The blob cache, when present,
// wins — those bytes are already the current ones for this session.
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) {
  if (file.startsWith("/media/")) return file;   // already a resolved absolute url (audio layer)
  return mediaBlobs[file] || mediaNetUrl(file);
}

// The morphs reachable from the CURRENT locked clip: to/from every member of the
// neighboring altitudes (the only clips one detent can reach), both directions.
// There are 154 morphs in all — far too many to preload up front — so we focus on
// what the next move can actually need and refresh it on every landing.
function reachableMorphFiles() {
  const files = new Set();
  if (!ring || !ring.scales || ring.scales.length < 2 || !activeClipId) return files;
  const n = ring.scales.length;
  for (const sign of [1, -1]) {
    const nb = ring.scales[((ringIndex + sign) % n + n) % n];
    const members = (nb.pool || [{ clip_id: nb.clip_id }]).map((m) => m.clip_id);
    for (const m of members) {
      const into = morphByPair[`${activeClipId}→${m}`];
      const back = morphByPair[`${m}→${activeClipId}`];
      if (into) files.add(into);
      if (back) files.add(back);
    }
  }
  return files;
}

// Every base clip the ring can show (so any scale displays instantly). Morphs are
// NOT bulk-listed here — they're cached per-landing via reachableMorphFiles().
function preloadList() {
  const files = new Set();
  for (const c of Object.values(clipsById)) if (c && c.base_file) files.add(c.base_file);
  return [...files];
}

// Order the preload so the clips most likely to be shown next come first: the
// current scale's pool, then outward to neighboring scales; then the morphs
// reachable from the current clip; then a sweep of remaining bases.
function preloadOrder() {
  if (!ring || !ring.scales) return preloadList();
  const n = ring.scales.length, seen = new Set(), ordered = [];
  const add = (f) => { if (f && !seen.has(f)) { seen.add(f); ordered.push(f); } };
  for (let d = 0; d < n; d++) {
    for (const sign of d === 0 ? [0] : [1, -1]) {
      const s = ring.scales[((ringIndex + sign * d) % n + n) % n];
      if (s && s.pool) for (const m of s.pool) add((clipsById[m.clip_id] || {}).base_file);
      else if (s) add((clipsById[s.clip_id] || {}).base_file);
    }
  }
  for (const f of reachableMorphFiles()) add(f);
  for (const f of preloadList()) add(f);   // sweep up anything not on the ring
  return ordered;
}

// Fetch one media file into the blob cache (idempotent — cached files are skipped).
async function cacheClip(file) {
  if (!file || mediaBlobs[file]) return;
  try {
    const blob = await (await fetch(mediaNetUrl(file), { cache: "reload" })).blob();
    mediaBlobs[file] = URL.createObjectURL(blob);
  } catch (_) { /* leave it to the network path on demand */ }
}

// After landing on a new altitude, kick a background cache fill of the morphs now
// reachable from the locked clip, so the next zoom is instant. Non-blocking.
function refreshReachablePreload() {
  for (const f of reachableMorphFiles()) cacheClip(f);
}

// Fetch every clip into the blob cache in the background — non-blocking, so the
// first scale plays immediately while the rest stream in. Bounded concurrency keeps
// the ~350 MB fetch from stampeding. A tiny progress chip self-removes when done.
async function preloadAllMedia(concurrency = 4) {
  const files = preloadOrder();
  if (!files.length) return;
  let done = 0;
  const total = files.length;
  const chip = preloadChip();
  const tick = () => {
    if (chip) chip.textContent = `⬇ caching clips ${done}/${total}`;
    setLoadingProgress(done, total);   // drive the "Loading Universe…" bar
  };
  tick();
  let i = 0;
  async function worker() {
    while (i < files.length) {
      // cacheClip is idempotent and content-hash-versioned (?v=) + cache:"reload",
      // so a re-baked clip's bytes can't serve stale; the blob cache gives instant
      // in-session swaps.
      await cacheClip(files[i++]);
      done++; tick();
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
  if (chip) chip.remove();
}

function preloadChip() {
  let c = document.getElementById("preload-chip");
  if (!c) {
    c = document.createElement("div");
    c.id = "preload-chip";
    c.style.cssText = "position:fixed;bottom:8px;right:10px;z-index:9998;background:rgba(0,0,0,.55);" +
      "color:#9fe;font:11px/1.4 monospace;padding:4px 8px;border-radius:4px;pointer-events:none;";
    document.body.appendChild(c);
  }
  return c;
}

// Load the active scale's BASE footage into the video (the painterly canvas reads
// its frames live). The Right knob no longer swaps the source — it only drives the
// deterministic dream restyle — so media reloads only when the SCALE changes.
// Each baked morph reproduces the first LOOP_TAIL_S seconds of the destination clip
// (pipeline `trim=0:3` in build_pool_manifest._make_transition), ending on that
// clip's frame at ~LOOP_TAIL_S. So the STEADY-STATE loop must begin there, not at 0
// — otherwise a landed morph (dst@~3s) jump-cuts back to dst@0 (the jitter). We loop
// the base within [LOOP_TAIL_S, duration]: the morph's last frame hands straight off
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP_TAIL_S = 3.0;

// Load a clip's base into the LOOP element (#vid-loop), seeked to the tail and PAUSED
// on that exact frame. Idempotent per clip. Called during a scrub to PRELOAD the clip
// we're about to land on, so settle just plays an already-decoded element at the morph's
// last frame — no reload, no seek, no stall. `playLoop()` starts it at landing.
function loadLoop(clip) {
  if (!clip || loopVid.dataset.clip === clip.id) return;
  loopVid.dataset.clip = clip.id;
  loopVid.dataset.loopTail = "1";    // arm the [LOOP_TAIL_S, duration] wrap handler
  loopVid.src = mediaUrl(clip.base_file);
  loopVid.loop = false;              // native loop restarts at 0; we loop from the tail instead
  loopVid.muted = true;
  const seek = () => { try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ } };
  if (loopVid.readyState >= 1) seek();
  else loopVid.addEventListener("loadedmetadata", seek, { once: true });
}

function playLoop() {
  if (loopVid.paused) loopVid.play().catch(() => {});
}

function ensureClipMedia() {
  const clip = activeClip();
  if (!clip) return;
  currentClipId = clip.id;
  loadLoop(clip);                    // idempotent: a no-op if the scrub already preloaded this clip
  playLoop();                        // steady state runs the loop
}

// The custom loop on the LOOP element: when a base loop reaches the end, jump back to
// the tail offset (not 0). The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
  if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
    try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
  }
});

// Safety net: the timeupdate wrap above is a narrow window (the last 0.08 s). Under
// real-browser decode/GPU load a `timeupdate` can skip past it, the clip fires
// `ended`, and — with native loop off — freezes on its last frame ("not looping").
// `ended` GUARANTEES recovery: seek back to the tail and resume. Headless rarely
// hits this; loaded real machines do.
loopVid.addEventListener("ended", () => {
  if (loopVid.dataset.loopTail !== "1") return;
  try { loopVid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable */ }
  loopVid.play().catch(() => {});
});

// Compose the video look. The Right DREAM is now a painterly WebGL restyle of the
// live frames (see the render loop below) — so here we only stash its intensity for
// that loop, and build the Dark/Light mood grade as a CSS filter. The grade rides
// on the painterly canvas (or #vid in the WebGL-less fallback). No blur: the dream
// must keep the base's crisp motion, just stylized.
function applyVideoLook(tone, dream) {
  const warm = tone > 0 ? tone : 0, cool = tone < 0 ? -tone : 0;
  const bright = 1 + 0.25 * warm - 0.35 * cool;
  const sat = 1 + 0.15 * warm - 0.30 * cool;
  const sepia = warm * 0.5;
  gradeFilter = `brightness(${bright.toFixed(3)}) saturate(${sat.toFixed(3)}) sepia(${sepia.toFixed(3)})`;
  dreamIntensity = dream;
  tint.style.opacity = (cool * 0.6).toFixed(3);
  // WebGL-less fallback: the canvas is hidden, so grade the visible source directly.
  if (!paintOK) displayVid().style.filter = gradeFilter;
}

// --- Right dream: real-time painterly restyle (WebGL2 Kuwahara) ---
// An edge-preserving "oil painting" filter on the LIVE video frames: each pixel
// becomes the mean of whichever surrounding quadrant is most uniform, flattening
// the image into painted regions WHILE KEEPING EDGES — so motion stays as crisp as
// the source. Strength + a gentle pastel/luminous dream-grade scale with intensity.
// Deterministic (a pure function of each frame) => the dream holds still across the
// loop; only the footage's own motion moves.
let paintOK = false;
const PAINT_VS = `#version 300 es
in vec2 p; out vec2 v_uv;
void main(){
  // Fullscreen triangle (verts to +3); map the visible [-1,1] to uv [0,1] with Y
  // flipped for the video's top-left origin. (Visible region stays in range; the
  // off-screen excess is clipped.)
  v_uv = vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5);
  gl_Position = vec4(p, 0.0, 1.0);
}`;
const PAINT_FS = `#version 300 es
precision highp float;
in vec2 v_uv; out vec4 frag;
uniform sampler2D u_tex; uniform vec2 u_texel; uniform float u_amt;
#define R 6
#define QUAD(x0,x1,y0,y1,MO,VO) { vec3 m=vec3(0.0),s=vec3(0.0); \
  for(int j=y0;j<=y1;j++){ for(int i=x0;i<=x1;i++){ \
    vec3 c=texture(u_tex, v_uv+vec2(float(i),float(j))*u_texel).rgb; m+=c; s+=c*c; } } \
  float n=float((x1-x0+1)*(y1-y0+1)); m/=n; vec3 vv=s/n-m*m; MO=m; VO=vv.r+vv.g+vv.b; }
vec3 hueRotate(vec3 c, float a){
  float co=cos(a), si=sin(a);
  return vec3(
    c.r*(0.299+0.701*co+0.168*si) + c.g*(0.587-0.587*co+0.330*si) + c.b*(0.114-0.114*co-0.497*si),
    c.r*(0.299-0.299*co-0.328*si) + c.g*(0.587+0.413*co+0.035*si) + c.b*(0.114-0.114*co+0.292*si),
    c.r*(0.299-0.300*co+1.250*si) + c.g*(0.587-0.588*co-1.050*si) + c.b*(0.114+0.886*co-0.203*si));
}
void main(){
  vec3 base = texture(u_tex, v_uv).rgb;
  if (u_amt <= 0.001) { frag = vec4(base, 1.0); return; }
  vec3 m0,m1,m2,m3; float v0,v1,v2,v3;
  QUAD(-R,0,-R,0,m0,v0) QUAD(0,R,-R,0,m1,v1) QUAD(-R,0,0,R,m2,v2) QUAD(0,R,0,R,m3,v3)
  vec3 painted=m0; float mv=v0;
  if(v1<mv){mv=v1;painted=m1;} if(v2<mv){mv=v2;painted=m2;} if(v3<mv){mv=v3;painted=m3;}
  vec3 styl = mix(base, painted, u_amt);                 // painterly restyle, linear in the knob
  // The "trippy" terms ramp with t = amt^2, so low Right stays gently dreamy and
  // only MAX goes psychedelic: vivid saturation, a surreal hue drift, poster banding.
  float t = u_amt * u_amt;
  float luma = dot(styl, vec3(0.299, 0.587, 0.114));
  styl = mix(vec3(luma), styl, 1.0 + 1.1 * t);           // saturation: vivid toward max
  styl = hueRotate(styl, 0.8 * t);                       // surreal hue drift (~45deg at max)
  float levels = mix(255.0, 6.0, t);                     // posterize: color banding toward max
  styl = floor(styl * levels + 0.5) / levels;
  styl *= 1.0 + 0.06 * u_amt;                            // luminous lift
  frag = vec4(clamp(styl, 0.0, 1.0), 1.0);
}`;
let gl = null, uAmt = null, uTexel = null;
function _shader(kind, src) {
  const s = gl.createShader(kind);
  gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  return s;
}
function initPaint() {
  gl = paint.getContext("webgl2");
  if (!gl) { paint.style.display = "none"; return false; }  // CSS fallback grades #vid
  const prog = gl.createProgram();
  gl.attachShader(prog, _shader(gl.VERTEX_SHADER, PAINT_VS));
  gl.attachShader(prog, _shader(gl.FRAGMENT_SHADER, PAINT_FS));
  gl.bindAttribLocation(prog, 0, "p"); gl.linkProgram(prog);
  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
  gl.useProgram(prog);
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
  const t = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, t);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  uAmt = gl.getUniformLocation(prog, "u_amt");
  uTexel = gl.getUniformLocation(prog, "u_texel");
  paintOK = true;
  requestAnimationFrame(paintLoop);
  return true;
}
function paintLoop() {
  const src = displayVid();          // morph while scrubbing, else the preloaded base loop
  if (paintOK && src.readyState >= 2 && src.videoWidth) {
    const w = src.videoWidth, h = src.videoHeight;
    if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
    gl.viewport(0, 0, w, h);
    try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
    // No painterly restyle during a ring transition (busy) — show it raw.
    gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
    gl.uniform2f(uTexel, 1 / w, 1 / h);
    gl.drawArrays(gl.TRIANGLES, 0, 3);
    paint.style.filter = busy ? "none" : gradeFilter;
  }
  // Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
  // labels (a label enters/leaves frame as the loop plays) against playback time.
  const c = activeClip();
  if (lastOverlay && lastOverlay.level > 0 && c &&
      c.annotations.some((a) => (a.track && a.track.length) ||
                                typeof a.appear === "number" || typeof a.disappear === "number")) {
    renderOverlay(lastOverlay.level, lastOverlay.intensity);
  }
  requestAnimationFrame(paintLoop);
}

const SVGNS = "http://www.w3.org/2000/svg";
function svg(tag, attrs, parent) {
  const el = document.createElementNS(SVGNS, tag);
  for (const k in attrs) el.setAttribute(k, attrs[k]);
  if (parent) parent.appendChild(el);
  return el;
}

// Deterministic per-key "confidence" in [0.78, 0.99] — stable across renders so
// a detection always reports the same score (looks like a real model, not noise).
function confidence(key) {
  let h = 0;
  for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0;
  return (0.78 + (h % 2200) / 10000).toFixed(2);
}

// Four L-shaped corner brackets framing [x,y,w,h] — a targeting reticle rather
// than a plain rectangle. Arm length scales to the box but is clamped legible.
function reticle(x, y, w, h, cls, parent) {
  const L = Math.min(Math.max(Math.min(w, h) * 0.28, 1.4), 4);
  const corners = [
    [[x + L, y], [x, y], [x, y + L]],
    [[x + w - L, y], [x + w, y], [x + w, y + L]],
    [[x + L, y + h], [x, y + h], [x, y + h - L]],
    [[x + w - L, y + h], [x + w, y + h], [x + w, y + h - L]],
  ];
  for (const pts of corners) {
    svg("polyline", { points: pts.map((p) => p.join(",")).join(" "), class: cls }, parent);
  }
}

// A label chip: translucent plate + monospace text, anchored top-left of the box.
// `conf` (detections only) is appended as a dimmer score tspan.
function chip(x, y, label, conf, kind, parent) {
  const fs = 2.4, pad = 0.6;
  const chars = label.length + (conf ? conf.length + 1 : 0);
  const w = chars * fs * 0.6 + pad * 2, h = fs + pad * 1.4;
  const cy = Math.max(y - h - 0.6, 0.4);
  svg("rect", { x, y: cy, width: w, height: h, rx: 0.4, class: "hud-chip-bg " + kind }, parent);
  const t = svg("text", { x: x + pad, y: cy + h - pad * 1.1, class: "hud-chip-text " + kind }, parent);
  t.appendChild(document.createTextNode(label));
  if (conf) {
    const sp = svg("tspan", { class: "hud-conf" }, t);
    sp.textContent = " " + conf;
  }
}

// Interpolate an annotation's box at loop-normalized time t (0..1). Static labels
// (no track) return their fixed box. Linear between sorted keyframes; clamps to ends.
function boxAt(a, t) {
  if (!a.track || !a.track.length) return a.box;
  const ks = a.track;
  if (t <= ks[0].t) return ks[0].box;
  if (t >= ks[ks.length - 1].t) return ks[ks.length - 1].box;
  for (let i = 1; i < ks.length; i++) {
    if (t <= ks[i].t) {
      const a0 = ks[i - 1], a1 = ks[i];
      const f = (t - a0.t) / (a1.t - a0.t);
      return a0.box.map((v, j) => v + (a1.box[j] - v) * f);
    }
  }
  return ks[ks.length - 1].box;
}

// Loop-normalized playback time of the base video (0..1), for track interpolation.
function loopT() {
  const v = displayVid();
  return v && v.duration ? (v.currentTime % v.duration) / v.duration : 0;
}

// --- Progressive labels & emotions (content-pipeline §11.3 / §11.4) ---
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));

// The Left level at which a label first appears. From `salience` (1..4): high
// salience appears early (first_level = 5 - salience), so raising Left brings in
// lower-salience objects. Falls back to a legacy flat `min_level`, else 1.
function firstLevel(a) {
  if (typeof a.salience === "number") return clamp(5 - a.salience, 1, 4);
  if (typeof a.min_level === "number") return a.min_level;
  return 1;
}

// A tiered string escalates with a knob: a LIST is indexed by tier-1 (clamped to
// the available tiers); a plain string is static (tier ignored — back-compat).
function pickTier(value, tier) {
  if (Array.isArray(value)) return value[clamp(tier - 1, 0, value.length - 1)];
  return value;
}
function tierCount(value) { return Array.isArray(value) ? value.length : 1; }

// Time-windowed presence (§11.2): a label with `appear`/`disappear` (loop-norm t)
// shows only while on screen. A `disappear < appear` window wraps across the loop
// seam (visible when t >= appear OR t <= disappear). No window => always present.
function inWindow(a, t) {
  const hasA = typeof a.appear === "number", hasD = typeof a.disappear === "number";
  if (!hasA && !hasD) return true;
  const ap = hasA ? a.appear : 0, dis = hasD ? a.disappear : 1;
  return ap <= dis ? (t >= ap && t <= dis) : (t >= ap || t <= dis);
}

function renderOverlay(level, intensity) {
  const clip = activeClip();
  overlay.innerHTML = "";
  if (!clip || level <= 0) { overlay.style.opacity = "0"; return; }
  overlay.style.opacity = String(intensity);
  const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
  const t = loopT();
  let shown = 0;
  for (const a of clip.annotations) {
    const first = firstLevel(a);
    if (level < first) continue;          // salience gating: low Left = fewer/general labels
    if (!inWindow(a, t)) continue;        // time-windowed: only while the object is on screen
    shown++;
    const [x, y, w, h] = boxAt(a, t).map((n) => n * 100);
    const measure = a.key.startsWith("measure.");
    const kind = measure ? "measure" : "detect";
    reticle(x, y, w, h, "hud-reticle " + kind, overlay);
    if (measure) {
      // Point measurement: a center crosshair tick to read as "sampled here".
      const cx = x + w / 2, cyc = y + h / 2;
      svg("line", { x1: cx - 1, y1: cyc, x2: cx + 1, y2: cyc, class: "hud-reticle measure" }, overlay);
      svg("line", { x1: cx, y1: cyc - 1, x2: cx, y2: cyc + 1, class: "hud-reticle measure" }, overlay);
    }
    // LEFT detail tier: a detection escalates general -> scientific+fact as Left
    // rises above where it first appeared; measurements stay single-tier.
    const raw = strings[a.key];
    const tier = measure ? 1 : clamp(level - first + 1, 1, tierCount(raw));
    const label = pickTier(raw !== undefined ? raw : a.key, tier);
    chip(x, y, label, measure ? null : confidence(a.key), kind, overlay);
  }
  // Global status tag — the machine announcing it is analyzing, escalating with Left.
  // Right-anchored at the frame edge; the plate is sized from the text's real bbox
  // (the ◉/· glyphs are wider than monospace, so an estimate clips).
  if (shown) {
    const pad = 0.8;
    const st = svg("text", { x: 98, y: 5, "text-anchor": "end", class: "hud-status" }, overlay);
    st.textContent = "◉ ANALYSIS · L" + level + " · " + shown + " OBJ";
    const b = st.getBBox();
    const bg = svg("rect", { x: b.x - pad, y: b.y - pad * 0.6, width: b.width + pad * 2,
                             height: b.height + pad * 1.2, rx: 0.4, class: "hud-chip-bg detect" });
    overlay.insertBefore(bg, st);
  }
  lastOverlay = { level, intensity };
}

// Affect channel: soft, glowing emotion-words — a RIGHT-brain output, surfaced by
// the "Feel" (right/dreamlike) knob alone (`strength` = right; the "Think" (left/analytical)
// knob no longer gates them). `intensity` is the layer opacity. Words are placed at authored
// scene points (no boxes — feelings are scene-level) and read softer than the
// clinical reticles — feeling, not measurement.
function renderAffect(strength, intensity, right) {
  lastAffect = { strength, intensity, right };
  const clip = activeClip();
  if (!affectLayer) return;      // tolerate a stale page missing the affect layer
  affectLayer.innerHTML = "";
  if (!clip || !clip.affect || strength <= 0) { affectLayer.style.opacity = "0"; return; }
  affectLayer.style.opacity = String(intensity);
  const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
  for (const f of clip.affect) {
    if (f.min_level > strength) continue;
    const [x, y] = f.at.map((n) => n * 100);
    const t = svg("text", { x, y, "text-anchor": "middle", class: "hud-affect" }, affectLayer);
    // RIGHT emotion tier: the vocabulary escalates basic -> compound as the Right
    // knob rises (appearance is gated by the Right knob via `strength` above).
    const raw = strings[f.key];
    t.textContent = pickTier(raw !== undefined ? raw : f.key, clamp(right || 0, 1, tierCount(raw)));
  }
}

function renderScaleReadout() {
  if (!ring) return;
  const s = ring.scales[ringIndex];
  const poolN = (s.pool && s.pool.length) || 1;
  const member = (activeClip() && activeClip().title) || s.title;
  // scale id · the chosen pool member · position on the ring (pool size if >1)
  const poolTag = poolN > 1 ? ` · pool ${poolN}` : "";
  const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
  $("scale-name").textContent = `${scaleName} · ${member}  (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
  renderDial();
  refreshDevClip();      // keep the Dev Mode pool picker + clip data in sync with the landing
}

function controls() {
  // Video (on/off) and Audio (on/off) are orthogonal toggles (audio spec §2).
  // Audio on = the per-altitude soundtrack (white-noise is deferred). One bipolar
  // Mood dial (-4 dark .. +4 light) maps onto the engine's two poles. Calibration
  // gains are pinned to 1.0, so the simulator sends no calibration.
  const mood = +$("mood").value;
  return {
    visual: $("visual").checked ? "on" : "off",
    audio: audioLevel() > 0 ? "soundtrack" : "off",
    // Back-compat: a server process predating the visual/audio split still requires
    // a 7-way `content` and would 422 the new payload. Send both — the current
    // server ignores `content`, an old one ignores visual/audio.
    content: $("visual").checked ? "video" : "off",
    left: +$("left").value, right: +$("right").value,
    dark: mood < 0 ? -mood : 0, light: mood > 0 ? mood : 0,
    volume: 2, brightness: 2,
  };
}

let timer = null;
async function update() {
  if (busy) return;
  const resp = await fetch("/api/alteration", {
    method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
  });
  if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
  const data = await resp.json();
  readout.textContent = JSON.stringify(data, null, 2);
  // Tolerate a stale server that still returns {content:{video}} instead of the
  // {render:{video:{shown}}} (visual/audio-split) shape, so video works either way.
  const videoShown = data.render ? !!(data.render.video && data.render.video.shown)
                                 : !!(data.content && data.content.video);
  if (!videoShown) {
    // Blank the screen. Cover with #black AND hide the video layers themselves —
    // the <video>/<canvas> are GPU-composited and can otherwise show through the
    // overlay on a real GPU (Safari/Chrome).
    black.style.opacity = "1"; black.classList.remove("hidden");
    vid.style.opacity = "0"; loopVid.style.opacity = "0"; paint.style.opacity = "0";
    return;
  }
  black.classList.add("hidden");
  paint.style.opacity = "";                              // restore the painterly canvas
  showActiveSource();                                    // show the active source (WebGL-less fallback)

  try {
    ensureClipMedia();
    applyVideoLook(data.plan.grade.tone, data.plan.dream.intensity);
    renderOverlay(data.plan.overlay.level, data.plan.overlay.intensity);
    renderAffect(data.plan.affect.strength, data.plan.affect.intensity, data.plan.dream.strength);
    const b = document.getElementById("err-banner");
    if (b) b.remove();           // render succeeded — clear any prior error
  } catch (err) {
    showError("render failed: " + (err && err.message ? err.message : err));
    throw err;
  }
}

function debounced() { clearTimeout(timer); timer = setTimeout(update, 80); }

// --- Scale ring (endless encoder, scrub-driven — see the scrub engine below) ---

// Per-altitude duration of an AUTO transition (wheel / label-click). The total time
// is PROPORTIONAL to the distance, so the speed between any two adjacent altitudes is
// the same — a 2-altitude jump takes twice as long as a 1-altitude one. (A manual
// drag is exempt: the operator drives its speed directly.)
const PER_ALTITUDE_MS = 1200;

// Animate `pos` to a target at a constant per-altitude speed, driving the same scrub
// path as a drag, then land exactly on the integer target (frac 0 settles + locks).
let autoRaf = 0;
function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
  if (!ring || ring.scales.length < 2) return;
  if (autoRaf) cancelAnimationFrame(autoRaf);
  const fromPos = pos, dist = targetPos - fromPos;
  if (!dist) { setPos(targetPos); return; }
  const ms = perAltMs * Math.abs(dist);          // constant per-altitude speed → N steps take N× as long
  let startTs = null;
  const tick = (ts) => {
    if (startTs === null) startTs = ts;
    const k = Math.min(1, (ts - startTs) / ms);
    setPos(fromPos + dist * k);
    if (k < 1) autoRaf = requestAnimationFrame(tick);
    else { autoRaf = 0; setPos(targetPos); }     // land exactly + lock (frac 0)
  };
  autoRaf = requestAnimationFrame(tick);
}

let wheelAccum = 0, wheelTimer = null;
function onWheel(e) {
  e.preventDefault();
  wheelAccum += e.deltaY;
  clearTimeout(wheelTimer);
  wheelTimer = setTimeout(() => {
    const detents = Math.trunc(wheelAccum / 60) || (wheelAccum > 0 ? 1 : -1);
    wheelAccum = 0;
    if (detents) autoScrub(Math.round(pos) + detents);   // wheel down = descend (+); auto-scrub then lock
  }, 90);
}

// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the
// top — the same endless ring the server already models. The drag accumulates a
// rotation and commits whole detents on release (so a big spin becomes one fast
// blended pass, exactly like the wheel); a tap on a label jumps to that scale.
const dial = $("dial");
const DIAL_C = 50, DIAL_LABEL_R = 41, DIAL_BODY_R = 27;
let needleDeg = 0;
let dialDrag = null;          // {lastAng, accum, moved, restPos, target} while turning

// --- Scrub engine state: the knob IS a continuous position ---
let pos = 0;                  // continuous knob position; rest value == ringIndex
let activeSeg = null;         // { lo, clipLo, clipHi, file } for the segment being scrubbed
let seekPending = false;      // throttle: at most one currentTime seek per animation frame
let scrubDir = 1;             // last travel direction (+1 descend / -1 ascend) — which end we'll land on

// Diagnostic seam (sibling of window.__hefMorphs): the committed altitude state, so
// E2E can assert the locked clip mid-scrub when #scale-name (settle-only) is stale.
if (typeof window !== "undefined") window.__hefState = () => ({ ringIndex, activeClipId, pos });

function dialStep() { return ring && ring.scales.length ? 360 / ring.scales.length : 360; }

function _xy(r, deg) {
  const a = (deg - 90) * Math.PI / 180;        // -90° so 0° points UP
  return [DIAL_C + r * Math.cos(a), DIAL_C + r * Math.sin(a)];
}

function buildDial() {
  if (!dial || !ring) return;
  dial.innerHTML = "";
  const n = ring.scales.length, step = 360 / n;
  svg("circle", { cx: DIAL_C, cy: DIAL_C, r: DIAL_LABEL_R + 6, class: "dial-rim" }, dial);
  svg("circle", { cx: DIAL_C, cy: DIAL_C, r: DIAL_BODY_R, class: "dial-body" }, dial);
  for (let i = 0; i < n; i++) {
    const deg = i * step;
    const [tx0, ty0] = _xy(DIAL_BODY_R - 1.5, deg);
    const [tx1, ty1] = _xy(DIAL_BODY_R + 2.5, deg);
    svg("line", { x1: tx0, y1: ty0, x2: tx1, y2: ty1, class: "dial-tick" }, dial);
    const [lx, ly] = _xy(DIAL_LABEL_R, deg);
    const t = svg("text", {
      x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
      class: "dial-label", "data-index": String(i),
    }, dial);
    t.textContent = ring.scales[i].id;
  }
  svg("text", { x: DIAL_C, y: DIAL_C - 7, "text-anchor": "middle",
                "dominant-baseline": "central", class: "dial-caption" }, dial)
    .textContent = "ALTITUDE";
  const needle = svg("g", { id: "needle", class: "dial-needle" }, dial);
  const tip = DIAL_C - (DIAL_BODY_R - 4);
  svg("polygon", { points: `${DIAL_C - 2.4},${DIAL_C} ${DIAL_C + 2.4},${DIAL_C} ${DIAL_C},${tip}` }, needle);
  svg("circle", { cx: DIAL_C, cy: DIAL_C, r: 3, class: "dial-hub" }, dial);
  renderDial();
}

function setNeedle(deg) {
  const needle = $("needle");
  if (needle) needle.setAttribute("transform", `rotate(${deg} ${DIAL_C} ${DIAL_C})`);
}

function renderDial() {
  if (!dial || !ring) return;
  needleDeg = ringIndex * dialStep();
  setNeedle(needleDeg);
  for (const el of dial.querySelectorAll(".dial-label")) {
    el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
  }
}

function dialAngle(e) {
  const r = dial.getBoundingClientRect();
  const dx = e.clientX - (r.left + r.width / 2);
  const dy = e.clientY - (r.top + r.height / 2);
  return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;   // 0 at top, clockwise +
}
function angDelta(a, b) {
  let d = a - b;
  while (d > 180) d -= 360;
  while (d < -180) d += 360;
  return d;
}

// A random pool member's clip_id for ring scale `index` (wrapped) — the client-side
// destination pick (was the server's job in /api/ring/advance; moved here so the
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
// canonical pure helper for the Pi player).
function pickPoolClip(index) {
  const n = ring.scales.length;
  const s = ring.scales[HEFScrub.wrapIndex(index, n)];
  const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
  return pool[Math.floor(Math.random() * pool.length)].clip_id;
}

// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
// (`enteredFrom`) keeps the locked clip; the OTHER end is a fresh random pick
// (re-roll on fresh approach). The canonical file is always the descend/forward
// morph clip@lo -> clip@lo+1, scrubbed bidirectionally (turn-back seeks it backward).
function rebuildSegment(lo, enteredFrom) {
  const n = ring.scales.length;
  const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
  const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
  const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
  const file = morphByPair[`${loId}→${hiId}`] || null;
  activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
  if (file) {
    overlay.style.opacity = "0";
    affectLayer.style.opacity = "0";
    tint.style.opacity = "0";
    vid.style.filter = "none";
    vid.loop = false;
    if (vid.dataset.morph !== file) {
      vid.dataset.morph = file;
      vid.src = mediaUrl(file);
      vid.pause();
    }
    // Diagnostic seam (matches advance()'s old record): the logical morph path
    // played, reliable even when served from an in-memory blob.
    (window.__hefMorphs || (window.__hefMorphs = [])).push(file);
  }
}

// Drive the continuous position. Commits every integer crossed since the last
// position (lock + re-roll), then either settles on an altitude (frac 0) or scrubs
// the active segment's morph + audio by the fraction.
function setPos(next) {
  if (!ring || ring.scales.length < 2) return;
  const n = ring.scales.length;
  const dir = next > pos ? 1 : (next < pos ? -1 : scrubDir);   // travel direction (for which end we'll land on)
  scrubDir = dir;
  for (const c of HEFScrub.integerCrossings(pos, next)) {
    ringIndex = HEFScrub.wrapIndex(c.index, n);
    if (activeSeg) {
      activeClipId = (HEFScrub.wrapIndex(activeSeg.lo, n) === ringIndex) ? activeSeg.clipLo : activeSeg.clipHi;
    }
    activeSeg = null;          // crossing ends the segment → next entry is a fresh approach (re-roll)
  }
  pos = next;
  const { lo, frac } = HEFScrub.segmentOf(pos);
  if (frac === 0) {            // settled exactly on an altitude → lock + base loop + single soundtrack
    busy = false;
    delete vid.dataset.morph;  // clear so re-entering the same segment reloads the morph (not the base)
    currentClipId = null;      // force ensureClipMedia to reload + loop the locked clip
    playLoop();                // the loop element was preloaded to this clip @ tail during the scrub → instant, no reload
    showActiveSource();
    setNeedle(ringIndex * dialStep());
    renderScaleReadout();
    update();                       // ensureClipMedia: idempotent loadLoop + keep the loop running
    restAudio(ringIndex);
    refreshReachablePreload();      // warm the morphs now reachable from the locked clip
    return;
  }
  busy = true;                 // mid-morph: block update() from reloading the base clip under us
  if (!activeSeg || activeSeg.lo !== lo) rebuildSegment(lo, ringIndex);
  // Preload the clip we're heading toward into the LOOP element (paused @ tail) so the
  // landing is a swap, not a reload+seek. dir>0 lands on clipHi, dir<0 on clipLo.
  const headingId = dir > 0 ? activeSeg.clipHi : activeSeg.clipLo;
  loadLoop(clipsById[headingId]);
  showActiveSource();          // morph element is the live source while scrubbing
  setNeedle(pos * dialStep());
  blendAudio(lo, frac);
  if (activeSeg.file && !seekPending) {       // throttle seeks to one per frame (avoid seek thrash)
    seekPending = true;
    requestAnimationFrame(() => {
      seekPending = false;
      if (vid.dataset.morph === activeSeg.file) vid.currentTime = HEFScrub.fracToTime(frac, vid.duration);
    });
  }
}

function onDialDown(e) {
  if (!ring || ring.scales.length < 2) return;
  e.preventDefault();
  dialDrag = { lastAng: dialAngle(e), accum: 0, moved: 0, target: e.target, restPos: pos };
}
function onDialMove(e) {
  if (!dialDrag) return;
  const a = dialAngle(e);
  const d = angDelta(a, dialDrag.lastAng);
  dialDrag.lastAng = a;
  dialDrag.accum += d;
  dialDrag.moved += Math.abs(d);
  setPos(HEFScrub.accumToPos(dialDrag.restPos, dialDrag.accum, dialStep()));   // LIVE scrub: knob position IS the transition
}
function onDialUp(e) {
  if (!dialDrag) return;
  const { moved, target } = dialDrag;
  dialDrag = null;
  if (moved < 6) {                                        // a tap, not a turn
    if (target && target.classList && target.classList.contains("dial-label")) {
      jumpToScale(+target.getAttribute("data-index"));
    }
    return;
  }
  // No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
}

// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
function jumpToScale(idx) {
  if (!ring) return;
  const n = ring.scales.length;
  let d = (idx - ringIndex) % n;
  if (d > n / 2) d -= n;
  if (d < -n / 2) d += n;
  if (d) autoScrub(Math.round(pos) + d);
}

// --- Dev Mode: pool picker + live analysis, all under one toggle ---
// Off by default, state persisted in localStorage. Everything here is read from
// data the renderer already has (clips, ring, the alteration response, the preload
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
let devMode = false;
let devStatsTimer = null;

const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));

function applyDevVisibility() {
  const panel = $("dev-panel");
  if (panel) panel.classList.toggle("hidden", !devMode);
  if (devMode) { refreshDevClip(); renderDevStats(); }
}

function initDev() {
  const toggle = $("dev-mode");
  if (!toggle) return;
  try { devMode = localStorage.getItem(DEV_KEY) === "1"; } catch (_) { devMode = false; }
  toggle.checked = devMode;
  toggle.addEventListener("change", () => {
    devMode = toggle.checked;
    try { localStorage.setItem(DEV_KEY, devMode ? "1" : "0"); } catch (_) {}
    applyDevVisibility();
  });
  $("pool-select").addEventListener("change", onPoolSelect);
  $("pool-reroll").addEventListener("click", reroll);
  // Cache + playback stats are live-ish; cheap to repaint twice a second when shown.
  devStatsTimer = setInterval(() => { if (devMode) renderDevStats(); }, 500);
  applyDevVisibility();
}

// Rebuild the pool picker for the current scale and repaint the clip data. Cheap
// (pools are <= a handful), so just rebuild on every landing/pick.
function refreshDevClip() {
  if (!devMode) return;
  const sel = $("pool-select");
  const scale = ring && ring.scales[ringIndex];
  if (sel && scale) {
    const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id, title: scale.title }];
    sel.innerHTML = "";
    for (const m of pool) {
      const o = document.createElement("option");
      o.value = m.clip_id;
      o.textContent = `${m.clip_id} · ${m.title || (clipsById[m.clip_id] || {}).title || ""}`;
      sel.appendChild(o);
    }
    if (activeClipId) sel.value = activeClipId;
    sel.disabled = pool.length < 2;
  }
  renderDevMeta();
  renderDevAnno();
}

function renderDevMeta() {
  const dl = $("clip-meta");
  const c = activeClip();
  if (!dl) return;
  if (!c) { dl.innerHTML = "<dt>—</dt><dd>no clip</dd>"; return; }
  const rows = [["id", c.id], ["title", c.title], ["source", c.source],
               ["license", c.license], ["base_file", c.base_file]];
  dl.innerHTML = rows.map(([k, v]) =>
    `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v != null ? v : "—")}</dd>`).join("");
}

function renderDevAnno() {
  const box = $("clip-anno");
  const c = activeClip();
  if (!box) return;
  const strings = (c && c.strings && c.strings.en) || {};
  const win = (a) => (typeof a.appear === "number" || typeof a.disappear === "number")
    ? ` win ${(a.appear ?? 0).toFixed(2)}–${(a.disappear ?? 1).toFixed(2)}` : "";
  const rows = [];
  for (const a of (c && c.annotations) || []) {
    const measure = a.key.startsWith("measure.");
    const sal = typeof a.salience === "number" ? `sal ${a.salience}`
              : typeof a.min_level === "number" ? `min L${a.min_level}` : "sal 1";
    const tiers = tierCount(strings[a.key]);
    rows.push(`<div class="anno-row"><span class="anno-key ${measure ? "measure" : ""}">${escapeHtml(a.key)}</span>` +
      `<span class="anno-meta"> · ${sal} · ${tiers} tier${tiers > 1 ? "s" : ""}${win(a)}</span></div>`);
  }
  for (const f of (c && c.affect) || []) {
    const tiers = tierCount(strings[f.key]);
    rows.push(`<div class="anno-row"><span class="anno-key affect">${escapeHtml(f.key)}</span>` +
      `<span class="anno-meta"> · min L${f.min_level ?? 1} · ${tiers} tier${tiers > 1 ? "s" : ""}</span></div>`);
  }
  box.innerHTML = rows.length ? rows.join("") : `<div class="anno-empty">no annotations or affect</div>`;
}

function renderDevStats() {
  const dl = $("dev-stats");
  if (!dl) return;
  const c = activeClip();
  const total = preloadList().length;
  const cached = Object.keys(mediaBlobs).length;
  const fromMem = !!(c && mediaBlobs[c.base_file]);
  const dv = displayVid();
  const res = dv.videoWidth ? `${dv.videoWidth}×${dv.videoHeight}` : "—";
  const dur = dv.duration ? `${dv.duration.toFixed(1)}s` : "—";
  const loop = vid.duration ? `${Math.round(loopT() * 100)}%` : "—";
  dl.innerHTML =
    `<dt>cache</dt><dd>${cached}/${total} cached</dd>` +
    `<dt>active source</dt><dd class="${fromMem ? "mem" : "net"}">${fromMem ? "memory (blob)" : "network"}</dd>` +
    `<dt>resolution</dt><dd>${res}</dd>` +
    `<dt>duration</dt><dd>${dur}</dd>` +
    `<dt>loop pos</dt><dd>${loop}</dd>`;
}

// Pick a specific pool member (Dev Mode override of the random landing pick).
function onPoolSelect() {
  const id = $("pool-select").value;
  if (!id || id === activeClipId) return;
  activeClipId = id;
  currentClipId = null;          // force the base media to reload
  renderScaleReadout();          // refresh scale name + dev clip data
  update();                      // reload media + plan
}

// Re-roll: a fresh server-canonical random pick for the current scale.
async function reroll() {
  if (busy) return;
  const id = await pickRandomMember();
  if (!id) return;
  activeClipId = id;
  currentClipId = null;
  renderScaleReadout();
  update();
}

// Dev live-reload: poll the asset version and reload when it changes, so an open
// tab never keeps running a stale renderer while we iterate (the readout updates
// from the live API and masks it otherwise). Reloads on my edits AND on a server
// restart. Dev-only convenience; harmless if the endpoint is absent.
function devLiveReload() {
  let seen = null;
  setInterval(async () => {
    try {
      const v = (await (await fetch("/dev/version", { cache: "no-store" })).json()).version;
      if (seen === null) seen = v;
      else if (v !== seen) location.reload();
    } catch (_) { /* server restarting / endpoint absent — ignore */ }
  }, 1000);
}

// --- Audio layer: ONE <audio> element, started in-gesture (Safari-safe) ---
// Real Safari/iOS only "unlocks" an <audio> element when play() is called
// SYNCHRONOUSLY inside a user gesture (Chrome is lenient; Safari is not). So we use
// a single element and start it directly from the Video/Audio toggle's click — not
// via the async server roundtrip; once unlocked it can be re-played programmatically
// (e.g. on an altitude change). audio on = the current scale's soundtrack.
const aud = $("aud");
const audB = $("aud-b");                 // second element: the two crossfade by scrub fraction
let audLastErr = "";                    // last play() rejection / element error (for the readout)
const FADE_MS = 500;

// Audio is a 0–10 level dial (was an on/off toggle). 0 = silent. The level is the
// MASTER gain the per-scale soundtrack / crossfade is scaled by.
function audioLevel() { return +$("audio").value || 0; }
function audioVol() { return HEFScrub.clamp01(audioLevel() / 10); }
function updateAudioLevelLabel() {
  const el = $("audio-level-val");
  if (el) el.textContent = String(audioLevel());
}

// The element currently carrying the most signal — for the diagnostic readout,
// which used to assume a single `aud`. With two crossfading elements either may be
// the audible one, so report on whichever is louder (ties → aud).
function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }

// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
function audioStatusText() {
  if (audioLevel() === 0) return "audio: off (level 0)";
  const s = ring && ring.scales[ringIndex];
  const url = soundtrackUrl();
  const el = activeAudEl();
  const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
  if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
  if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`;
  if (audLastErr) return `audio: ${head} · ${audLastErr}`;
  if (el.paused) return `audio: ${head} · PAUSED rs=${el.readyState}`;
  return `audio: ${head} · ▶ ${el.currentTime.toFixed(1)}s vol=${Math.round(el.volume * 100)}% rs=${el.readyState}`;
}
function updateAudioStatus() {
  const el = $("audio-status");
  if (!el) return;
  el.textContent = audioStatusText();
  const a = activeAudEl();
  const on = audioLevel() > 0;
  const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
  el.classList.toggle("playing", playing);
  el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr));
}
aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
setInterval(updateAudioStatus, 400);

// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
// if a stale one keeps running after a new fade or a direct volume set, it drags the
// element back toward its old target (e.g. a fade-to-0 on what becomes the ACTIVE
// element silences it — the "audio off after landing" bug).
function cancelFade(el) {
  if (el._fadeIv) { clearInterval(el._fadeIv); el._fadeIv = null; }
}

function fadeVolume(el, to, ms, done) {
  // setInterval (not requestAnimationFrame): rAF throttles/pauses when the tab is
  // backgrounded, which would stall the gain ramp; a timer fires regardless.
  cancelFade(el);                       // supersede any fade already running on this element
  const from = el.volume, steps = Math.max(1, Math.round(ms / 30));
  let i = 0;
  const iv = setInterval(() => {
    i += 1;
    const k = Math.min(1, i / steps);
    el.volume = Math.min(1, Math.max(0, from + (to - from) * k));
    if (k >= 1) { clearInterval(iv); el._fadeIv = null; if (done) done(); }
  }, 30);
  el._fadeIv = iv;
}

// Diagnostic seam (sibling of window.__hefState): expose the fade so E2E can verify a
// new fade supersedes a stale one rather than racing it.
if (typeof window !== "undefined") window.__hefFade = fadeVolume;

// Fallback per-scale soundtrack paths, used when the ring from /api/ring lacks the
// `audio` field (e.g. a server process started before the audio field existed).
// Mirrors simulator/build_pool_manifest.py SCALE_AUDIO — the 5 scales + files are
// fixed, committed facts, so this keeps audio working without a server restart.
const SCALE_AUDIO_FALLBACK = {
  cosmos: "cosmos/pillars.loop.mp3",
  orbit: "orbit/spaceamb.loop.mp3",
  coast: "coast/waves.loop.mp3",
  reef: "reef/soundscape.loop.mp3",
  abyss: "abyss/whale.loop.mp3",
};

// The soundtrack url for ring scale `index` (wrapped): the ring's `audio` field,
// else the scale-id fallback above. `null` when there is no ring/scale.
function scaleAudioUrl(index) {
  if (!ring || !ring.scales.length) return null;
  const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
  const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
  return a ? "/media/audio/" + a : null;
}
// The current resting altitude's soundtrack url (kept for the status readout).
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }

// Start (once) and set the volume of one element to a url. Safari needs the first
// play() inside a user gesture; later programmatic plays reuse the unlocked element.
// Only (re)loads `src` when the url actually changes (so a same-url call is a no-op
// seek-wise and doesn't restart the loop).
function ensurePlaying(el, url, vol) {
  if (!url) { el.volume = 0; if (!el.paused) el.pause(); return; }
  if (el.dataset.url !== url) {
    el.dataset.url = url;
    el.src = mediaUrl(url);
  }
  if (el.paused) {
    audLastErr = "";
    const pr = el.play();
    if (pr) pr.then(() => { audLastErr = ""; updateAudioStatus(); })
            .catch((e) => { audLastErr = "play BLOCKED: " + ((e && e.name) || e); updateAudioStatus(); });
  }
  cancelFade(el);                       // a direct volume set supersedes any in-flight fade on this element
  el.volume = HEFScrub.clamp01(vol);
}

// Pick which physical element carries each of the two needed urls, reusing the
// element that already plays a url so a crossing reloads only the genuinely-new
// scale (no restart click on the scale that persists across the boundary).
function assignElements(urlLo, urlHi) {
  if (aud.dataset.url === urlLo) return { elLo: aud, elHi: audB };
  if (audB.dataset.url === urlLo) return { elLo: audB, elHi: aud };
  if (aud.dataset.url === urlHi) return { elLo: audB, elHi: aud };
  if (audB.dataset.url === urlHi) return { elLo: aud, elHi: audB };
  return { elLo: aud, elHi: audB };
}

// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
// both scaled by the master audio level. No-op when the level is 0.
function blendAudio(loIndex, frac) {
  const lvl = audioVol();
  if (lvl === 0) return;
  const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
  const g = HEFScrub.crossfadeGains(frac);
  const { elLo, elHi } = assignElements(urlLo, urlHi);
  ensurePlaying(elLo, urlLo, g.from * lvl);
  ensurePlaying(elHi, urlHi, g.to * lvl);
  updateAudioStatus();
}

// At rest on a single altitude: the element holding it plays at the master level, the
// other fades out. Level 0 → both fade to silence.
function restAudio(index) {
  if (audioLevel() === 0) {
    fadeVolume(aud, 0, FADE_MS, () => aud.pause());
    fadeVolume(audB, 0, FADE_MS, () => audB.pause());
    return;
  }
  const url = scaleAudioUrl(index);
  const active = (audB.dataset.url === url) ? audB : aud;
  const idle = active === audB ? aud : audB;
  ensurePlaying(active, url, audioVol());
  fadeVolume(idle, 0, FADE_MS);
  updateAudioStatus();
}

// Reconcile audio to the Audio toggle + current resting altitude. MUST be called
// synchronously from the toggle gesture the first time it plays (Safari unlock).
function applyAudio() { restAudio(ringIndex); }

// "Loading Universe…" splash — hidden once media is preloaded; reflects progress.
function setLoadingProgress(done, total) {
  const fill = document.getElementById("loading-fill");
  if (fill && total) fill.style.width = Math.round((done / total) * 100) + "%";
}
function hideLoading() {
  const el = document.getElementById("loading");
  if (!el) return;
  el.classList.add("done");
  setTimeout(() => el.remove(), 700);
}

let videoEverOn = false;      // has Video ever been switched on this session?

async function main() {
  devLiveReload();
  try { initPaint(); } catch (e) { paintOK = false; paint.style.display = "none"; showError("WebGL init: " + e.message); }
  await loadData();
  await landScale();          // pick the initial scale's pool member before first render
  buildDial();                // draw the altitude knob from the ring's scales
  initDev();                  // wire the Dev Mode toggle + pool picker (reads persisted state)
  initLanguage();             // populate the language dropdown + wire live switching
  renderScaleReadout();
  // Sliders stream on "input"; the toggles fire on "change".
  for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
  // Audio level dial: set the master soundtrack gain. Reconcile SYNCHRONOUSLY in this
  // input gesture so the first raise from 0 unlocks + plays on Safari (which blocks
  // play() outside a user gesture).
  $("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
  updateAudioLevelLabel();
  // Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
  // flip = the full experience at a gentle level). If audio is already raised, leave it.
  // Set + played in THIS gesture so it unlocks on Safari.
  $("visual").addEventListener("change", () => {
    if ($("visual").checked && !videoEverOn) {
      videoEverOn = true;
      if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
    }
    debounced();
  });
  // Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
  dial.addEventListener("pointerdown", onDialDown);
  window.addEventListener("pointermove", onDialMove);
  window.addEventListener("pointerup", onDialUp);
  dial.addEventListener("wheel", onWheel, { passive: false });
  $("stage").addEventListener("wheel", onWheel, { passive: false });
  update();                   // render the initial state (both toggles off → black, silent)
  await preloadAllMedia();    // download all media, updating the loading bar
  hideLoading();              // experience is ready — boots SILENT (video off, audio 0)
  // No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
  // outside a user gesture (muted video survives, sound does not), so an auto-start
  // would show video but stay silent. Instead the experience waits for the operator to
  // turn Video on — that click IS the gesture, and its handler (above) lifts audio to
  // 3/10 and starts the soundtrack in-gesture, so sound reliably comes up with video.
}

// Populate the language dropdown from the registry and wire live switching.
// English default, session-only (no persistence). Switching swaps UI chrome
// and re-renders the current annotations/affect in place — no reload, no fetch.
function initLanguage() {
  const sel = $("lang-select");
  if (!sel) return;
  for (const { code, nativeName } of HEFi18n.LANGUAGES) {
    const o = document.createElement("option");
    o.value = code;
    o.textContent = nativeName;
    sel.appendChild(o);
  }
  sel.value = activeLang;
  applyUiStrings(activeLang);
  sel.addEventListener("change", () => setLanguage(sel.value));
}

// Fill every [data-i18n] node with its catalog string for `lang`.
function applyUiStrings(lang) {
  document.documentElement.lang = lang;
  for (const el of document.querySelectorAll("[data-i18n]")) {
    el.textContent = HEFi18n.pickUiString(el.getAttribute("data-i18n"), lang);
  }
}

function setLanguage(lang) {
  activeLang = lang;
  applyUiStrings(lang);
  renderScaleReadout();
  if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
  if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
}

main();
