/* global React */
const { useEffect, useRef, useState, useLayoutEffect } = React;

// =============================================================
// Title strip — Share Tech Mono, STEPWISE marquee when overflowing
// =============================================================
function StubTitle({ text, stepSize = 0.5, stepIntervalMs = 120 }) {
  const wrapRef = useRef(null);
  const innerRef = useRef(null);
  const [maxOffsetPx, setMaxOffsetPx] = useState(0);
  const [stepPx, setStepPx] = useState(8);
  const [offset, setOffset] = useState(0);

  useLayoutEffect(() => {
    const wrap = wrapRef.current;
    const inner = innerRef.current;
    if (!wrap || !inner) return;
    inner.style.transform = "translateX(0)";
    const overflow = inner.scrollWidth - wrap.clientWidth;
    if (overflow > 4) {
      setMaxOffsetPx(overflow + 40);
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;visibility:hidden;font:inherit;letter-spacing:inherit;";
      probe.textContent = "M";
      inner.appendChild(probe);
      const charPx = probe.getBoundingClientRect().width || 14;
      inner.removeChild(probe);
      setStepPx(Math.max(1, Math.round(charPx * stepSize)));
    } else {
      setMaxOffsetPx(0);
      setStepPx(8);
    }
    setOffset(0);
  }, [text, stepSize]);

  useEffect(() => {
    if (maxOffsetPx <= 0) { setOffset(0); return; }
    let timeoutId;
    let cancelled = false;
    const tick = () => {
      if (cancelled) return;
      setOffset((prev) => {
        if (prev >= maxOffsetPx) {
          timeoutId = setTimeout(() => {
            if (cancelled) return;
            setOffset(0);
            timeoutId = setTimeout(tick, 800);
          }, 1400);
          return prev;
        }
        timeoutId = setTimeout(tick, stepIntervalMs);
        return Math.min(maxOffsetPx, prev + stepPx);
      });
    };
    timeoutId = setTimeout(tick, 800);
    return () => { cancelled = true; clearTimeout(timeoutId); };
  }, [maxOffsetPx, stepPx, stepIntervalMs]);

  return (
    <div
      ref={wrapRef}
      style={{
        position: "absolute", inset: 0,
        display: "flex", alignItems: "center",
        padding: "0 4%",
        overflow: "hidden",
        fontFamily: '"Share Tech Mono", ui-monospace, monospace',
        fontSize: "clamp(14px, 4cqw, 25px)",
        letterSpacing: "0.05em",
        color: "#ff879a",
        textShadow: "0 0 6px rgba(255,135,154,0.5)",
        containerType: "inline-size",
      }}
    >
      <div
        ref={innerRef}
        style={{
          whiteSpace: "nowrap",
          display: "inline-block",
          transform: `translateX(${-offset}px)`,
        }}
      >
        {text}
      </div>
    </div>
  );
}

// =============================================================
// LandingMusic — the landing theme, synthesized from the notes JSON
// (generated by scripts/midi-to-notes.mjs: absolute seconds with the
// source MIDI's rubato baked in). Tone.js (vendor/Tone.js) drives TWO
// blendable PolySynth voices playing the same notes — voice 2 can be
// transposed/detuned against voice 1 — through a shared lowpass filter
// and Freeverb (chosen over convolution Reverb: every knob moves live,
// no async impulse rebuild). All sound-design params stream in from the
// tweaks panel and apply mid-playback; the baked TWEAK_DEFAULTS are
// what production plays. Browsers refuse audio before a user gesture,
// so playback arms on mount and starts at the first pointer/key press.
// Unmount (GO pressed, session lost → relanding) fades out and
// disposes. Every entry point no-ops if Tone.js isn't loaded.
// =============================================================
const MUSIC_NOTES_URL = "assets/troubble4.notes.json";
let __musicNotesPromise = null;
function loadMusicNotes() {
  if (!__musicNotesPromise) {
    __musicNotesPromise = fetch(MUSIC_NOTES_URL)
      .then((r) => (r.ok ? r.json() : null))
      .catch(() => null)
      .then((d) => {
        // A failed fetch must not be memoised forever — clear the cache so
        // the next landing visit retries instead of staying silent.
        if (!d) __musicNotesPromise = null;
        return d;
      });
  }
  return __musicNotesPromise;
}

// Fallbacks only — real values stream in from TWEAK_DEFAULTS via the
// landing prop, so these matter just for a malformed/missing prop.
const MUSIC_V1_FB = { osc: "triangle", vol: -12, trans: 0, detune: 0, attack: 0.005, decay: 0.12, sustain: 0.25, release: 0.3 };
const MUSIC_V2_FB = { on: true, osc: "square", vol: -22, trans: -12, detune: 6, attack: 0.02, decay: 0.25, sustain: 0.15, release: 0.5 };
function musicNorm(m) {
  const p = m || {};
  // Spread would let an undefined tweak value SHADOW its fallback (Tone then
  // silently ignores e.g. oscillator {type: undefined}) — strip them first.
  const defined = (o) => Object.fromEntries(
    Object.entries(o || {}).filter(([, v]) => v !== undefined));
  return {
    bpm: p.bpm || 109, filterHz: p.filterHz ?? 9000,
    verbWet: p.verbWet ?? 0.18, verbRoom: p.verbRoom ?? 0.75,
    pan: p.pan ?? -1,
    v1: { ...MUSIC_V1_FB, ...defined(p.v1) },
    v2: { ...MUSIC_V2_FB, ...defined(p.v2) },
  };
}

const __music = {
  phase: "idle", // idle → starting (armed/loading) → playing → fading → idle
  v1: null, v2: null, filter: null, verb: null, panner: null, part: null,
  fadeTimer: null, unarm: null, beginToken: null,
  params: musicNorm(null), srcBpm: 120,
};
// Console handle for sound-design playtesting, e.g.:
//   __music.v1.get().oscillator.type   → what the synth is actually set to
window.__music = __music;

function musicTransport() {
  return Tone.getTransport ? Tone.getTransport() : Tone.Transport;
}

