// 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;
}

// 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;

  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;
    // resume() covers BOTH the Chrome-desktop ~15s stall (speaking, not paused)
    // and the backgrounded-page pause (paused=true on return to foreground) —
    // without the paused branch a backgrounded utterance never resumes.
    keepalive = setInterval(() => { try { if (synth.speaking || synth.paused) 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;
      u.onstart = () => { if (!opened) { opened = true; cbs.onstart(); } };
      u.onend = () => { if (!closed) { closed = true; stopKeepaliveIfIdle(); cbs.onend(); } };
      u.onerror = (ev) => {
        if (closed) return;
        closed = true;
        stopKeepaliveIfIdle();
        // 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();
      };
      startKeepalive();
      try { synth.speak(u); } catch (e) { if (!closed) { closed = true; cbs.onerror(); } }
    },
    cancel() {
      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();
    },
  });
}

// 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-wrap { position:fixed; left:10px; right:10px; z-index:55; max-width:560px; margin:0 auto;
           bottom:calc(64px + max(0px, env(safe-area-inset-bottom, 0px) - 28px) + 10px);
           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); }
@media (min-width:821px){ .vd-wrap { bottom:16px; } }
.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; }
.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 = { 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 }) {
  const [showLog, setShowLog] = React.useState(false);
  if (!v) return null;
  const st = v.state;
  const who = st === "speaking"
    ? (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 === "listening"
    ? (v.interim ? tail(v.interim) : "Speak when ready — a pause sends it.")
    : 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;
  const skippable = st === "speaking" || st === "thinking";
  return (
    <div className="vd-wrap" 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}>{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>
        {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 });
