// The Boardroom — VOICE CHAT browser layer (Phase A). Two things live here:
//   1. the browser adapters the pure controller (BR_VOICE, app/js/voice.js) needs —
//      a SpeechRecognition factory and a speechSynthesis speaker (iOS-aware)
//   2. VoiceDock — the always-visible voice HUD: mic-state indicator
//      (LISTENING / THINKING / SPEAKING / PAUSED), live interim captions,
//      STOP/SKIP + MUTE + EXIT controls, and the per-turn latency log.
//
// The interaction model (RULED by Mike): always-listening, half-duplex, no barge-in.
// All turn logic is in BR_VOICE.createVoiceController — tested headlessly in
// app/test/voicechat.test.js. This file is glue + presentation only.

// ── Browser adapters ───────────────────────────────────────────────────────────
function createBrowserRecognition() {
  const Rec = window.SpeechRecognition || window.webkitSpeechRecognition;
  if (!Rec) return null;
  const rec = new Rec();
  rec.lang = "en-US";
  rec.interimResults = true;
  rec.continuous = true;
  return rec;
}

// ── The ready cue ──────────────────────────────────────────────────────────────
// Voice chat is HANDS-FREE: the user is not looking at the phone, so the dock flipping
// to LISTENING cannot be the only signal that the mic is armed. A short soft tone is
// what actually keeps them out of the cold-open dead zone that was eating the addressee.
// Web Audio (not an <audio> file): no asset, no network, and it shares the entry tap's
// unlock. Deliberately quiet and short — this fires every single turn.
let _cueCtx = null;
function cueUnlock() {
  try {
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    if (!_cueCtx) _cueCtx = new AC();
    if (_cueCtx.state === "suspended") _cueCtx.resume();
  } catch (e) { _cueCtx = null; }
}
function cueReady() {
  try {
    if (!_cueCtx) cueUnlock();
    if (!_cueCtx || _cueCtx.state !== "running") return;   // never block a turn on a cue
    const t = _cueCtx.currentTime;
    const osc = _cueCtx.createOscillator();
    const gain = _cueCtx.createGain();
    osc.type = "sine";
    osc.frequency.setValueAtTime(880, t);
    // a soft blip: quick attack, short decay — audible over a room, never a beep in the ear
    gain.gain.setValueAtTime(0.0001, t);
    gain.gain.exponentialRampToValueAtTime(0.06, t + 0.015);
    gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.13);
    osc.connect(gain); gain.connect(_cueCtx.destination);
    osc.start(t); osc.stop(t + 0.15);
  } catch (e) {}
}