function musicVoiceSet(synth, cfg) {
  synth.set({
    oscillator: { type: cfg.osc },
    envelope: { attack: cfg.attack, decay: cfg.decay, sustain: cfg.sustain, release: cfg.release },
    detune: cfg.detune,
  });
}

// Push the current params into the live audio graph. Volumes only move in
// the "playing" phase so a slider can't fight the fade-out ramp.
function musicApplyParams() {
  const p = __music.params;
  if (__music.phase !== "playing") return;
  // Transport at 120 plays the scheduled seconds 1:1; scaling around the
  // file's own average tempo makes the tweak read as absolute bpm.
  musicTransport().bpm.rampTo(120 * (p.bpm / (__music.srcBpm || 120)), 0.1);
  if (__music.v1) {
    musicVoiceSet(__music.v1, p.v1);
    __music.v1.volume.rampTo(p.v1.vol, 0.05);
  }
  if (__music.v2) {
    musicVoiceSet(__music.v2, p.v2);
    __music.v2.volume.rampTo(p.v2.vol, 0.05);
  }
  if (__music.filter) __music.filter.frequency.rampTo(p.filterHz, 0.05);
  if (__music.verb) {
    __music.verb.wet.rampTo(p.verbWet, 0.05);
    __music.verb.roomSize.rampTo(p.verbRoom, 0.05);
  }
  if (__music.panner) __music.panner.pan.rampTo(p.pan, 0.05);
}

async function musicBegin() {
  const token = (__music.beginToken = {});
  const data = await loadMusicNotes();
  if (__music.beginToken !== token || __music.phase !== "starting") return;
  if (!data || !data.notes || !data.notes.length) { __music.phase = "idle"; return; }
  __music.srcBpm = data.srcBpm || 120;
  const p = __music.params;
  const t = musicTransport();
  // Schedule anchor: events convert to ticks AT ADD TIME against the current
  // bpm, so this reset must happen before the Part is built — a stale scaled
  // bpm from an earlier landing visit would warp the whole schedule.
  t.bpm.value = 120;
  // Graph: v1 + v2 → lowpass filter → Freeverb → panner → speakers.
  // The panner sits LAST so the whole music bed occupies one side of the
  // stereo field (left by design; the gear ticker answers from the right).
  const panner = new Tone.Panner(p.pan).toDestination();
  const verb = new Tone.Freeverb({ roomSize: p.verbRoom, wet: p.verbWet }).connect(panner);
  const filter = new Tone.Filter(p.filterHz, "lowpass").connect(verb);
  const mkVoice = (cfg) => {
    const s = new Tone.PolySynth(Tone.Synth, {
      oscillator: { type: cfg.osc },
      envelope: { attack: cfg.attack, decay: cfg.decay, sustain: cfg.sustain, release: cfg.release },
    }).connect(filter);
    s.maxPolyphony = 24; // dense chords, bounded CPU
    s.volume.value = cfg.vol;
    s.set({ detune: cfg.detune });
    return s;
  };
  const v1 = mkVoice(p.v1);
  const v2 = mkVoice(p.v2);
  const part = new Tone.Part((time, n) => {
    // n = [timeSec, midi, durSec, velocity]; params read at fire time so
    // transpose/voice-2 toggles land on the very next note.
    const pp = __music.params;
    __music.v1.triggerAttackRelease(
      Tone.Frequency(n[1] + (pp.v1.trans || 0), "midi").toFrequency(), n[2], time, n[3]);
    if (pp.v2.on) __music.v2.triggerAttackRelease(
      Tone.Frequency(n[1] + (pp.v2.trans || 0), "midi").toFrequency(), n[2], time, n[3]);
  }, data.notes.map((n) => [n[0], n]));
  part.loop = true;
  part.loopEnd = (data.duration || 600) + 3; // a breath before the reprise
  part.start(0);
  t.start();
  __music.v1 = v1; __music.v2 = v2;
  __music.filter = filter; __music.verb = verb; __music.panner = panner;
  __music.part = part;
  __music.phase = "playing";
  musicApplyParams();
}

function musicStart(params) {
  if (typeof Tone === "undefined") return;
  if (params) __music.params = musicNorm(params);
  if (__music.phase === "fading") {
    // Relanded mid-fade (e.g. session lost): pull the volumes back up
    clearTimeout(__music.fadeTimer);
    __music.fadeTimer = null;
    __music.phase = "playing";
    musicApplyParams();
    return;
  }
  if (__music.phase !== "idle") { musicApplyParams(); return; }
  __music.phase = "starting";
  const ctx = Tone.getContext ? Tone.getContext() : Tone.context;
  if (ctx && ctx.state === "running") { musicBegin(); return; }
  // Arm every gesture WebKit reliably counts as user activation — iOS grants
  // it to touchend/click where pointerdown and soft-keyboard keydown can miss.
  // Listeners stay armed until a start SUCCEEDS: a rejected Tone.start() just
  // waits for the next gesture instead of killing music for the whole visit.
  // Multi-fire from one tap is safe: Tone.start() is idempotent and the
  // beginToken in musicBegin collapses overlapping begins to one graph.
  const MUSIC_ARM_EVENTS = ["pointerdown", "touchend", "click", "keydown"];
  const fire = () => {
    if (__music.phase !== "starting") { unarm(); return; }
    Tone.start().then(() => {
      if (__music.phase !== "starting") return;
      unarm();
      musicBegin();
    }).catch(() => {});
  };
  const unarm = () => {
    for (const ev of MUSIC_ARM_EVENTS) window.removeEventListener(ev, fire, true);
    __music.unarm = null;
  };
  __music.unarm = unarm;
  for (const ev of MUSIC_ARM_EVENTS) window.addEventListener(ev, fire, true);
}

function musicUpdate(params) {
  if (typeof Tone === "undefined" || !params) return;
  __music.params = musicNorm(params);
  musicApplyParams();
}

function musicFadeOut() {
  if (typeof Tone === "undefined") return;
  if (__music.unarm) __music.unarm();
  if (__music.phase === "starting") { __music.phase = "idle"; return; }
  if (__music.phase !== "playing") return;
  __music.phase = "fading";
  if (__music.v1) __music.v1.volume.rampTo(-60, 0.8);
  if (__music.v2) __music.v2.volume.rampTo(-60, 0.8);
  __music.fadeTimer = setTimeout(() => {
    const t = musicTransport();
    t.stop();
    t.cancel();
    for (const k of ["part", "v1", "v2", "filter", "verb", "panner"]) {
      if (__music[k]) __music[k].dispose();
      __music[k] = null;
    }
    __music.phase = "idle";
  }, 950);
}

function LandingMusic({ music }) {
  // Singleton owns the audio lifecycle; the component just marks "the
  // landing screen is on screen". Param changes ride the second effect,
  // keyed on content — the landing prop object is rebuilt every render.
  const key = JSON.stringify(music || null);
  useEffect(() => {
    musicStart(music);
    return () => musicFadeOut();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => { musicUpdate(music); }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
  return null;
}

// =============================================================
// TopicFlash — ambient cycling example topics on the landing screen.
// Topics come from assets/landing-topics.txt, a hand-edited file: one
// topic per line, blank lines and #-comments skipped, ASCII only
// (rendered raw in the pixel font — no fixText pass). cache:no-store
// so a manual edit shows up on a plain reload. Draws from a shuffled
// deck; reshuffles on exhaustion with a seam guard so the on-screen
// topic never repeats back-to-back. Hard cut per flash — no fade; at
// ~700ms a transition would eat the dwell time.
// =============================================================
let __landingTopicsPromise = null;
function loadLandingTopics() {
  if (!__landingTopicsPromise) {
    __landingTopicsPromise = fetch("assets/landing-topics.txt", { cache: "no-store" })
      .then((r) => (r.ok ? r.text() : ""))
      .then((text) => text.split(/\r?\n/)
        .map((line) => line.trim())
        .filter((line) => line && !line.startsWith("#"))
        // Exact-line dedupe: a duplicate in the curated file would otherwise
        // legitimately appear twice per deck pass.
        .filter((line, i, arr) => arr.indexOf(line) === i))
      .catch(() => []);
  }
  return __landingTopicsPromise;
}

// Deck state is module-scope so the no-repeat guarantee spans landing
// visits: one shuffled pass through the whole list per page session, no
// matter how many times TopicFlash mounts (every episode return used to
// reshuffle from scratch and could re-deal a topic seen minutes before).
// Reload = fresh deck.
let __topicDeck = [];
let __topicCurrent = null;
function drawTopic(topics) {
  if (!topics.length) return "";
  if (!__topicDeck.length) {
    const deck = topics.slice();
    for (let i = deck.length - 1; i > 0; i--) { // Fisher-Yates
      const j = Math.floor(Math.random() * (i + 1));
      [deck[i], deck[j]] = [deck[j], deck[i]];
    }
    if (deck.length > 1 && deck[0] === __topicCurrent) {
      const j = 1 + Math.floor(Math.random() * (deck.length - 1));
      [deck[0], deck[j]] = [deck[j], deck[0]];
    }
    __topicDeck = deck;
  }
  __topicCurrent = __topicDeck.shift();
  return __topicCurrent;
}

// `paused` (flash not yet armed by GO, or input holds text) stops the
// interval and hides the flash — the player's words never get cycled under.
// `onTopicChange` mirrors each drawn topic up to the parent so GO or a tap
// on the input can capture it. On resume the effect re-runs and draw()
// fires immediately — no blank dwell.
function TopicFlash({ intervalMs = 700, fontSize, color, posStyle, paused, onTopicChange }) {
  const [topics, setTopics] = useState([]);
  const [topic, setTopic] = useState("");

  useEffect(() => {
    let alive = true;
    loadLandingTopics().then((list) => {
      if (!alive) return;
      setTopics(list);
    });
    return () => { alive = false; };
  }, []);

  useEffect(() => {
    if (!topics.length || paused) return;
    const draw = () => {
      const next = drawTopic(topics); // module deck — continues across mounts
      setTopic(next);
      onTopicChange && onTopicChange(next);
    };
    draw();
    const id = setInterval(draw, Math.max(150, intervalMs || 700));
    return () => clearInterval(id);
    // the module deck survives interval changes and remounts without restarting
  }, [intervalMs, topics, paused]);

  if (paused) return null;

  return (
    <div style={{
      minHeight: "1.3em", // stable row even with no topics loaded
      fontSize: `${fontSize}px`,
      color: color || "#ffcdd0",
      letterSpacing: "0.06em",
      textAlign: "center",
      pointerEvents: "none", // ambient — not a control
      maxWidth: "90%",
      whiteSpace: "nowrap",
      overflow: "hidden",
      ...posStyle,
      textOverflow: "ellipsis", // a long curated topic can't reflow the column
    }}>
      {topic || " "}
    </div>
  );
}

// =============================================================
// DomainInput — the landing ritual. Quiet until the first GO press
// (the hint says PRESS 'GO' FOR TROUBBLE): GO #1 starts the topic
// flash, GO #2 drops the flashed topic into the field — selected,
// keyboard up — and GO #3 launches it. The keyboard's Done/tick just
// accepts the text; > rejects an untouched capture and the flash
// resumes. Tapping the field at any point is plain typing: before
// arming no flash ever shows, and GO launches whatever was typed.
// =============================================================
function DomainInput({ onSubmit, retroParams, landing }) {
  // Persist value across unmount/remount (loading → error → back to input)
  const [value, setValue] = useState(() => window.__domainDraft || "");
  const inputRef = useRef(null);
  // Armed = the player pressed GO once this landing visit. Per-visit state
  // on purpose (the ritual repeats); the topic deck itself is module-scope,
  // so the no-repeat pass still spans visits.
  const [armed, setArmed] = useState(false);
  // Topic capture: GO (or a tap on the empty input) adopts the currently-
  // flashed topic as real, selected text — tick (keyboard Done) accepts it,
  // one backspace wipes it, typing replaces it. lastTopicRef mirrors
  // TopicFlash's draw; justCapturedRef arms the select-all for the click
  // that ends a capturing tap (click fires after the browser has focused
  // the field and placed the caret, so selecting any earlier would be
  // undone). capturedTopicRef holds the exact captured text so > can tell
  // an untouched capture (reject it) from the player's own words (never
  // touched). goSelectRef defers the GO-capture select() until React has
  // committed the new value — selecting sooner selects the old empty text.
  const lastTopicRef = useRef("");
  const justCapturedRef = useRef(false);
  const capturedTopicRef = useRef("");
  const goSelectRef = useRef(false);
  // All landing text follows the narrative colour (eng_textColor) — hardcoded
  // palette copies drift the moment the engine colour is retuned. Dimmed
  // variants (border, field fill) derive via color-mix, same as the
  // retro-display textShadow does.
  const textColor = retroParams?.textColor || "#ffcdd0";
  const baseFont = retroParams?.fontSize || 32;
  const l = landing || {};
  // Each landing element is absolutely placed in display-% coordinates
  // (center-anchored) so the tweaks panel can compose the screen around
  // the woodpecker sprite.
  const placeAt = (x, y) => ({
    position: "absolute",
    left: `${x ?? 50}%`,
    top: `${y ?? 50}%`,
    transform: "translate(-50%, -50%)",
  });

  // Expose current value so the physical GO button can trigger submit
  useEffect(() => {
    window.__domainInputValue = () => value.trim();
    window.__domainDraft = value;
    return () => { window.__domainInputValue = null; };
  }, [value]);

  // No mount autofocus: the field stays unfocused until the player taps it
  // (or GO captures into it) — the hint sends them to GO first, and a
  // pre-focused input would swallow the GO-capture keyboard pop on iOS
  // (focus() on an already-focused element won't raise the keyboard).

  // GO lands here from device.jsx only when the field is empty (a non-empty
  // field launches instead): the first press arms the flash, later presses
  // capture the currently-flashed topic — selected, keyboard up. The whole
  // press chain is synchronous, so focus() still runs inside the tap
  // gesture, which iOS requires before it shows the keyboard.
  const landingGo = () => {
    if (!armed) { setArmed(true); return; }
    const t = lastTopicRef.current;
    if (!t || value) return; // topics not loaded yet, or already typing
    goSelectRef.current = true;
    justCapturedRef.current = false;
    capturedTopicRef.current = t;
    setValue(t);
    inputRef.current && inputRef.current.focus();
  };
  // > rejects an untouched capture: field clears, the flash resumes on the
  // next topic. The player's own words (typed or edited) are never cleared.
  const landingNext = () => {
    if (!value || value !== capturedTopicRef.current) return;
    capturedTopicRef.current = "";
    setValue("");
  };
  const goRef = useRef();
  goRef.current = landingGo;
  const nextRef = useRef();
  nextRef.current = landingNext;
  useEffect(() => {
    window.__landingGo = () => goRef.current();
    window.__landingNext = () => nextRef.current();
    return () => { window.__landingGo = null; window.__landingNext = null; };
  }, []);
  // The GO-capture select-all — after the captured value is committed to
  // the DOM (layout effect: before paint, so no caret-then-select flash).
  useLayoutEffect(() => {
    if (!goSelectRef.current) return;
    goSelectRef.current = false;
    inputRef.current && inputRef.current.select();
  }, [value]);

  // The hardware GO button is the only launcher (device.jsx reads
  // window.__domainInputValue on SUBMIT_DOMAIN); the keyboard's Done/tick
  // only accepts the text. onSubmit stays in the props for that wiring.
  const captureTopic = () => {
    // Real interaction with a running flash only — before arming,
    // lastTopicRef is empty and a tap is plain typing.
    if (value || !lastTopicRef.current) return;
    justCapturedRef.current = true;
    capturedTopicRef.current = lastTopicRef.current;
    setValue(lastTopicRef.current);
  };
  const selectIfJustCaptured = () => {
    if (!justCapturedRef.current) return;
    justCapturedRef.current = false;
    inputRef.current && inputRef.current.select();
  };

  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <RetroDisplay
        text=""
        params={retroParams}
      >
        <div style={{ position: "relative", height: "100%" }}>
          <LandingMusic music={l.music} />
          <TopicFlash
            intervalMs={l.topicFlashMs}
            fontSize={baseFont * (l.topicFontScale ?? 0.5)}
            color={textColor}
            posStyle={placeAt(l.topicX, l.topicY ?? 33)}
            paused={!armed || value.length > 0}
            onTopicChange={(t) => { lastTopicRef.current = t; }}
          />
          <input
            ref={inputRef}
            type="text"
            value={value}
            onChange={(e) => setValue(e.target.value)}
            onPointerDown={captureTopic}
            onClick={selectIfJustCaptured}
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                // Keyboard Done/tick accepts the text and drops the keyboard;
                // it never launches. GO launches — same on desktop.
                e.preventDefault();
                e.currentTarget.blur();
              }
            }}
            enterKeyHint="done"
            placeholder={l.inputPlaceholder ?? "a situation, a person, an event..."}
            maxLength={200}
            style={{
              width: "90%",
              ...placeAt(l.inputX, l.inputY),
              background: `color-mix(in srgb, ${textColor} 8%, transparent)`,
              border: `1px solid color-mix(in srgb, ${textColor} 35%, transparent)`,
              borderRadius: 0,
              padding: "0.6em 0.8em",
              // The input takes after the flash font — same face, size and
              // tracking, so a captured topic doesn't change appearance the
              // instant it becomes real text. Floor at 16px: anything
              // smaller makes iOS auto-zoom the page on focus (and it
              // doesn't cleanly zoom back on dismiss).
              fontSize: `${Math.max(16, baseFont * (l.topicFontScale ?? 0.5))}px`,
              fontFamily: `"${retroParams?.fontFamily || "EXEPixelPerfect"}", ui-monospace, monospace`,
              color: textColor,
              outline: "none",
              textAlign: "center",
              letterSpacing: "0.06em",
              caretColor: textColor,
            }}
          />
          <div style={{
            fontSize: `${baseFont * (l.hintFontScale ?? 0.4)}px`,
            color: textColor,
            letterSpacing: "0.06em",
            textAlign: "center",
            width: "88%", // fixed wrap box so the text column is stable at any X
            ...placeAt(l.hintX, l.hintY ?? 66),
          }}>
            {l.hintText ?? "Type anything. GO lands you beside someone real who could use your advice."}
          </div>
        </div>
      </RetroDisplay>
    </div>
  );
}