// speechSynthesis speaker. Handles the two iOS realities from the brief:
//   • voices load ASYNC — pick lazily on every speak + refresh on voiceschanged
//   • audio needs a user-gesture unlock — unlock() is called from the mode-entry tap
// Plus the Chrome-desktop long-speech pause bug: a resume() keepalive ticks while
// anything is speaking (harmless elsewhere; sentences are short anyway).
let _sharedSpeaker = null;
function createBrowserSpeaker() {
  // SINGLETON: one speaker for the page lifetime. Re-entering voice mode must not
  // stack another `voiceschanged` listener or keepalive closure on the global
  // speechSynthesis — N enter/exit cycles previously pinned N dead speakers.
  if (_sharedSpeaker) return _sharedSpeaker;
  const synth = window.speechSynthesis;
  let baseVoice = null;
  let keepalive = null;
  let deliberatePause = false;   // a user PAUSE (speechSynthesis.pause) — the keepalive must
                                 // NOT auto-resume it; only resumePlayback()/cancel() clear it
  const _live = new Set();   // utterances in flight — see the GC GUARD in speak()
  const _parked = new Set(); // cancel hooks for utterances waiting on a busy engine
  let epoch = 0;             // bumped by cancel() — see the EPOCH note in speak()

  function pickVoice() {
    if (baseVoice) return baseVoice;
    const voices = (synth.getVoices() || []).filter((v) => /^en([-_]|$)/i.test(v.lang || ""));
    if (!voices.length) return null;
    baseVoice = voices.filter((v) => v.default)[0] ||
      voices.filter((v) => /en[-_]GB/i.test(v.lang))[0] || voices[0];
    return baseVoice;
  }
  if (synth && typeof synth.addEventListener === "function") {
    synth.addEventListener("voiceschanged", () => { baseVoice = null; pickVoice(); });
  }
  function startKeepalive() {
    if (keepalive) return;
    // ONLY when actually paused. This used to also fire resume() while the engine was
    // happily SPEAKING — the classic workaround for Chrome's ~15s cutoff on a LONG
    // utterance. resume()-while-speaking is itself a documented source of engines
    // skipping or restarting mid-utterance, and on the 2026-07-17 Pixel test the board's
    // speech dropped words and "caught up" — this ticking every 5s across every sentence
    // is the one thing in our control that plausibly causes exactly that.
    // Dropping it is only safe because the stall is now UNREACHABLE BY CONSTRUCTION:
    // voice.js caps every chunk at MAX_CHUNK_CHARS (~12s at the slowest voice). That
    // premise used to be false — splitSentences returned a whole run-on answer as ONE
    // 25-31s utterance — so this comment described a guarantee that did not exist. The
    // cap makes it real; if the cap ever goes, this workaround has to come back.
    // The paused branch stays: without it a backgrounded utterance never resumes.
    keepalive = setInterval(() => { try { if (synth.paused && !deliberatePause) synth.resume(); } catch (e) {} }, 5000);
  }
  function stopKeepalive() {
    if (keepalive) { clearInterval(keepalive); keepalive = null; }
  }
  function stopKeepaliveIfIdle() {
    if (keepalive && !synth.speaking && !synth.pending) stopKeepalive();
  }

  return (_sharedSpeaker = {
    // The mode-entry tap is the natural unlock gesture (iOS requirement).
    unlock() {
      try {
        const u = new SpeechSynthesisUtterance(" ");
        u.volume = 0;
        synth.speak(u);
        synth.resume();
        pickVoice();
      } catch (e) {}
    },
    speak(text, voice, cbs) {
      let u;
      try {
        u = new SpeechSynthesisUtterance(text);
        const v = pickVoice();
        if (v) u.voice = v;
        u.rate = (voice && voice.rate) || 1;
        u.pitch = (voice && voice.pitch) || 1;
      } catch (e) { cbs.onerror(); return; }
      let opened = false, closed = false;
      const done = () => { _live.delete(u); stopKeepaliveIfIdle(); };
      u.onstart = () => { if (!opened) { opened = true; cbs.onstart(); } };
      u.onend = () => { if (!closed) { closed = true; done(); cbs.onend(); } };
      u.onerror = (ev) => {
        if (closed) return;
        closed = true;
        done();
        // A cancel() surfaces as an "interrupted"/"canceled" error — that is a
        // normal skip, not a TTS failure; the controller's generation guard
        // already ignores the callback either way.
        if (ev && (ev.error === "interrupted" || ev.error === "canceled")) cbs.onend();
        else cbs.onerror();
      };
      // GC GUARD: hold a hard reference until the utterance settles. Chrome garbage-
      // collects an unreferenced SpeechSynthesisUtterance MID-SPEECH — the engine cuts
      // off part-way and moves on, which reads exactly like "dropped words, catching
      // up". The local `u` is NOT enough: nothing else refers to it once speak() returns
      // (the handlers hang off u, not the reverse).
      _live.add(u);
      startKeepalive();
      // Do not hand the engine the next sentence while it is still busy with the last.
      // onend can land a beat before the engine is genuinely idle, and speak()-while-
      // speaking is the documented way to make Chrome drop or garble an utterance.
      // Bounded: if the engine never goes idle we speak anyway rather than deadlock —
      // the controller's watchdog is the backstop either way.
      let waited = 0;
      let fireTimer = null;
      // EPOCH: cancel() invalidates work that is PARKED here but not yet handed to the
      // engine. `closed` is not enough and this is not theoretical — it was a live
      // hot-mic regression this defer loop introduced: synth.cancel() fires no callback
      // for an utterance the engine never received, so `closed` stayed false, and 40ms
      // later fire() woke on an idle engine and spoke. After a STOP the mic has already
      // reopened, so the board spoke INTO A LIVE MIC — the feedback-loop law broken in
      // the adapter, underneath the controller that enforces it. The controller's `gen`
      // guard cannot save this: it suppresses callbacks, not audio.
      const myEpoch = epoch;
      const fire = () => {
        fireTimer = null;
        if (closed || myEpoch !== epoch) return;   // cancelled while parked → never speak
        try {
          if ((synth.speaking || synth.pending) && waited < 1200) {
            waited += 40;
            fireTimer = setTimeout(fire, 40);
            return;
          }
          synth.speak(u);
        } catch (e) { if (!closed) { closed = true; done(); cbs.onerror(); } }
      };
      _parked.add(() => { if (fireTimer != null) { clearTimeout(fireTimer); fireTimer = null; } });
      fire();
    },
    // PAUSE / RESUME playback (freeze the current utterance mid-word, then continue). The
    // keepalive is taught to leave a deliberate pause alone (above); resume/cancel clear it.
    pausePlayback() { try { deliberatePause = true; synth.pause(); } catch (e) {} },
    resumePlayback() { try { deliberatePause = false; synth.resume(); } catch (e) {} },
    cancel() {
      // Invalidate every parked fire() FIRST: after this, none of them may reach the
      // engine, whatever the engine reports about its own state.
      epoch++;
      _parked.forEach((clear) => { try { clear(); } catch (e) {} });
      _parked.clear();
      // A cancel() while PAUSED must actually clear the queue: some engines defer cancel
      // until resumed, so lift the pause first. Also clears the deliberate-pause flag.
      deliberatePause = false;
      try { if (synth.paused) synth.resume(); } catch (e) {}
      try { synth.cancel(); } catch (e) {}
      // unconditional: engines can report `speaking` asynchronously after cancel(),
      // which left the interval ticking forever. speak() restarts it when needed.
      stopKeepalive();
      // Drop the GC guards: cancel() may fire no callback at all for a queued-but-never-
      // started utterance, so nothing else would ever release these.
      _live.clear();
    },
  });
}