// =============================================================
// FreedomInput — in-flow card that replaces the choice card when the
// OFF SCRIPT card is confirmed. GO (or Enter) sends, "..." backs out.
// =============================================================
function FreedomInput({ onSubmit, fontScale = 0.92, backgroundColor }) {
  // Persist the draft across cancel/reopen within the episode
  const [value, setValue] = useState(() => window.__freedomDraft || "");
  const inputRef = useRef(null);
  const MAX = 280;

  // Expose current value so the physical GO button can trigger submit
  useEffect(() => {
    window.__freedomInputValue = () => value.trim();
    window.__freedomDraft = value;
    return () => { window.__freedomInputValue = null; };
  }, [value]);

  useEffect(() => {
    const t = setTimeout(() => inputRef.current?.focus(), 150);
    return () => clearTimeout(t);
  }, []);

  const submit = () => {
    const trimmed = value.trim();
    if (trimmed) onSubmit(trimmed);
  };

  const notchBg = (!backgroundColor
    || backgroundColor === "transparent"
    || backgroundColor === "none")
    ? "#1a0a1c"
    : backgroundColor;

  return (
    <div style={{
      position: "relative",
      border: "1px solid rgba(255,205,208,0.45)",
      padding: "0.9em 0.9em 0.7em 0.9em",
      display: "flex",
      flexDirection: "column",
    }}>
      <div style={{
        alignSelf: "flex-start",
        margin: "-1.7em 0 0.55em 0",
        padding: "0 0.3em 0 0",
        background: notchBg,
        color: "#ff879a",
        fontWeight: 700,
        fontSize: `${fontScale}em`,
        lineHeight: 1.2,
      }}>
        OFF SCRIPT
      </div>
      <textarea
        ref={inputRef}
        rows={3}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submit(); } }}
        placeholder="what do you do?"
        maxLength={MAX}
        style={{
          width: "100%",
          boxSizing: "border-box",
          background: "rgba(255,205,208,0.06)",
          border: "1px solid rgba(255,205,208,0.3)",
          borderRadius: 0,
          padding: "0.5em 0.6em",
          fontSize: `${fontScale}em`,
          fontFamily: '"EXEPixelPerfect", ui-monospace, monospace',
          color: "#ffcdd0",
          outline: "none",
          resize: "none",
          letterSpacing: "0.04em",
          caretColor: "#ff879a",
        }}
      />
      <div style={{
        display: "flex",
        justifyContent: "space-between",
        marginTop: "0.4em",
        fontSize: `${fontScale * 0.7}em`,
        color: "rgba(255,205,208,0.45)",
        letterSpacing: "0.06em",
      }}>
        <span>GO sends. ... backs out.</span>
        <span>{value.length}/{MAX}</span>
      </div>
    </div>
  );
}

// =============================================================
// LoadingView — standalone loading (initial load, no narrative yet)
// =============================================================
function LoadingView({ retroParams }) {
  const [dots, setDots] = useState("");

  useEffect(() => {
    let i = 0;
    const id = setInterval(() => {
      i = (i + 1) % 4;
      setDots(".".repeat(i));
    }, 400);
    return () => clearInterval(id);
  }, []);

  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <RetroDisplay
        text=""
        params={retroParams}
      >
        <div style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          fontSize: `${(retroParams?.fontSize || 32) * 0.55}px`,
          color: "#ff879a",
          letterSpacing: "0.15em",
          textShadow: "0 0 10px rgba(255,135,154,0.5)",
        }}>
          {dots || "\u00A0"}
        </div>
      </RetroDisplay>
    </div>
  );
}

// =============================================================
// InlineLoadingDots — shows at end of narrative during mid-episode load
// =============================================================
function InlineLoadingDots() {
  const [dots, setDots] = useState("");
  useEffect(() => {
    let i = 0;
    const id = setInterval(() => {
      i = (i + 1) % 4;
      setDots(".".repeat(i));
    }, 400);
    return () => clearInterval(id);
  }, []);
  return <div style={{ paddingTop: "1.2em", opacity: 0.6 }}>{dots || "\u00A0"}</div>;
}

// =============================================================
// EpisodeComplete — coda display
// =============================================================
function EpisodeCompleteView({ retroParams }) {
  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <RetroDisplay
        text=""
        params={retroParams}
      >
        <div style={{
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          height: "100%",
          gap: "1.5em",
        }}>
          <div style={{
            fontSize: `${(retroParams?.fontSize || 32) * 0.6}px`,
            color: "#ff879a",
            letterSpacing: "0.15em",
            textShadow: "0 0 12px rgba(255,135,154,0.6)",
          }}>
            EPISODE OVER
          </div>
          <div style={{
            fontSize: `${(retroParams?.fontSize || 32) * 0.38}px`,
            color: "rgba(255,205,208,0.45)",
            letterSpacing: "0.08em",
          }}>
            GO to begin again
          </div>
        </div>
      </RetroDisplay>
    </div>
  );
}

// =============================================================
// PageAdvanceIndicator — pulsing > in lower-right of CRT viewport
// =============================================================
function PageAdvanceIndicator() {
  const ref = useRef(null);
  useEffect(() => {
    let on = true;
    const id = setInterval(() => {
      on = !on;
      if (ref.current) ref.current.style.opacity = on ? "0.9" : "0.25";
    }, 600);
    return () => clearInterval(id);
  }, []);
  return (
    <div ref={ref} style={{
      position: "absolute",
      bottom: "1%",
      right: "1%",
      fontSize: "clamp(18px, 5cqw, 36px)",
      fontFamily: '"EXEPixelPerfect", ui-monospace, monospace',
      color: "#ff879a",
      textShadow: "0 0 8px rgba(255,135,154,0.6)",
      pointerEvents: "none",
      zIndex: 5,
    }}>{"\u25B8"}</div>
  );
}