// Everything voice mode needs from the platform, in one gate (the entry button
// renders only when this is true — voice is a layer, never a lock).
function voiceChatAvailable() {
  return !!((window.BR_CONFIG || {}).voiceChat &&
    (window.SpeechRecognition || window.webkitSpeechRecognition) &&
    window.speechSynthesis && typeof SpeechSynthesisUtterance !== "undefined");
}

// ── VoiceDock — the voice-mode HUD ─────────────────────────────────────────────
// Fixed above the phone tab bar (dark inverted surface, same overlay language as
// the auth gate / PasskeyOffer — it must read as a mode, not a panel). Mike must
// ALWAYS know if the mic is hot: the state row is the loudest element.
const voiceDockCSS = `
/* --vd-bottom is the dock's own bottom inset — defined once as a var so the transcript
   reservation below tracks it on phone and desktop without duplicating the calc. */
:root { --vd-bottom: calc(64px + max(0px, env(safe-area-inset-bottom, 0px) - 28px) + 10px); }
@media (min-width:821px){ :root { --vd-bottom: 16px; } }
.vd-wrap { position:fixed; left:10px; right:10px; z-index:55; max-width:560px; margin:0 auto;
           bottom:var(--vd-bottom);
           background:var(--paper-inverse); color:#f3ede3;
           border:1px solid rgba(246,239,234,0.16); border-radius:var(--radius-2);
           box-shadow:0 14px 44px rgba(27,26,23,0.45); font-family:var(--font-sans); }
/* VOICE MODE: the transcript is a FIXED-HEIGHT card PINNED directly above the dock — a
   stationary window whose bottom edge sits just above the dock (no beige void below it);
   the text auto-scrolls (crawls) UP inside it. Earlier attempts scrolled the whole page and
   left the card floating mid-screen with dead space beneath it. Now .rm-scroll itself is the
   scroller: its height is set to reach from just below the header down to just above the dock
   (--rm-crawl-h, measured in app.jsx from the live dock position, so it tracks every dock
   state), and it scrolls internally. margin-top:auto bottom-aligns short content so the
   newest line rises from just above the dock rather than clinging to the top. */
.rm-scroll.voice-crawl { height: var(--rm-crawl-h, 55vh); overflow-y:auto; overscroll-behavior:contain;
                         display:flex; flex-direction:column; }
.rm-scroll.voice-crawl > .rm-wrap { margin-top:auto; }
.vd-top { display:flex; align-items:center; gap:12px; padding:12px 14px 0; }
.vd-state { display:flex; align-items:center; gap:9px; flex:1; min-width:0;
            font-family:var(--font-mono); font-size:11px; letter-spacing:0.18em; text-transform:uppercase; }
.vd-dot { width:11px; height:11px; border-radius:50%; flex:none; }
/* Arming: the mic is opening but NOT capturing. Deliberately NOT the green go-pulse —
   a hollow amber ring reads as "wait", which is exactly what it means. */
.vd-dot.arming { background:transparent; border:2px solid #b98a3e; box-sizing:border-box;
                 animation:vdpulse 0.9s ease-in-out infinite; }
.vd-statelabel.arming { color:#c79a4e; }
.vd-dot.listening { background:#5DA271; animation:vdpulse 1.3s ease-in-out infinite; }
.vd-dot.thinking { background:var(--claret-soft); animation:vdpulse 1.6s ease-in-out infinite; }
.vd-dot.speaking { background:var(--claret-soft); }
.vd-dot.paused { background:#7d7468; animation:none; }
@keyframes vdpulse { 0%,100%{ opacity:0.35; transform:scale(0.8);} 50%{ opacity:1; transform:scale(1);} }
@media (prefers-reduced-motion: reduce){ .vd-dot { animation:none !important; } }
.vd-statelabel.listening { color:#8fbf9d; }
.vd-statelabel.thinking, .vd-statelabel.speaking { color:var(--claret-soft); }
.vd-statelabel.paused { color:#8f877b; }
.vd-who { color:#b9b2a6; letter-spacing:0.04em; text-transform:none; font-family:var(--font-sans);
          font-size:12.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vd-caption { padding:8px 14px 0; font-family:var(--font-serif); font-size:15.5px; line-height:1.45;
              color:#e7e2d8; min-height:22px; max-height:68px; overflow:hidden;
              display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; }
.vd-caption.quiet { color:#8f877b; font-style:italic; font-size:13.5px; }
.vd-controls { display:flex; align-items:center; gap:8px; padding:10px 10px 12px; }
.vd-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:44px;
          padding:0 14px; border-radius:var(--radius-1); cursor:pointer; flex:none;
          font-family:var(--font-sans); font-size:13px; background:transparent;
          border:1px solid rgba(246,239,234,0.22); color:#e7e2d8; }
.vd-btn.primary { background:var(--claret); border-color:var(--claret); color:#fff; }
.vd-btn:disabled { opacity:0.4; cursor:default; }
.vd-spacer { flex:1; }
.vd-lat { padding:0 14px 2px; font-family:var(--font-mono); font-size:10.5px; letter-spacing:0.06em; color:#8f877b; }
.vd-lat b { color:#b9b2a6; font-weight:500; }
.vd-logbtn { background:transparent; border:0; padding:6px 0; cursor:pointer;
             font-family:var(--font-mono); font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:#8f877b; }
.vd-log { margin:0 14px 10px; padding:8px 0 0; border-top:1px solid rgba(246,239,234,0.12);
          max-height:120px; overflow-y:auto; }
.vd-logrow { display:flex; gap:10px; font-family:var(--font-mono); font-size:10.5px; color:#b9b2a6;
             padding:3px 0; }
.vd-logrow span:first-child { color:#8f877b; width:14px; flex:none; }
`;