// =============================================================
// MainDisplay — wraps the CRT RetroDisplay engine
// =============================================================
function StubMain({
  appState, narrative, body, bodyDone, choices, choiceIdx,
  menuStack, settingValues, isOptions,
  retroParams, landing, errorMsg,
  onDomainSubmit, onFreedomSubmit,
  pagePaused, onPageFull,
  lastDecision, pendingDecision, episodeComplete, isCoda, choicesRevealed,
}) {
  const scrollRef = useRef(null);
  const choiceCardRef = useRef(null);
  const contentEndRef = useRef(null);
  const spacerRef = useRef(null);
  const decisionRef = useRef(null);
  const pendingRef = useRef(null);
  const prevAppStateRef = useRef(appState);

  // Expose refs for smart > scrolling in device.jsx
  useEffect(() => {
    window.__crtScrollContainer = scrollRef.current;
    return () => { window.__crtScrollContainer = null; };
  });
  useEffect(() => {
    window.__crtChoiceCard = choiceCardRef.current;
  });

  // Dynamic spacer height: provides scroll room for page-advance during printing
  useEffect(() => {
    if (!spacerRef.current || !scrollRef.current) return;
    const needsSpacer = appState === "loading" || appState === "printing" || appState === "awaitingInput" || appState === "freedomInput";
    spacerRef.current.style.height = needsSpacer
      ? `${scrollRef.current.clientHeight * 0.85}px`
      : "0px";
  });

  // Returning from the options overlay: put the reveal frontier back in view.
  // A text-size change reflows everything above it, so the pre-menu scroll
  // offset no longer maps to the same text. Declared before the page-pause
  // lock so the lock re-arms on the corrected position (effects run in order).
  const prevIsOptionsRef = useRef(isOptions);
  useEffect(() => {
    const was = prevIsOptionsRef.current;
    prevIsOptionsRef.current = isOptions;
    if (!was || isOptions) return;
    const container = scrollRef.current;
    const marker = contentEndRef.current;
    if (!container || !marker) return;
    const deficit = marker.getBoundingClientRect().bottom - container.getBoundingClientRect().bottom;
    if (deficit > 0) container.scrollTop += deficit + 4;
  }, [isOptions]);

  // Lock scroll when page-paused (no scrolling past the page boundary).
  // isOptions is a dep so the lock re-captures after the frontier scroll above.
  useEffect(() => {
    const container = scrollRef.current;
    if (!container || !pagePaused) return;
    const lockedTop = container.scrollTop;
    const lock = () => { container.scrollTop = lockedTop; };
    container.addEventListener("scroll", lock);
    return () => container.removeEventListener("scroll", lock);
  }, [pagePaused, isOptions]);

  // Phase 1: After choice confirmation (awaitingInput → loading):
  // Center the decision block in the viewport so player retains context while
  // waiting. Suppressed when a pendingDecision is armed — lastDecision is the
  // PREVIOUS turn's then, and the pending branch below owns the scroll.
  useEffect(() => {
    if (appState === "loading" && prevAppStateRef.current === "awaitingInput" && narrative && lastDecision && !pendingDecision) {
      requestAnimationFrame(() => {
        const container = scrollRef.current;
        const decision = decisionRef.current;
        if (container && decision) {
          const containerRect = container.getBoundingClientRect();
          const decisionRect = decision.getBoundingClientRect();
          const decisionOffsetTop = decisionRect.top - containerRect.top + container.scrollTop;
          const decisionH = decision.offsetHeight;
          const containerH = container.clientHeight;
          container.scrollTop = decisionOffsetTop - (containerH / 2) + (decisionH / 2);
        }
      });
    }
    // Pending variant: freedom submit (freedomInput → loading) centers the
    // typed block the same way. A friend invocation ({text: null}) skips this
    // on purpose — the body stays mounted, so the view has nothing to chase.
    if (appState === "loading" && (prevAppStateRef.current === "awaitingInput" || prevAppStateRef.current === "freedomInput") && pendingDecision?.text) {
      requestAnimationFrame(() => {
        const container = scrollRef.current;
        const pending = pendingRef.current;
        if (container && pending) {
          const containerRect = container.getBoundingClientRect();
          const pendingRect = pending.getBoundingClientRect();
          const pendingOffsetTop = pendingRect.top - containerRect.top + container.scrollTop;
          const pendingH = pending.offsetHeight;
          const containerH = container.clientHeight;
          container.scrollTop = pendingOffsetTop - (containerH / 2) + (pendingH / 2);
        }
      });
    }
    // Phase 2: When engine returns (loading → printing):
    // Scroll decision title to top of viewport — incoming narrative renders below
    if (appState === "printing" && prevAppStateRef.current === "loading" && lastDecision) {
      requestAnimationFrame(() => {
        const container = scrollRef.current;
        const decision = decisionRef.current;
        if (container && decision) {
          const containerRect = container.getBoundingClientRect();
          const decisionRect = decision.getBoundingClientRect();
          const decisionOffsetTop = decisionRect.top - containerRect.top + container.scrollTop;
          container.scrollTop = decisionOffsetTop;
        }
      });
    }
    prevAppStateRef.current = appState;
  }, [appState, narrative, lastDecision, pendingDecision]);

  // Overflow detection: pause typewriter when content fills the visible page
  useEffect(() => {
    if (appState !== "printing" || !onPageFull) return;
    const container = scrollRef.current;
    const marker = contentEndRef.current;
    if (!container || !marker) return;
    const containerRect = container.getBoundingClientRect();
    const markerRect = marker.getBoundingClientRect();
    if (markerRect.top > containerRect.bottom + 4) {
      onPageFull();
    }
  }, [body, appState, onPageFull]);

  // Scroll to reveal choice card when first revealed or when cycling choices
  useEffect(() => {
    if (appState !== "awaitingInput" || !choices?.length || !choicesRevealed) return;
    const container = scrollRef.current;
    const card = choiceCardRef.current;
    if (!container || !card) return;
    requestAnimationFrame(() => {
      const containerRect = container.getBoundingClientRect();
      const cardRect = card.getBoundingClientRect();
      const deficit = cardRect.bottom - containerRect.bottom;
      if (deficit > 0) {
        container.scrollBy({ top: deficit + 4, behavior: 'smooth' });
      }
    });
  }, [choicesRevealed, choiceIdx, appState]);

  // ---- DOMAIN INPUT view ----
  if (appState === "domainInput") {
    return (
      <div style={{ position: "relative", width: "100%", height: "100%" }}>
        <DomainInput onSubmit={onDomainSubmit} retroParams={retroParams} landing={landing} />
        {errorMsg && (
          <div style={{
            position: "absolute",
            left: "5%", right: "5%", bottom: "8%",
            padding: "0.5em 0.8em",
            background: "rgba(180,40,40,0.85)",
            color: "#ffcdd0",
            fontSize: `${(retroParams?.fontSize || 32) * 0.4}px`,
            fontFamily: '"Share Tech Mono", ui-monospace, monospace',
            textAlign: "center",
            zIndex: 10,
          }}>{errorMsg}</div>
        )}
      </div>
    );
  }

  // ---- LOADING view (initial — no narrative yet) ----
  // pendingDecision keeps the narrative view mounted: a friend invocation on
  // turn 1 of a later episode (roster persists) has empty narrative but a
  // body the player must keep seeing.
  if (appState === "loading" && !narrative && !pendingDecision) {
    return <LoadingView retroParams={retroParams} />;
  }

  // ---- EPISODE COMPLETE view ----
  if (appState === "episodeComplete") {
    return <EpisodeCompleteView retroParams={retroParams} />;
  }

  // ---- OPTIONS overlay (drawn over the narrative view; the CRT stays
  // mounted underneath so scroll position survives the menu) ----
  let optionsOverlay = null;
  if (isOptions) {
    const top = menuStack[menuStack.length - 1];
    // Root frame contributes no title — BIOGRAPHY stands alone at the top of
    // the list and section headers (below) label the settings block. Drilled
    // views (biography) still show their own name.
    const trail = menuStack.slice(1).map((f) => f.node.label).join(" › ");
    const isTextView = top?.node.kind === "text";
    const items = top?.node.items || [];
    // C.V. document, assembled from the localStorage record (device.jsx owns
    // the shape: v2 = { entries: [{ position_line, bullets[] }], skills }).
    // The model wrote only position lines, bullets, and the skills line;
    // NAME/OBJECTIVE/REFERENCES are client furniture. Entries render
    // newest-first, résumé convention.
    let cvDoc = null;
    if (isTextView) {
      try {
        const p = JSON.parse(localStorage.getItem("deviceBiography") || "null");
        if (p && p.v === 2 && Array.isArray(p.entries) && p.entries.length) {
          const jobs = p.entries.slice().reverse()
            .filter((e) => e && e.position_line)
            .map((e) => [e.position_line, ...(e.bullets || []).map((b) => `- ${b}`)].join("\n"));
          if (jobs.length) cvDoc = { jobs, skills: p.skills || "" };
        }
      } catch (_) { cvDoc = null; }
    }
    const cvSectionHead = {
      color: "#fff",
      margin: "1.4em 0 0.5em",
      letterSpacing: "0.05em",
    };
    optionsOverlay = (
      <div ref={(el) => { window.__bioScrollEl = isTextView ? el : null; }} style={{
        position: "absolute", inset: 0,
        padding: "5% 5% 4% 5%",
        background: "#1a0a1c",
        zIndex: 20,
        // The C.V. view reads like the game itself (user ruling 2026-09-25):
        // the narrative engine's font, its effective size (tracks the TEXT
        // SIZE setting via retroParams), and its line height. Menu rows keep
        // the terminal UI font.
        fontFamily: isTextView
          ? '"EXEPixelPerfect", ui-monospace, monospace'
          : '"Share Tech Mono", ui-monospace, monospace',
        fontSize: isTextView
          ? `${retroParams?.fontSize || 28}px`
          : "clamp(11px, 3.5cqw, 28px)",
        lineHeight: isTextView ? (retroParams?.lineHeight || 0.75) : 1.4,
        color: "#ffcdd0",
        textShadow: "0 0 6px rgba(255,205,208,0.4)",
        whiteSpace: "pre-wrap",
        containerType: "inline-size",
        boxSizing: "border-box",
        overflowY: "auto",
        overflowX: "hidden",
      }}>
        {!!trail && <div style={{
          marginBottom: "1.2em",
          color: "#ff879a",
          letterSpacing: "0.08em",
          fontSize: "0.85em",
          opacity: 0.85,
        }}>{trail}</div>}
        {isTextView ? (
          cvDoc ? (
            <div>
              <div>{"NAME: [REDACTED]\nOBJECTIVE: To be in the room."}</div>
              <div style={cvSectionHead}>EXPERIENCE</div>
              <div>{cvDoc.jobs.join("\n\n")}</div>
              {!!cvDoc.skills && (
                <div>
                  <div style={cvSectionHead}>SKILLS</div>
                  <div>{cvDoc.skills}</div>
                </div>
              )}
              <div style={cvSectionHead}>REFERENCES</div>
              <div>{"Available upon declassification."}</div>
            </div>
          ) : (
            <div style={{ opacity: 0.75 }}>
              {"NO RECORD YET.\n\nYour C.V. writes itself as you play. Finish an episode and return."}
            </div>
          )
        ) : items.map((entry, i) => {
          const isSel = i === top.idx;
          let valueHint;
          if (entry.kind === "choice") {
            const v = settingValues[entry.id] ?? entry.defaultIdx ?? 0;
            valueHint = entry.options[v];
          } else {
            valueHint = "›";
          }
          // Non-selectable section header whenever the tree's section label
          // changes between rows (the selector index never counts it).
          const sectionHeader = entry.section && (i === 0 || items[i - 1].section !== entry.section)
            ? entry.section : null;
          return (
            <React.Fragment key={entry.id + i}>
              {sectionHeader && (
                <div style={{
                  margin: "1.1em 0 0.4em 0.6em",
                  color: "#ff879a",
                  letterSpacing: "0.08em",
                  fontSize: "0.85em",
                  opacity: 0.85,
                }}>{sectionHeader}</div>
              )}
              <div style={{
                padding: "0.4em 0.6em",
                background: isSel ? "rgba(255,135,154,0.18)" : "transparent",
                color: isSel ? "#fff" : "#ffcdd0",
                marginBottom: "0.2em",
                display: "flex",
                justifyContent: "space-between",
              }}>
                <span>{isSel ? "▸ " : "  "}{entry.label}</span>
                {valueHint != null && (
                  <span style={{ opacity: 0.7, marginLeft: "1em" }}>{valueHint}</span>
                )}
              </div>
            </React.Fragment>
          );
        })}
      </div>
    );
  }

  // ---- NARRATIVE + CHOICE view (printing, awaitingInput, mid-episode loading) ----
  const onChoicePage = !!choices?.length;
  const currentChoice = onChoicePage && bodyDone && choicesRevealed && appState === "awaitingInput" ? choices[choiceIdx] : null;
  // Show pulsing > when body done but waiting for user to advance:
  // - choices exist but not yet revealed
  // - episodeComplete or coda page (no choices, user must press > to continue)
  const awaitingAdvance = appState === "awaitingInput" && bodyDone && (
    (onChoicePage && !choicesRevealed) ||
    (!onChoicePage && (episodeComplete || isCoda))
  );

  // Split narrative into prefix + decision for scroll targeting
  let narrativePrefix = narrative;
  let decisionText = null;
  if (lastDecision && narrative && narrative.endsWith(lastDecision)) {
    narrativePrefix = narrative.slice(0, narrative.length - lastDecision.length);
    decisionText = lastDecision;
  }

  return (
    <div style={{ position: "absolute", inset: 0 }}>
      <RetroDisplay
        text=""
        params={retroParams}
        scrollRef={scrollRef}
      >
        {narrativePrefix && <span style={{ whiteSpace: "pre-wrap" }}>{narrativePrefix}</span>}
        {decisionText && <span ref={decisionRef} style={{ whiteSpace: "pre-wrap" }}>{decisionText}</span>}
        {(appState !== "loading" || pendingDecision) && narrative && body && <span style={{ whiteSpace: "pre-wrap" }}>{"\n\n"}</span>}
        {(appState !== "loading" || pendingDecision) && body && <span style={{ whiteSpace: "pre-wrap" }}>{body}</span>}
        {appState === "loading" && pendingDecision?.text && <span ref={pendingRef} style={{ whiteSpace: "pre-wrap" }}>{pendingDecision.text}</span>}
        {appState === "printing" && !pagePaused && <BlinkingCaret />}
        <div ref={contentEndRef} style={{ height: 0 }} />
        {currentChoice && (
          <div ref={choiceCardRef} style={{ paddingTop: "1.2em" }}>
            <ChoiceCard
              title={currentChoice.title}
              body={currentChoice.body}
              fontScale={retroParams?.choiceFontScale}
              lineHeight={retroParams?.choiceLineHeight}
              backgroundColor={retroParams?.backgroundColor}
            />
          </div>
        )}
        {appState === "freedomInput" && (
          <div ref={choiceCardRef} style={{ paddingTop: "1.2em" }}>
            <FreedomInput
              onSubmit={onFreedomSubmit}
              fontScale={retroParams?.choiceFontScale}
              backgroundColor={retroParams?.backgroundColor}
            />
          </div>
        )}
        {appState === "loading" && (narrative || pendingDecision) && <InlineLoadingDots />}
        <div ref={spacerRef} aria-hidden="true" style={{ height: 0 }} />
      </RetroDisplay>
      {(pagePaused || awaitingAdvance) && <PageAdvanceIndicator />}
      {errorMsg && (
        <div style={{
          position: "absolute",
          left: "5%", right: "5%", bottom: "8%",
          padding: "0.5em 0.8em",
          background: "rgba(180,40,40,0.85)",
          color: "#ffcdd0",
          fontSize: `${(retroParams?.fontSize || 32) * 0.4}px`,
          fontFamily: '"Share Tech Mono", ui-monospace, monospace',
          textAlign: "center",
          zIndex: 10,
        }}>{errorMsg}</div>
      )}
      {optionsOverlay}
    </div>
  );
}

function BlinkingCaret() {
  const ref = useRef(null);
  useEffect(() => {
    let on = true;
    const id = setInterval(() => {
      on = !on;
      if (ref.current) ref.current.style.opacity = on ? "0.7" : "0";
    }, 500);
    return () => clearInterval(id);
  }, []);
  return <span ref={ref} style={{ display: "inline-block", marginLeft: "0.1em" }}>_</span>;
}

// Terminal-box with a notch in the top edge that holds the option title.
// The title sits in normal flow (a negative top margin lifts it onto the
// border) so a wrapped title pushes the body down instead of painting over it.
function ChoiceCard({ title, body, fontScale = 0.92, lineHeight = 1.45, backgroundColor }) {
  const notchBg = (!backgroundColor
    || backgroundColor === "transparent"
    || backgroundColor === "none")
    ? "#1a0a1c"
    : backgroundColor;
  return (
    <div style={{
      position: "relative",
      border: "1px solid rgba(255,205,208,0.45)",
      padding: "0.9em 0.9em 0.7em 0.9em",
      display: "flex",
      flexDirection: "column",
    }}>
      <div style={{
        alignSelf: "flex-start",
        margin: "-1.7em 0 0.55em 0",
        padding: "0 0.3em 0 0",
        background: notchBg,
        color: "#ff879a",
        letterSpacing: "normal",
        fontWeight: 700,
        fontSize: `${fontScale}em`,
        lineHeight: 1.2,
        // The notch overlays the card border via the negative margin — a
        // wrapped second line lands inside the card with the font's odd
        // leading between. Single-line always: overflow ellipsizes instead.
        maxWidth: "100%",
        whiteSpace: "nowrap",
        overflow: "hidden",
        textOverflow: "ellipsis",
      }}>
        {title}
      </div>
      <div style={{
        color: "#ffcdd0",
        opacity: 0.95,
        fontSize: `${fontScale}em`,
        lineHeight: lineHeight,
      }}>
        {body}
      </div>
    </div>
  );
}

window.StubTitle = StubTitle;
window.StubMain = StubMain;
window.DomainInput = DomainInput;
window.TopicFlash = TopicFlash;
window.LoadingView = LoadingView;