const VD_LABEL = { arming: "Opening the mic", listening: "Listening", thinking: "Thinking", speaking: "Speaking", paused: "Mic paused" };

function vdSecs(ms) { return ms == null ? "—" : (ms / 1000).toFixed(1) + "s"; }
function vdTargetName(target, experts) {
  if (target === "board") return "board";
  const m = (experts || []).filter((e) => e.id === target)[0];
  return m ? m.name.split(" ")[0] : target;
}

function VoiceDock({ v, experts, onSkip, onMute, onResume, onExit, onPauseSpeech, onResumeSpeech }) {
  const [showLog, setShowLog] = React.useState(false);
  // Publish the dock's live height to --vd-occlude so the shell scroller can pad exactly
  // its footprint (see the .br-main rule in voiceDockCSS). Re-measures on every size
  // change — the caption clamps/grows and the turns log expands, and the reservation
  // must track that so the last spoken line never slips behind the box. Cleared on
  // unmount, so the padding reverts the instant voice mode ends.
  const wrapRef = React.useRef(null);
  const measure = React.useCallback(() => {
    const root = (typeof document !== "undefined" && document.documentElement) || null;
    const el = wrapRef.current;
    if (!root || !el) return;
    const h = el.offsetHeight || (el.getBoundingClientRect && el.getBoundingClientRect().height) || 0;
    if (h) root.style.setProperty("--vd-occlude", h + "px");   // 0 → keep the CSS floor
  }, []);
  // Re-measure after EVERY render: the dock's height changes with the state (a tall
  // LISTENING dock with caption + latency vs a short SPEAKING one) and the expanded log,
  // and the reservation MUST track the CURRENT height — a stale small value is exactly
  // how the last line ends up behind a grown dock. The ResizeObserver is the backstop
  // for size changes React doesn't re-render for; this covers the ones it does.
  React.useEffect(() => { measure(); });
  React.useEffect(() => {
    const root = (typeof document !== "undefined" && document.documentElement) || null;
    if (!root) return undefined;
    let ro = null;
    if (wrapRef.current && typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(measure);
      ro.observe(wrapRef.current);
    }
    return () => { if (ro) ro.disconnect(); root.style.removeProperty("--vd-occlude"); };
  }, [measure]);
  if (!v) return null;
  const st = v.state;
  // SPEECH pause (TTS frozen mid-word via Pause) vs the existing MIC-mute — both land on
  // state "paused"; v.speechPaused disambiguates them for the label, caption and controls.
  const speechPaused = st === "paused" && !!v.speechPaused;
  const who = (st === "speaking" || speechPaused)
    ? (v.speakingSlug === "__board__" ? "The board’s view" :
       (experts.filter((e) => e.id === v.speakingSlug)[0] || {}).name || "")
    : "";
  // Long interims show their TAIL — the newest transcribed words must stay
  // visible (the caption clamps to 3 lines and grows at the end).
  const tail = (t) => (t && t.length > 150 ? "… " + t.slice(-140) : t);
  const caption = st === "arming"
    // The mic is NOT capturing yet. Saying so is the whole fix: the user waits, and the
    // first word — the addressee — survives. LISTENING (+ the tone) is the go signal.
    ? "One moment — wait for Listening."
    : st === "listening"
    ? (v.interim ? tail(v.interim) : "Speak when ready — a pause sends it.")
    : speechPaused
    ? "Paused — Resume to pick up where the board left off."
    : st === "paused"
      ? (v.interim ? "Mic is off — holding: “" + tail(v.interim) + "”" : "Mic is off. Nothing is heard until you resume.")
      : (v.notice || "");
  const quiet = st !== "listening" || !v.interim;
  const last = v.turns.length ? v.turns[v.turns.length - 1] : null;
  // Stop/skip ends the turn — also from a SPEECH pause (Mike may want to move on).
  const skippable = st === "speaking" || st === "thinking" || speechPaused;
  return (
    <div className="vd-wrap" ref={wrapRef} role="region" aria-label="Voice chat">
      <style>{voiceDockCSS}</style>
      <div className="vd-top">
        <div className="vd-state">
          <span className={"vd-dot " + st} />
          <span className={"vd-statelabel " + st}>{speechPaused ? "Paused" : (VD_LABEL[st] || st)}</span>
          {who ? <span className="vd-who">— {who}</span> : null}
        </div>
        <button type="button" className="vd-logbtn" aria-expanded={showLog} onClick={() => setShowLog((x) => !x)}>
          Turns {v.turns.length} {showLog ? "⌃" : "⌄"}
        </button>
      </div>
      {(caption || st === "thinking") ? (
        <div className={"vd-caption" + (quiet ? " quiet" : "")}>
          {caption || (v.lastTarget && v.lastTarget !== "board"
            ? vdTargetName(v.lastTarget, experts) + " is thinking…"
            : "The board has it…")}
        </div>
      ) : null}
      {last && st === "listening" ? (
        <div className="vd-lat">last · {vdTargetName(last.target, experts)} · first word <b>{vdSecs(last.firstAudioMs)}</b> · total <b>{vdSecs(last.totalMs)}</b></div>
      ) : null}
      {showLog && (
        <div className="vd-log">
          {v.turns.length === 0 && <div className="vd-logrow"><span /> no turns yet</div>}
          {v.turns.map((t, i) => (
            <div key={i} className="vd-logrow">
              <span>{i + 1}</span>
              <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{vdTargetName(t.target, experts)}</span>
              <span>ack {vdSecs(t.ackMs)}</span>
              <span>first {vdSecs(t.firstAudioMs)}</span>
              <span>total {vdSecs(t.totalMs)}</span>
              {t.skipped ? <span>skip</span> : null}
            </div>
          ))}
        </div>
      )}
      <div className="vd-controls">
        <button type="button" className="vd-btn primary" onClick={onSkip} disabled={!skippable}
          aria-label="Stop the board speaking and reopen the microphone">
          <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="2" /></svg>
          Stop / skip
        </button>
        {speechPaused
          ? <button type="button" className="vd-btn" onClick={onResumeSpeech} aria-label="Resume the board speaking from where it paused">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 5l12 7-12 7z" /></svg>
              Resume
            </button>
          : st === "speaking"
          ? <button type="button" className="vd-btn" onClick={onPauseSpeech} aria-label="Pause the board mid-sentence; resume from the same point">
              <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
              Pause
            </button>
          : st === "paused"
          ? <button type="button" className="vd-btn" onClick={onResume} aria-label="Resume listening">
              <Icon name="mic" size={15} stroke="currentColor" /> Resume
            </button>
          : <button type="button" className="vd-btn" onClick={onMute} disabled={st !== "listening"} aria-label="Pause the microphone">
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
                <path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 10v1a7 7 0 0 1-14 0v-1M12 18v4M8 22h8" /><path d="M3 3l18 18" />
              </svg>
              Mute
            </button>}
        <div className="vd-spacer" />
        <button type="button" className="vd-btn" onClick={onExit} aria-label="Exit voice chat">
          <Icon name="x" size={15} stroke="currentColor" /> Exit
        </button>
      </div>
    </div>
  );
}

Object.assign(window, { createBrowserRecognition, createBrowserSpeaker, voiceChatAvailable, VoiceDock,
  cueUnlock, cueReady });
