/* ============================================================
   page-ask-ai.jsx — floating Ask AI widget (bottom-right) + VoiceWidget
   v16: admin AI model policy panel; user_can_change gating; GET+PUT /api/settings/ai-model-policy — diagnostic panel, 3 escalation paths,
        test-mic button, native dictation fallback, technical-info console
   v17: premium TTS via /api/ai/voice-chat/synthesize; POST-first with browser fallback;
   v18: VoiceWidget listens for voice-pref-change events so picker changes take effect immediately;
        VoiceSettingsSection extended with admin-only "Premium voice (paid)" panel
   ============================================================ */

const SUGGESTED_PROMPTS = [
  { icon:'pulse',    title:'Who is at risk of burnout?',     hint:'30-day hours and late patterns' },
  { icon:'alert',    title:'Summarize today\'s blockers',    hint:'Group by team, suggest owners' },
  { icon:'chart',    title:'Eng vs Ops attendance',          hint:'Past 30 days · outliers' },
  { icon:'pto',      title:'Who\'s out next week?',          hint:'Approved + tentative PTO' },
];

function AskAIWidget() {
  const D = window.STATUS_DATA;
  const [open, setOpen] = useState(false);
  const [messages, setMessages] = useState([
    { role:'system', content:"I'm Pulse. I can read all attendance, PTO (pending/approved/denied), strikes, standups (last 365 days), audit events (last 180 days), and live K9 presence. Flip on Action mode and I can also take admin actions for you — file time-off, approve/deny, manage strikes & roles, or message someone — and I'll always show what I'll do and ask before running it." }
  ]);
  const [input, setInput] = useState('');
  const [attachedServer, setAttachedServer] = useState(null);
  const [showServerPicker, setShowServerPicker] = useState(false);
  const [busy, setBusy] = useState(false);
  const [model, setModel] = useState('');
  const [modelGroups, setModelGroups] = useState([]);
  const [modelLocked, setModelLocked] = useState(false);
  const _meRole = (window.STATUS_DATA && window.STATUS_DATA.ME && window.STATUS_DATA.ME.org_role) || 'employee';
  const isAskAdmin = _meRole === 'admin';
  const [modelsLoading, setModelsLoading] = useState(true);
  const [noModels, setNoModels] = useState(false);
  const [userCanChange, setUserCanChange] = useState(true);
  const [showPolicyPanel, setShowPolicyPanel] = useState(false);
  const [policyLoading, setPolicyLoading] = useState(false);
  const [policyAllowChange, setPolicyAllowChange] = useState(true);
  const [policyDefaultModel, setPolicyDefaultModel] = useState('');
  const [policyAllowedModels, setPolicyAllowedModels] = useState([]);
  const [unread, setUnread] = useState(0);
  const [days, setDays] = useState(30);
  const scrollRef = useRef(null);
  // Action Mode — when on, send() routes through /api/ai/plan so the
  // assistant can ask clarifying Q/A chips and propose confirmable
  // actions instead of only answering in text. Off (default) keeps the
  // existing read-only /api/ask behaviour.
  const [actionMode, setActionMode] = useState(() => {
    try { return localStorage.getItem('aiActionMode') === '1'; } catch { return false; }
  });
  useEffect(() => {
    try { localStorage.setItem('aiActionMode', actionMode ? '1' : '0'); } catch {}
  }, [actionMode]);
  // Pending clarify follow-up answers, keyed by question id. Once all the
  // questions in a clarify block are answered, we POST the bundle back to
  // /api/ai/plan with `answers` set so the planner can propose the action.
  const [pendingAnswers, setPendingAnswers] = useState({});
  const [pendingNextAction, setPendingNextAction] = useState(null);
  const [pendingSeedQuestion, setPendingSeedQuestion] = useState('');

  // Saved / pinned / scheduled prompts (Stage 3c).
  const [savedPrompts, setSavedPrompts] = useState([]);
  const loadSavedPrompts = React.useCallback(() => {
    fetch('/api/ai/prompts', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : null)
      .then(d => { if (d && Array.isArray(d.prompts)) setSavedPrompts(d.prompts); })
      .catch(() => {});
  }, []);
  useEffect(() => { if (open) loadSavedPrompts(); }, [open, loadSavedPrompts]);
  const saveCurrentPrompt = async () => {
    const lastUser = [...messages].reverse().find(m => m.role === 'user' && typeof m.content === 'string');
    const seed = (input.trim() || (lastUser && lastUser.content) || '').trim();
    if (!seed) { alert('Type or ask something first, then save it.'); return; }
    const label = (window.prompt('Name this saved prompt:', seed.slice(0, 60)) || '').trim();
    if (!label) return;
    const sched = (window.prompt('Schedule? leave blank for none, or type: daily / weekly / monthly', '') || '').trim().toLowerCase();
    try {
      const r = await fetch('/api/ai/prompts', {
        method: 'POST', credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ label, prompt_text: seed, pinned: true, schedule: ['daily','weekly','monthly'].includes(sched) ? sched : '' }),
      });
      if (r.ok) loadSavedPrompts();
    } catch {}
  };
  const deleteSavedPrompt = async (id) => {
    try { const r = await fetch(`/api/ai/prompts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (r.ok) loadSavedPrompts(); } catch {}
  };

  // Mic mode for the inline chatbot mic button. Mirrors the Topbar toggle
  // (localStorage.voice_enabled). 'idle' shows the mic icon; 'recording'
  // means we're currently capturing; 'transcribing' means uploading to STT.
  const [voiceOn, setVoiceOn] = useState(() => localStorage.getItem('voice_enabled') === '1');
  const [micState, setMicState] = useState('idle');
  const [micErr, setMicErr] = useState('');
  const recRef = useRef(null);

  useEffect(() => {
    const h = () => setVoiceOn(localStorage.getItem('voice_enabled') === '1');
    window.addEventListener('voice-pref-change', h);
    return () => window.removeEventListener('voice-pref-change', h);
  }, []);

  // One-shot record → /api/ai/transcribe → setInput (user can review/send).
  const askMicRecord = async () => {
    if (micState !== 'idle') {
      // Click while recording = stop early.
      try { recRef.current?.stop(); } catch (_) {}
      return;
    }
    if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
      setMicErr('Recording not supported in this browser.');
      setTimeout(() => setMicErr(''), 4000);
      return;
    }
    setMicErr('');
    let stream;
    try {
      stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    } catch (_) {
      setMicErr('Mic permission denied.');
      setTimeout(() => setMicErr(''), 4000);
      return;
    }
    const tryTypes = ['audio/webm;codecs=opus','audio/webm','audio/mp4','audio/ogg;codecs=opus'];
    const mime = tryTypes.find(t => MediaRecorder.isTypeSupported(t)) || '';
    let rec;
    try {
      rec = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
    } catch (err) {
      setMicErr(`Recorder init failed: ${err?.message || err}`);
      setTimeout(() => setMicErr(''), 4000);
      stream.getTracks().forEach(t => t.stop());
      return;
    }
    recRef.current = rec;
    const chunks = [];
    let peakLevel = 0;
    rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };

    let audioCtx, analyser, srcNode, rafId;
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      srcNode = audioCtx.createMediaStreamSource(stream);
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 512;
      srcNode.connect(analyser);
      const buf = new Uint8Array(analyser.fftSize);
      const tick = () => {
        analyser.getByteTimeDomainData(buf);
        let sum = 0;
        for (let i = 0; i < buf.length; i++) {
          const v = (buf[i] - 128) / 128;
          sum += v * v;
        }
        const rms = Math.sqrt(sum / buf.length);
        if (rms > peakLevel) peakLevel = rms;
        rafId = requestAnimationFrame(tick);
      };
      tick();
    } catch (_) {}

    rec.onstop = async () => {
      try { cancelAnimationFrame(rafId); } catch (_) {}
      try { audioCtx?.close(); } catch (_) {}
      try { srcNode?.disconnect(); } catch (_) {}
      stream.getTracks().forEach(t => t.stop());
      recRef.current = null;
      const blob = new Blob(chunks, { type: rec.mimeType || mime || 'audio/webm' });
      if (peakLevel < 0.005) {
        setMicErr("Mic didn't pick up any audio. Check input device and that no other app is using it.");
        setMicState('idle');
        setTimeout(() => setMicErr(''), 6000);
        return;
      }
      if (blob.size < 500) {
        setMicErr('Recording too short — try again, speak 1-2 seconds.');
        setMicState('idle');
        setTimeout(() => setMicErr(''), 4000);
        return;
      }
      setMicState('transcribing');
      try {
        const ext = (blob.type.includes('mp4') ? 'mp4' : blob.type.includes('ogg') ? 'ogg' : 'webm');
        const fd = new FormData();
        fd.append('file', blob, `clip.${ext}`);
        const r = await fetch('/api/ai/transcribe', { method: 'POST', credentials: 'same-origin', body: fd });
        if (!r.ok) {
          const j = await r.json().catch(() => ({}));
          setMicErr(j?.detail || `Transcribe HTTP ${r.status}`);
          setTimeout(() => setMicErr(''), 5000);
          setMicState('idle');
          return;
        }
        const j = await r.json();
        const transcript = (j.transcript || '').trim();
        if (!transcript) {
          setMicErr("Whisper returned no transcript — speak clearer or longer.");
          setTimeout(() => setMicErr(''), 5000);
          setMicState('idle');
          return;
        }
        setInput(prev => (prev ? prev + ' ' : '') + transcript);
        setMicState('idle');
      } catch (err) {
        setMicErr(`Transcribe error: ${err?.message || err}`);
        setTimeout(() => setMicErr(''), 5000);
        setMicState('idle');
      }
    };
    rec.onerror = (e) => {
      setMicErr(`Recorder error: ${e?.error?.message || 'unknown'}`);
      setTimeout(() => setMicErr(''), 5000);
      setMicState('idle');
      stream.getTracks().forEach(t => t.stop());
    };
    rec.start();
    setMicState('recording');
    setTimeout(() => {
      if (rec.state === 'recording') { try { rec.stop(); } catch (_) {} }
    }, 7000);
  };

  useEffect(() => {
    setModelsLoading(true);
    fetch('/api/ai/model-lock', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : { locked: false })
      .then(d => setModelLocked(!!(d && d.locked)))
      .catch(() => {});
    fetch('/api/ai/models', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : null)
      .then(data => {
        if (!data || !data.groups || data.groups.length === 0) { setNoModels(true); return; }
        setModelGroups(data.groups);
        if (data.default_value) {
          setModel(data.default_value);
        } else {
          const first = data.groups[0]?.models?.[0]?.value || data.groups[0]?.options?.[0]?.value;
          if (first) setModel(first);
        }
        if (data.user_can_change === false) setUserCanChange(false);
      })
      .catch(() => setNoModels(true))
      .finally(() => setModelsLoading(false));
  }, []);

  useEffect(() => {
    if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
  }, [messages, busy, open]);

  useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') { e.preventDefault(); setOpen(o => !o); }
      if (e.key === 'Escape' && open) setOpen(false);
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [open]);

  useEffect(() => { if (open) setUnread(0); }, [open]);

  const send = async (text) => {
    const serverCtx = attachedServer ? `[Server: hostname=${attachedServer.hostname}, ip=${attachedServer.ip}, status=${attachedServer.status}, region=${attachedServer.region}]\n\n` : '';
    const q = (serverCtx + (text ?? input)).trim();
    if (!q || busy) return;
    // Conversation memory — replay the recent real turns (skip the system
    // intro, clarify/action/HUD bubbles) so Pulse answers follow-ups in context.
    const history = messages
      .filter(m => (m.role === 'user' || m.role === 'assistant')
        && typeof m.content === 'string' && m.content && !m.hudInfo && !m.error)
      .slice(-6)
      .map(m => ({ role: m.role, content: m.content }));
    setInput('');
    setMessages(m => [...m, { role:'user', content: q }]);
    setBusy(true);

    // HUD Phase 3 — fire HUD intent route in parallel with the LLM /api/ask
    // call. If the keyword router matches, the HUD pops with relevant
    // visuals while the long-form text answer is still cooking. The bot's
    // "Setting up the HUD with X…" message lands as a chat bubble.
    fetch('/api/ai/hud-route', {
      method: 'POST',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question: q }),
    }).then(r => r.ok ? r.json() : null).then(routed => {
      if (!routed || !routed.widgets || !routed.widgets.length) return;
      const HUD_WIDGETS = window.HUD_WIDGETS || {};
      const list = routed.widgets.map(id => HUD_WIDGETS[id]).filter(Boolean);
      if (list.length) {
        window.__hudState__ = { open: true, widgets: list, title: `From: "${q.slice(0, 60)}"` };
        window.dispatchEvent(new CustomEvent('hud-state-change', {
          detail: window.__hudState__,
        }));
      }
      // Surface the friendly "Setting up the HUD with X…" line as its own
      // chat bubble — distinct from the longer LLM reply that follows.
      setMessages(m => [...m, {
        role: 'assistant',
        content: `🛰  ${routed.message || 'Opening HUD…'}`,
        hudInfo: { widgets: routed.widgets, deep_link: routed.deep_link },
      }]);
    }).catch(() => {/* best-effort */});

    try {
      // ── Action mode → planner ────────────────────────────────────────
      // The planner can answer (regular text), ask multi-choice clarify
      // questions, or propose an action card for the user to confirm.
      if (actionMode) {
        const planResp = await fetch('/api/ai/plan', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'same-origin',
          body: JSON.stringify({
            question: q,
            follow_up_to: pendingNextAction || undefined,
            answers: Object.keys(pendingAnswers).length ? pendingAnswers : undefined,
          }),
        });
        if (!planResp.ok) {
          const errBody = await planResp.json().catch(() => null);
          throw new Error(errBody?.detail || `${planResp.status} ${planResp.statusText}`);
        }
        const plan = await planResp.json();
        if (plan.kind === 'answer') {
          setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
          setMessages(m => [...m, { role:'assistant', content: plan.text || '(no response)' }]);
        } else if (plan.kind === 'clarify') {
          setMessages(m => [...m, { role:'assistant', clarify: plan.questions }]);
        } else if (plan.kind === 'action_proposal') {
          setMessages(m => [...m, { role:'assistant', actionProposal: plan.action }]);
        } else {
          setMessages(m => [...m, { role:'assistant', content: JSON.stringify(plan) }]);
        }
        if (!open) setUnread(n => n + 1);
        return;
      }

      // ── Plain Q&A (default) ─────────────────────────────────────────
      const resp = await fetch('/api/ask', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ question: q, model: model || undefined, days, history }),
      });
      if (!resp.ok) {
        const errBody = await resp.json().catch(() => null);
        throw new Error(errBody?.detail || `${resp.status} ${resp.statusText}`);
      }
      const data = await resp.json();
      const answer = data.answer || data.text || '(no response)';
      const modelUsed = data.model_used || model;
      setMessages(m => [...m, { role:'assistant', content: answer, modelUsed }]);
      if (!open) setUnread(n => n + 1);
    } catch (err) {
      setMessages(m => [...m, { role:'assistant', content: err.message || 'Could not reach the AI provider. Check Admin → Providers.', error: true }]);
    } finally {
      setBusy(false);
    }
  };

  // Pick a clarify chip. We accumulate answers and re-call /api/ai/plan
  // immediately — the planner decides whether to ask the next question
  // or propose the action.
  const pickClarify = async (qid, value, nextAction) => {
    const nextAnswers = { ...pendingAnswers, [qid]: value };
    setPendingAnswers(nextAnswers);
    setPendingNextAction(nextAction);
    if (!pendingSeedQuestion) {
      // First time we hit a clarify in this thread — seed the original
      // question so the planner can re-classify against it.
      const lastUser = [...messages].reverse().find(m => m.role === 'user');
      setPendingSeedQuestion(lastUser?.content || nextAction || '');
    }
    setMessages(m => [...m, { role:'user', content: `→ ${value}` }]);
    setBusy(true);
    try {
      const seed = pendingSeedQuestion || nextAction || '';
      const planResp = await fetch('/api/ai/plan', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({
          question: seed, follow_up_to: nextAction, answers: nextAnswers,
        }),
      });
      const plan = await planResp.json();
      if (plan.kind === 'answer') {
        setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
        setMessages(m => [...m, { role:'assistant', content: plan.text }]);
      } else if (plan.kind === 'clarify') {
        setMessages(m => [...m, { role:'assistant', clarify: plan.questions }]);
      } else if (plan.kind === 'action_proposal') {
        setMessages(m => [...m, { role:'assistant', actionProposal: plan.action }]);
      }
    } catch (err) {
      setMessages(m => [...m, { role:'assistant', content: `Plan failed: ${err.message}`, error: true }]);
    } finally {
      setBusy(false);
    }
  };

  // Confirm + run a proposed action via /api/ai/execute.
  const runAction = async (action) => {
    setBusy(true);
    try {
      const r = await fetch('/api/ai/execute', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ name: action.name, args: action.args || {} }),
      });
      const d = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(d?.detail || `${r.status}`);
      setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
      setMessages(m => [...m, {
        role:'assistant',
        content: `✅ Done — ${action.summary || action.name}`,
        actionResult: d,
      }]);
    } catch (err) {
      setMessages(m => [...m, {
        role:'assistant',
        content: `❌ Action failed: ${err.message}`,
        error: true,
      }]);
    } finally {
      setBusy(false);
    }
  };

  const cancelAction = (idx) => {
    setMessages(m => m.map((mm, i) => i === idx
      ? { role:'assistant', content: '↩︎ Action cancelled.' }
      : mm));
    setPendingAnswers({}); setPendingNextAction(null); setPendingSeedQuestion('');
  };

  const reset = () => {
    setMessages([{ role:'system', content:'I have read access to all attendance history, PTO requests (pending, approved, denied), format-violation strikes, standup messages (last 365 days), audit events (last 180 days), and live K9 presence.' }]);
    setInput('');
  };

  const isAdminUser = !!(window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME) || (D?.ME?.org_role === 'manager')
    : (D && D.ME && (D.ME.is_admin || D.ME.org_role === 'admin' || D.ME.org_role === 'manager' || (D.ME.role || '').toLowerCase() === 'admin')));
  const canChangeModel = isAdminUser || userCanChange;

  const modelSelect = noModels ? null : (
    modelsLoading ? (
      <select className="sel ai-prov" disabled><option>Loading…</option></select>
    ) : !canChangeModel ? (
      <select className="sel ai-prov" disabled title="Model selection disabled by admin">
        {modelGroups.flatMap(g => (g.models || g.options || []).map(o => (
          <option key={o.value || o.id} value={o.value || o.id}>{o.label || o.name || o.value}</option>
        )))}
      </select>
    ) : modelGroups.length > 0 ? (
      <span style={{display:'inline-flex', gap:6, alignItems:'center'}}>
      {isAskAdmin && (
        <button type='button' className='btn btn-ghost' style={{padding:'4px 8px', fontSize:11}}
          title={modelLocked ? 'Click to unlock — let everyone pick a model' : 'Click to lock — only admins can change models'}
          onClick={async () => {
            const next = !modelLocked;
            try {
              const r = await fetch('/api/ai/model-lock', {method:'PUT', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body: JSON.stringify({locked: next})});
              if (r.ok) setModelLocked(next);
            } catch(_) {}
          }}>
          {modelLocked ? '🔒 model' : '🔓 model'}
        </button>
      )}
      <select className="sel ai-prov" value={model} onChange={e => setModel(e.target.value)} title={modelLocked ? "Model locked by admin" : "Model"} disabled={modelLocked && !isAskAdmin}>
        {modelGroups.map(g => {
          const opts = g.models || g.options || [];
          return (
            <optgroup key={g.label || g.provider} label={(g.label || g.provider) + (g.failover_role ? ' · ' + g.failover_role : '')}>
              {opts.map(o => (
                <option key={o.value || o.id} value={o.value || o.id}>{o.label || o.name || o.value}</option>
              ))}
            </optgroup>
          );
        })}
      </select>
      </span>
    ) : null
  );

  return (
    <>
      <button className={`ai-fab ${open ? 'on' : ''}`} onClick={() => setOpen(o => !o)} title="Ask Pulse (⌘J)" aria-label="Ask Pulse">
        <span className="ai-fab-glow" aria-hidden="true" />
        {open ? <Icon name="x" size={20} />
              : <img src="/static/v2/pulse-mascot.svg" alt="" style={{ width: 32, height: 32, borderRadius: '50%', display: 'block' }} />}
        {!open && unread > 0 && <span className="ai-fab-dot">{unread}</span>}
      </button>

      {open && (
        <>
          {/* Tap-outside backdrop — closes on mobile (and desktop), gives
              the chat panel the obvious "modal" affordance iOS users expect. */}
          <div
            className="ai-pop-backdrop"
            onClick={() => setOpen(false)}
            aria-hidden="true"
          />
          <div
            className="ai-pop"
            role="dialog"
            aria-label="Ask Pulse"
            onTouchStart={(e) => {
              // swipe-down-to-dismiss on the header area only
              const t = e.touches[0];
              const target = e.target;
              if (target && target.closest && target.closest('.ai-pop-head')) {
                e.currentTarget.dataset.dragStartY = String(t.clientY);
              }
            }}
            onTouchMove={(e) => {
              const startY = parseFloat(e.currentTarget.dataset.dragStartY || '');
              if (isNaN(startY)) return;
              const dy = e.touches[0].clientY - startY;
              if (dy > 0) {
                e.currentTarget.style.transform = `translateY(${Math.min(dy, 400)}px)`;
              }
            }}
            onTouchEnd={(e) => {
              const startY = parseFloat(e.currentTarget.dataset.dragStartY || '');
              if (isNaN(startY)) return;
              const lastTouch = e.changedTouches[0];
              const dy = lastTouch ? lastTouch.clientY - startY : 0;
              e.currentTarget.style.transform = '';
              delete e.currentTarget.dataset.dragStartY;
              if (dy > 90) setOpen(false);
            }}
          >
          <div className="ai-pop-grab" aria-hidden="true" />
          <div className="ai-pop-head">
            <div className="ai-pop-title">
              <div className="ai-pop-ic" style={{ padding: 0, overflow: 'hidden', background: 'transparent', border: 'none' }}>
                <img src="/static/v2/pulse-mascot.svg" alt="Pulse" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
              </div>
              <div>
                <div className="ai-pop-name" style={{ whiteSpace: 'nowrap' }}>Ask Pulse</div>
                <div className="ai-pop-sub" style={{ whiteSpace: 'nowrap' }}>
                  <span className="pulse" />
                  {isAdminUser
                    ? (actionMode ? 'Read · Write · Execute' : 'Read · Write · Execute · read mode')
                    : 'Read-only · org-scoped'}
                </div>
              </div>
            </div>
            <div className="ai-pop-actions">
              {noModels ? (
                <span className="muted" style={{ fontSize: 11 }}>
                  <a href="#" onClick={e => {
                    e.preventDefault();
                    setOpen(false);
                    try {
                      if (typeof window.__navigate === 'function') window.__navigate('providers');
                      else window.dispatchEvent(new CustomEvent('nav-to', { detail: { page: 'providers' } }));
                    } catch (_) {}
                  }} style={{ color:'var(--brand)', cursor:'pointer' }}>No models active — configure in Providers →</a>
                </span>
              ) : modelSelect}
              {isAdminUser && (
                <button className="btn btn-ghost btn-icon" title="AI model policy" onClick={() => {
                  setShowPolicyPanel(p => {
                    if (!p) {
                      setPolicyLoading(true);
                      fetch('/api/settings/ai-model-policy', { credentials: 'same-origin' })
                        .then(r => r.ok ? r.json() : null)
                        .then(d => {
                          if (d) {
                            setPolicyAllowChange(d.user_model_change_allowed);
                            setPolicyDefaultModel(d.default_model || '');
                            setPolicyAllowedModels(d.allowed_models || []);
                          }
                        })
                        .finally(() => setPolicyLoading(false));
                    }
                    return !p;
                  });
                }}><Icon name="settings" size={13}/></button>
              )}
              <button className="btn btn-ghost btn-icon" onClick={reset} title="New chat"><Icon name="refresh" size={13}/></button>
              <button className="btn btn-ghost btn-icon ai-pop-close" onClick={() => setOpen(false)} title="Close (Esc)" aria-label="Close Pulse"><Icon name="x" size={16}/></button>
            </div>
          </div>

          {isAdminUser && showPolicyPanel && (
            <div style={{ padding:'10px 14px', borderBottom:'1px solid var(--border)', background:'var(--bg-2)', fontSize:12 }}>
              <div style={{ fontWeight:600, marginBottom:8, color:'var(--text-1)' }}>AI Model Policy</div>
              {policyLoading ? (
                <div style={{ color:'var(--text-3)' }}>Loading…</div>
              ) : (
                <>
                  <label style={{ display:'flex', alignItems:'center', gap:6, marginBottom:8, cursor:'pointer' }}>
                    <input type="checkbox" checked={policyAllowChange} onChange={e => setPolicyAllowChange(e.target.checked)} />
                    Allow non-admin users to change AI model
                  </label>
                  <div style={{ marginBottom:6 }}>
                    <div style={{ color:'var(--text-2)', marginBottom:4 }}>Default model</div>
                    <select className="sel" style={{ fontSize:11 }} value={policyDefaultModel} onChange={e => setPolicyDefaultModel(e.target.value)}>
                      <option value="">(use active provider default)</option>
                      {modelGroups.flatMap(g => (g.models || g.options || []).map(o => (
                        <option key={o.value || o.id} value={o.value || o.id}>{o.label || o.name || o.value}</option>
                      )))}
                    </select>
                  </div>
                  <div style={{ marginBottom:8 }}>
                    <div style={{ color:'var(--text-2)', marginBottom:4 }}>Allowed models (empty = all)</div>
                    <div style={{ display:'flex', flexWrap:'wrap', gap:4 }}>
                      {modelGroups.flatMap(g => (g.models || g.options || []).map(o => {
                        const v = o.value || o.id;
                        const checked = policyAllowedModels.includes(v);
                        return (
                          <label key={v} style={{ display:'flex', alignItems:'center', gap:3, fontSize:10, cursor:'pointer', background:'var(--bg-1)', padding:'2px 6px', borderRadius:4, border:'1px solid var(--border)' }}>
                            <input type="checkbox" checked={checked} onChange={e => {
                              setPolicyAllowedModels(prev => e.target.checked ? [...prev, v] : prev.filter(x => x !== v));
                            }} />
                            {o.label || o.name || v}
                          </label>
                        );
                      }))}
                    </div>
                  </div>
                  <button className="btn btn-primary" style={{ fontSize:11, padding:'3px 10px' }} onClick={() => {
                    fetch('/api/settings/ai-model-policy', {
                      method: 'PUT',
                      headers: { 'Content-Type': 'application/json' },
                      credentials: 'same-origin',
                      body: JSON.stringify({
                        user_model_change_allowed: policyAllowChange,
                        default_model: policyDefaultModel || null,
                        allowed_models: policyAllowedModels,
                      }),
                    }).then(r => r.ok ? r.json() : null).then(d => {
                      if (d?.ok) {
                        setUserCanChange(policyAllowChange);
                        setShowPolicyPanel(false);
                      }
                    });
                  }}>Save policy</button>
                </>
              )}
            </div>
          )}

          <div className="ai-pop-list" ref={scrollRef}>
            {messages.map((m, i) => (
              <div key={i} className={`chat-msg chat-${m.role}`}>
                {m.role === 'system' ? (
                  <div className="chat-system"><Icon name="sparkle" size={13}/> {m.content}</div>
                ) : (
                  <>
                    {m.role === 'assistant' && <div className="chat-avatar"><Icon name="ai" size={12}/></div>}
                    <div className={`chat-bubble${m.error ? ' chat-bubble-err' : ''}`}>
                      {m.error && <Badge kind="err" style={{marginBottom: 6}}>error</Badge>}
                      {/* Plain text answer */}
                      {!m.clarify && !m.actionProposal && (
                        <div className="chat-body">{m.content}</div>
                      )}
                      {/* Q/A clarify chips — planner is asking for more info */}
                      {m.clarify && (
                        <div style={{ display:'flex', flexDirection:'column', gap: 10 }}>
                          {m.clarify.map((qq, qi) => (
                            <div key={qi}>
                              <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 6, color:'var(--text)' }}>
                                {qq.prompt}
                              </div>
                              {qq.free_text ? (
                                <form onSubmit={e => {
                                  e.preventDefault();
                                  const v = e.target.elements.ftxt.value.trim();
                                  if (v) pickClarify(qq.id, v, qq.next_action);
                                }} style={{ display:'flex', gap: 6 }}>
                                  <input className="inp" name="ftxt" autoFocus
                                    style={{ flex: 1, fontSize: 12.5 }}
                                    placeholder="Type your answer…" />
                                  <button type="submit" className="btn btn-pri" style={{ fontSize: 12 }}>Send</button>
                                </form>
                              ) : (
                                <div style={{ display:'flex', flexWrap:'wrap', gap: 6 }}>
                                  {(qq.choices || []).map(c => (
                                    <button key={c.value} type="button"
                                      onClick={() => pickClarify(qq.id, c.value, qq.next_action)}
                                      style={{
                                        fontSize: 11.5, padding:'5px 10px',
                                        border:'1px solid var(--line-hi)',
                                        background:'var(--bg-elev-2)',
                                        color:'var(--text)',
                                        borderRadius: 999, cursor:'pointer',
                                      }}>
                                      {c.label}
                                    </button>
                                  ))}
                                </div>
                              )}
                            </div>
                          ))}
                        </div>
                      )}
                      {/* Action proposal card — Run / Cancel */}
                      {m.actionProposal && (
                        <div>
                          <div style={{
                            display:'flex', alignItems:'center', gap: 6, marginBottom: 6,
                            fontSize: 10.5, color:'var(--text-3)', textTransform:'uppercase',
                            letterSpacing: '0.06em',
                          }}>
                            <span style={{
                              display:'inline-block', width: 6, height: 6, borderRadius:'50%',
                              background: m.actionProposal.destructive ? 'var(--err)' : 'var(--brand)',
                            }}/>
                            Action proposal · {m.actionProposal.name}
                          </div>
                          <div style={{ fontSize: 13, marginBottom: 10, color:'var(--text)' }}>
                            {m.actionProposal.summary}
                          </div>
                          <div style={{ display:'flex', gap: 6 }}>
                            <button className="btn btn-pri"
                              style={{ fontSize: 12 }}
                              onClick={() => runAction(m.actionProposal)}>
                              <Icon name="check" size={12}/> {m.actionProposal.confirm_label || 'Run'}
                            </button>
                            <button className="btn btn-ghost"
                              style={{ fontSize: 12 }}
                              onClick={() => cancelAction(i)}>
                              Cancel
                            </button>
                          </div>
                        </div>
                      )}
                      {m.modelUsed && !m.error && (
                        <div style={{ fontSize: 10, color:'var(--text-3)', marginTop: 6 }} className="mono">{m.modelUsed}</div>
                      )}
                    </div>
                  </>
                )}
              </div>
            ))}
            {busy && (
              <div className="chat-msg chat-assistant">
                <div className="chat-avatar"><Icon name="ai" size={12}/></div>
                <div className="chat-bubble"><div className="ai-typing"><span/><span/><span/></div></div>
              </div>
            )}
          </div>

          {messages.length <= 1 && !busy && (
            <div className="ai-pop-suggest">
              <div className="ai-pop-suggest-lbl">Try</div>
              <div className="ai-pop-suggest-grid">
                {SUGGESTED_PROMPTS.map(p => (
                  <button key={p.title} className="ai-pop-suggest-card" onClick={() => send(p.title)}>
                    <Icon name={p.icon} size={13} />
                    <div><div className="t">{p.title}</div><div className="h">{p.hint}</div></div>
                  </button>
                ))}
              </div>
              {savedPrompts.length > 0 && (
                <div style={{ marginTop: 12 }}>
                  <div className="ai-pop-suggest-lbl">Saved</div>
                  <div style={{ display:'flex', flexWrap:'wrap', gap:6, marginTop:6 }}>
                    {savedPrompts.map(sp => (
                      <span key={sp.id} style={{ display:'inline-flex', alignItems:'center', gap:6, background:'var(--bg-1)', border:'1px solid var(--border)', borderRadius:14, padding:'4px 6px 4px 11px', fontSize:12 }}>
                        <button onClick={() => send(sp.prompt_text)} title={sp.prompt_text}
                          style={{ background:'none', border:'none', color:'var(--text-1)', cursor:'pointer', padding:0, font:'inherit' }}>
                          {sp.pinned ? '📌 ' : ''}{sp.label}{sp.schedule ? ` · ${sp.schedule}` : ''}
                        </button>
                        <button onClick={() => deleteSavedPrompt(sp.id)} title="Delete saved prompt"
                          style={{ background:'none', border:'none', color:'var(--text-3)', cursor:'pointer', padding:'0 2px', lineHeight:1, fontSize:14 }}>×</button>
                      </span>
                    ))}
                  </div>
                </div>
              )}
            </div>
          )}

          <div className="ai-pop-input-wrap">
            {/* Action Mode toggle — when on, the assistant uses Q/A
                clarification + confirmable action cards instead of
                plain answers. */}
            <div style={{
              display:'flex', alignItems:'center', gap: 8, padding:'4px 10px 6px',
              fontSize: 11, color:'var(--text-3)',
            }}>
              <label style={{ display:'flex', alignItems:'center', gap: 6, cursor:'pointer' }}>
                <input type="checkbox" checked={actionMode}
                  onChange={e => setActionMode(e.target.checked)}
                  style={{ accentColor: 'var(--brand)' }} />
                <span style={{ color: actionMode ? 'var(--brand)' : 'var(--text-3)', fontWeight: 500 }}>
                  Action mode
                </span>
              </label>
              <button onClick={saveCurrentPrompt} title="Save this prompt (pin / schedule)"
                style={{ background:'none', border:'none', color:'var(--text-3)', cursor:'pointer', fontSize:11, padding:0 }}>
                📌 Save
              </button>
              <span style={{ flex: 1, textAlign:'right', fontSize: 10.5 }}>
                {actionMode
                  ? 'Acting — Pulse confirms before any change'
                  : 'Read-only — toggle to let Pulse act'}
              </span>
            </div>
            <div className="ai-pop-input">
              <textarea
                value={input} onChange={e => setInput(e.target.value)}
                placeholder={
                  noModels ? 'Configure a provider first…' :
                  actionMode ? 'Try: "approve pto", "run report", "dm Marina hi"' :
                  'Ask anything about your team…'
                }
                disabled={noModels}
                onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
                rows={1} autoFocus
              />
              {voiceOn && (
                <button
                  className="ai-pop-send"
                  onClick={askMicRecord}
                  disabled={micState === 'transcribing'}
                  title={
                    micState === 'recording' ? 'Recording — click to stop' :
                    micState === 'transcribing' ? 'Transcribing…' :
                    'Tap to record (≤7s, transcribed via Whisper)'
                  }
                  style={{
                    marginRight: 2,
                    background: micState === 'recording' ? 'var(--err, #e44)' : undefined,
                    color: micState === 'recording' ? '#fff' : undefined,
                    animation: micState === 'recording' ? 'pulse 1.2s infinite' : undefined,
                  }}
                >
                  {micState === 'transcribing'
                    ? <span className="ai-typing" style={{display:'inline-flex',gap:2}}><span/><span/><span/></span>
                    : <Icon name="mic" size={13}/>}
                </button>
              )}
              <button
                className="ai-pop-send"
                onClick={() => setShowServerPicker(p => !p)}
                title="Attach VPSie server context"
                style={{ opacity: attachedServer ? 1 : 0.55, marginRight: 2 }}
              >
                {attachedServer ? <span style={{fontSize:10, fontWeight:700, color:'var(--brand)'}}>SRV</span> : <span style={{fontSize:11}}>&#128204;</span>}
              </button>
              <button className="ai-pop-send" onClick={() => send()} disabled={busy || !input.trim() || noModels} title="Send (↵)">
                {busy ? <span className="ai-typing" style={{display:'inline-flex',gap:2}}><span/><span/><span/></span> : <Icon name="send" size={13}/>}
              </button>
            </div>
            {micErr && (
              <div style={{ color:'var(--err)', fontSize:11, padding:'4px 12px 0' }}>{micErr}</div>
            )}
            <div className="ai-pop-tip">
              <span className="mono"><span className="kbd-inline">↵</span> send · <span className="kbd-inline">⇧↵</span> newline · <span className="kbd-inline">⌘J</span> toggle</span>
            </div>
          </div>
          {showServerPicker && (
            <div style={{ padding: '8px 12px 4px', borderTop: '1px solid var(--line)', background: 'var(--bg-elev-1)' }}>
              <div style={{ fontSize: 11, color: 'var(--text-3)', marginBottom: 6 }}>Attach server context to question:</div>
              <VPSieServerSelect
                value={attachedServer ? String(attachedServer.id) : ''}
                onChange={(id, allServers) => {
                  if (!id) { setAttachedServer(null); setShowServerPicker(false); return; }
                  // Fetch servers list to find full server object
                  fetch('/api/me/vpsie/servers', { credentials: 'include' })
                    .then(r => r.ok ? r.json() : { servers: [] })
                    .then(d => {
                      const s = (d.servers || []).find(x => String(x.id) === String(id));
                      setAttachedServer(s || { id, hostname: id, ip: '', status: '', region: '' });
                      setShowServerPicker(false);
                    })
                    .catch(() => { setAttachedServer({ id, hostname: id, ip: '', status: '', region: '' }); setShowServerPicker(false); });
                }}
                placeholder="Select a server to attach…"
              />
              {attachedServer && (
                <button onClick={() => setAttachedServer(null)} style={{ marginTop: 4, fontSize: 11, color: 'var(--err)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
                  Remove attached server
                </button>
              )}
            </div>
          )}
          </div>
        </>
      )}
    </>
  );
}

/* ============================================================
   VoiceWidget — V1-V10 full spec implementation
   ============================================================ */

// Short 200ms 440Hz beep on wake detection
const WAKE_BEEP_B64 = (() => {
  try {
    const sr = 8000, freq = 440, dur = 0.2;
    const n = Math.floor(sr * dur);
    const buf = new Uint8Array(44 + n * 2);
    const dv = new DataView(buf.buffer);
    const ws = (s, off) => { for (let i=0;i<s.length;i++) dv.setUint8(off+i, s.charCodeAt(i)); };
    ws('RIFF',0); dv.setUint32(4,36+n*2,true); ws('WAVE',8); ws('fmt ',12);
    dv.setUint32(16,16,true); dv.setUint16(20,1,true); dv.setUint16(22,1,true);
    dv.setUint32(24,sr,true); dv.setUint32(28,sr*2,true);
    dv.setUint16(32,2,true); dv.setUint16(34,16,true);
    ws('data',36); dv.setUint32(40,n*2,true);
    for (let i=0;i<n;i++) {
      const v = Math.round(0.4*32767*Math.sin(2*Math.PI*freq*i/sr));
      dv.setInt16(44+i*2,v,true);
    }
    let bin = ''; buf.forEach(b => bin += String.fromCharCode(b));
    return 'data:audio/wav;base64,' + btoa(bin);
  } catch { return null; }
})();

function playWakeBeep() {
  if (!WAKE_BEEP_B64) return;
  try { const a = new Audio(WAKE_BEEP_B64); a.volume = 0.4; a.play().catch(()=>{}); } catch {}
}

// Default wake-word patterns (English)
const DEFAULT_WAKE_PATTERNS = [
  /hey\s+chat\s*-?\s*bot/i,
  /hi\s+chat\s*-?\s*bot/i,
  /hello\s+chat\s*-?\s*bot/i,
  /hey\s+bot/i,
  /hi\s+bot/i,
];
// Arabic wake-word patterns (V10)
const ARABIC_WAKE_PATTERNS = [
  /يا\s+شات\s*بوت/,
  /هاي\s+شات\s*بوت/,
  /تشات\s*بوت/,
];
// End-of-turn cue phrases (V2) — finalize immediately when detected at end of utterance
const EOT_CUES = ['okay bot', 'go ahead', 'answer', 'go ahead bot', 'thats it', "that's it"];

function stripWakeWord(t, patterns) {
  let s = t;
  for (const p of patterns) s = s.replace(p, '');
  return s.trim();
}

function stripEOTCue(t) {
  let s = t.trim();
  for (const cue of EOT_CUES) {
    const idx = s.toLowerCase().lastIndexOf(cue);
    if (idx !== -1 && idx >= s.length - cue.length - 2) {
      s = s.slice(0, idx).trim();
      break;
    }
  }
  return s;
}

function isWakeWord(transcript, customWakeWords = []) {
  // Check default patterns
  if (DEFAULT_WAKE_PATTERNS.some(p => p.test(transcript))) return true;
  if (ARABIC_WAKE_PATTERNS.some(p => p.test(transcript))) return true;
  // Check custom wake words (case-insensitive substring match)
  const lower = transcript.toLowerCase();
  return customWakeWords.some(w => w && lower.includes(w.toLowerCase()));
}

function hasEOTCue(transcript) {
  const lower = transcript.toLowerCase().trim();
  return EOT_CUES.some(cue => lower.endsWith(cue) || lower.endsWith(cue + '.') || lower.endsWith(cue + '?'));
}

// V3 — Curated voices with fallback resolution
function resolveCuratedVoice(voices, key) {
  if (!voices || !voices.length) return null;
  if (key === 'nadia') {
    // Nadia persona: warm female voice. Prefer modern macOS/Chrome
    // "Samantha" / "Karen" / "Victoria" / "Allison" — fall back to any
    // en-US/en-GB female-flagged voice. If none matched, the browser
    // default voice is used (no `utt.voice` set).
    return voices.find(v => v.name === 'Samantha')
      || voices.find(v => v.name === 'Karen')
      || voices.find(v => v.name === 'Allison')
      || voices.find(v => v.name === 'Victoria')
      || voices.find(v => v.name.toLowerCase().includes('nadia'))
      || voices.find(v => /(en-US|en-GB).*female/i.test((v.lang||'') + ' ' + (v.name||'')))
      || voices.find(v => v.lang && v.lang.startsWith('en-'))
      || null;
  }
  if (key === 'uk-female') {
    return voices.find(v => v.name.includes('UK English Female'))
      || voices.find(v => v.lang && v.lang.startsWith('en-GB') && v.name.toLowerCase().includes('female'))
      || voices.find(v => v.lang && v.lang.startsWith('en-GB'))
      || null;
  }
  if (key === 'uk-male') {
    return voices.find(v => v.name.includes('UK English Male'))
      || voices.find(v => v.lang && v.lang.startsWith('en-GB') && v.name.toLowerCase().includes('male'))
      || null;
  }
  if (key === 'us-female') {
    return voices.find(v => v.name.includes('US English Female'))
      || voices.find(v => v.name === 'Samantha')
      || voices.find(v => v.lang && v.lang.startsWith('en-US') && v.name.toLowerCase().includes('female'))
      || voices.find(v => v.lang && v.lang.startsWith('en-US'))
      || null;
  }
  if (key === 'us-male') {
    return voices.find(v => v.name.includes('US English Male'))
      || voices.find(v => v.name === 'Daniel' || v.name === 'Alex')
      || voices.find(v => v.lang && v.lang.startsWith('en-US') && v.name.toLowerCase().includes('male'))
      || null;
  }
  if (key === 'ar') {
    return voices.find(v => v.lang && (v.lang.startsWith('ar') || v.lang === 'ar-SA'))
      || null;
  }
  return null;
}

const CURATED_VOICES = [
  { key: 'uk-male',   label: 'UK Male (default)' },
  { key: 'uk-female', label: 'UK Female' },
  { key: 'us-female', label: 'US Female' },
  { key: 'us-male',   label: 'US Male' },
  { key: 'nadia',     label: 'Nadia (warm female)' },
];

// V6 — Waveform visualizer
function WaveformCanvas({ mediaStream }) {
  const canvasRef = useRef(null);
  const rafRef = useRef(null);
  const analyserRef = useRef(null);

  useEffect(() => {
    if (!mediaStream) {
      // Draw flat line
      const canvas = canvasRef.current;
      if (canvas) {
        const ctx = canvas.getContext('2d');
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.strokeStyle = 'var(--text-3)';
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.moveTo(0, canvas.height/2);
        ctx.lineTo(canvas.width, canvas.height/2);
        ctx.stroke();
      }
      return;
    }
    try {
      const ac = new (window.AudioContext || window.webkitAudioContext)();
      const source = ac.createMediaStreamSource(mediaStream);
      const analyser = ac.createAnalyser();
      analyser.fftSize = 64;
      source.connect(analyser);
      analyserRef.current = analyser;
      const data = new Uint8Array(analyser.frequencyBinCount);
      const draw = () => {
        rafRef.current = requestAnimationFrame(draw);
        const canvas = canvasRef.current;
        if (!canvas) return;
        analyser.getByteFrequencyData(data);
        const ctx = canvas.getContext('2d');
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        const barW = canvas.width / data.length;
        ctx.fillStyle = 'var(--brand)';
        for (let i = 0; i < data.length; i++) {
          const h = (data[i] / 255) * canvas.height;
          ctx.fillRect(i * barW, canvas.height - h, barW - 1, h);
        }
      };
      draw();
      return () => {
        cancelAnimationFrame(rafRef.current);
        ac.close().catch(()=>{});
      };
    } catch {
      // AudioContext not available — handled by fallback in render
    }
  }, [mediaStream]);

  return (
    <canvas
      ref={canvasRef}
      width={320}
      height={40}
      style={{ width:'100%', height:40, borderRadius:4, background:'var(--bg-1)', display:'block' }}
    />
  );
}

function VoiceWidget() {
  const D = window.STATUS_DATA;
  const isAdmin = !!(window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME)
    : (D && D.ME && (D.ME.is_admin || D.ME.org_role === 'admin' || (D.ME.role || '').toLowerCase() === 'admin')));

  // V9: voice mode starts OFF on every page load (do NOT load from localStorage)
  const [voiceEnabled, setVoiceEnabled] = useState(false);
  // status: idle | listening | capturing | thinking | speaking | blocked
  const [status, setStatus] = useState('idle');
  const [errMsg, setErrMsg] = useState('');
  const [voices, setVoices] = useState([]);
  // V3: curated voice key — persisted
  const [selectedVoiceKey, setSelectedVoiceKey] = useState(() => {
    try { return localStorage.getItem('voice_persona_key') || 'uk-male'; } catch { return 'uk-male'; }
  });
  // V10: language — persisted
  const [voiceLang, setVoiceLang] = useState(() => {
    try { return localStorage.getItem('voice_language') || 'en-US'; } catch { return 'en-US'; }
  });
  // V1: custom wake words — persisted
  const [customWakeWords, setCustomWakeWords] = useState(() => {
    try { return JSON.parse(localStorage.getItem('voice_wake_words') || '[]'); } catch { return []; }
  });
  const [newWakeWord, setNewWakeWord] = useState('');

  // V6: overlay
  const [captureText, setCaptureText] = useState(''); // live interim transcript
  const [botReply, setBotReply] = useState('');
  const [overlayErr, setOverlayErr] = useState('');
  const [mediaStream, setMediaStream] = useState(null);

  // V7: auto-relisten countdown
  const [sleepCountdown, setSleepCountdown] = useState(null);
  const sleepTimerRef = useRef(null);
  const sleepCountdownRef = useRef(null);

  // v14: modal state — 'preflight' | 'blocked' | 'notfound' | null
  const [modal, setModal] = useState(null);
  // v14: track stop-confirm dialog
  const [stopConfirm, setStopConfirm] = useState(false);
  // v15: blocked-modal diagnostic state
  const [diagResult, setDiagResult] = useState(null);   // null | object
  const [diagLoading, setDiagLoading] = useState(false);
  const [testMicResult, setTestMicResult] = useState(null); // null | { ok, name, message }
  const [testMicRunning, setTestMicRunning] = useState(false);
  const [resetCopied, setResetCopied] = useState(false);
  const [chromeCopied, setChromeCopied] = useState(false);
  const [showResetSteps, setShowResetSteps] = useState(false);

  const recognitionRef = useRef(null);
  const recognitionRunRef = useRef(0);
  const stopRequestedRef = useRef(false);
  // BUG FIX (mic-not-capturing): mirror of mediaStream as a ref so the
  // startListeningInternal() function can synchronously check whether a
  // live track already exists, without depending on stale state closure.
  const mediaStreamRef = useRef(null);
  const voiceEnabledRef = useRef(voiceEnabled);
  const statusRef = useRef(status);
  const captureTimerRef = useRef(null);
  const silenceTimerRef = useRef(null);
  const captureTranscriptRef = useRef('');
  const wakingRef = useRef(false);
  const autoRelistenRef = useRef(false); // V7: true = auto-relisten mode (no wake word needed)
  // v17: cached premium TTS status {enabled, fetchedAt}
  const premiumTtsRef = useRef({ enabled: false, fetchedAt: 0 });

  useEffect(() => { voiceEnabledRef.current = voiceEnabled; }, [voiceEnabled]);
  useEffect(() => { statusRef.current = status; }, [status]);

  // Load TTS voices
  useEffect(() => {
    const load = () => {
      const v = window.speechSynthesis ? window.speechSynthesis.getVoices() : [];
      if (v.length) setVoices(v);
    };
    load();
    if (window.speechSynthesis) window.speechSynthesis.onvoiceschanged = load;
    return () => { if (window.speechSynthesis) window.speechSynthesis.onvoiceschanged = null; };
  }, []);

  // v18: sync voice prefs when user changes them in settings (VoiceSettingsSection dispatches this)
  useEffect(() => {
    const onPrefChange = (e) => {
      const { key, value } = e.detail || {};
      if (key === 'voice_persona_key') setSelectedVoiceKey(value);
      if (key === 'voice_language') setVoiceLang(value);
    };
    window.addEventListener('voice-pref-change', onPrefChange);
    return () => window.removeEventListener('voice-pref-change', onPrefChange);
  }, []);

  // V9: stop on logout — watch for session expiry
  useEffect(() => {
    const onLogout = () => {
      if (voiceEnabled) {
        setVoiceEnabled(false);
        stopListening();
        if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
      }
    };
    window.addEventListener('voice-logout', onLogout);
    const origFetch = window._origFetch || window.fetch;
    if (!window._voiceFetchPatched) {
      window._voiceFetchPatched = true;
      window._origFetch = window.fetch;
      window.fetch = function(...args) {
        const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '');
        if (url.includes('/api/auth/logout')) {
          window.dispatchEvent(new Event('voice-logout'));
        }
        return origFetch.apply(this, args);
      };
    }
    return () => window.removeEventListener('voice-logout', onLogout);
  }, [voiceEnabled, mediaStream]);

  // v17: browser TTS fallback (used when premium is off or fails)
  const speakBrowser = (text, onEnd) => {
    if (!window.speechSynthesis || !text) { if (onEnd) onEnd(); return; }
    window.speechSynthesis.cancel();
    const utt = new SpeechSynthesisUtterance(text);
    utt.lang = voiceLang;
    const isArabic = voiceLang.startsWith('ar');
    const voiceKey = isArabic ? 'ar' : selectedVoiceKey;
    const resolved = resolveCuratedVoice(voices, voiceKey);
    if (resolved) utt.voice = resolved;
    utt.rate = 1.0;
    utt.pitch = 1.0;
    utt.onstart = () => {
      if (stopRequestedRef.current || !voiceEnabledRef.current) return;
      setStatus('speaking');
    };
    const finishSpeech = () => {
      setStatus('idle');
      if (!stopRequestedRef.current && voiceEnabledRef.current && onEnd) onEnd();
    };
    utt.onend = finishSpeech;
    utt.onerror = finishSpeech;
    console.log('Speaking with voice:', utt.voice?.name || '(browser default)', '| key:', voiceKey);
    window.speechSynthesis.speak(utt);
  };

  // v17: premium TTS status (cached 30s to avoid per-utterance roundtrips)
  const _fetchPremiumStatus = async () => {
    const now = Date.now();
    if (now - premiumTtsRef.current.fetchedAt < 30000) return premiumTtsRef.current.enabled;
    try {
      const r = await fetch('/api/ai/tts/status', { credentials: 'same-origin' });
      if (r.ok) {
        const d = await r.json();
        premiumTtsRef.current = { enabled: !!d.enabled, fetchedAt: now };
        return !!d.enabled;
      }
    } catch { /* ignore */ }
    premiumTtsRef.current = { enabled: false, fetchedAt: now };
    return false;
  };

  // v17: main speak entry point — tries premium TTS first, falls back to browser
  const speakReply = (text, onEnd) => {
    if (!text) { if (onEnd) onEnd(); return; }
    setStatus('speaking');

    _fetchPremiumStatus().then(premiumEnabled => {
      if (!premiumEnabled) {
        speakBrowser(text, onEnd);
        return;
      }
      // Attempt premium TTS via backend
      fetch('/api/ai/voice-chat/synthesize', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ text }),
      })
        .then(async r => {
          if (!r.ok) {
            // Not available (disabled, limit hit, error) — fall back silently
            speakBrowser(text, onEnd);
            return;
          }
          const blob = await r.blob();
          const url = URL.createObjectURL(blob);
          const audio = new Audio(url);
          audio.onended = () => {
            URL.revokeObjectURL(url);
            setStatus('idle');
            if (!stopRequestedRef.current && voiceEnabledRef.current && onEnd) onEnd();
          };
          audio.onerror = () => {
            URL.revokeObjectURL(url);
            // Fall back to browser TTS on audio decode error
            speakBrowser(text, onEnd);
          };
          audio.play().catch(() => speakBrowser(text, onEnd));
        })
        .catch(() => speakBrowser(text, onEnd));
    });
  };

  const doVoiceChat = async (transcript) => {
    setStatus('thinking');
    setCaptureText('');
    setBotReply('');
    setOverlayErr('');

    // HUD Phase 3 — fire-and-forget HUD intent route in parallel.
    // The bot says "Setting up the HUD with X…" immediately so the admin
    // gets visuals while the longer voice-chat reply is still cooking.
    // Fails silently if the keyword router can't match — full voice-chat
    // reply still lands as normal.
    fetch('/api/ai/hud-route', {
      method: 'POST',
      credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ question: transcript }),
    }).then(r => r.ok ? r.json() : null).then(routed => {
      if (!routed || !routed.widgets || !routed.widgets.length) return;
      // Open the HUD immediately with the picked widgets.
      const HUD_WIDGETS = window.HUD_WIDGETS || {};
      const list = routed.widgets.map(id => HUD_WIDGETS[id]).filter(Boolean);
      if (list.length && typeof window.__hudState__ === 'object') {
        const evt = new CustomEvent('hud-state-change', {
          detail: { open: true, widgets: list, title: `From: "${transcript.slice(0, 60)}"` },
        });
        window.__hudState__ = { open: true, widgets: list, title: `From: "${transcript.slice(0, 60)}"` };
        window.dispatchEvent(evt);
      }
      // Speak the short feedback message before the richer reply arrives.
      try { speakReply(routed.message || 'Setting up the HUD…', () => {}); } catch (_) {}
    }).catch(() => {/* best-effort */});

    try {
      const r = await fetch('/api/ai/voice-chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ transcript }),
      });
      if (!r.ok) {
        const errData = await r.json().catch(() => ({}));
        const techReason = errData?.detail || `HTTP ${r.status}`;
        setOverlayErr(techReason);
        speakReply("Sorry, I couldn't process that — try again?", () => startAutoRelisten());
        return;
      }
      const data = await r.json();
      const reply = data.reply || '(no response)';
      setBotReply(reply);
      if (data.nav_hint) {
        window.dispatchEvent(new CustomEvent('voice-nav-hint', { detail: data.nav_hint }));
      }
      speakReply(reply, () => startAutoRelisten());
    } catch (e) {
      const techReason = e.message || 'Unknown error';
      setOverlayErr(techReason);
      speakReply("Sorry, I couldn't process that — try again?", () => startAutoRelisten());
    }
  };

  // One-shot "Tap to ask" — server-side STT via MediaRecorder + Groq Whisper.
  // Bypasses Chrome's flaky cloud webkitSpeechRecognition (which silently
  // produces no transcripts on many networks). Records ~7s of audio, POSTs
  // the blob to /api/ai/transcribe, and feeds the transcript into the
  // existing doVoiceChat() flow. Live RMS metering catches a flat-line mic
  // before we waste a round-trip on silent audio.
  const tapToAskOnce = async () => {
    if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
      setOverlayErr('Recording not supported in this browser. Use the text box.');
      return;
    }
    try { recognitionRef.current?.stop(); } catch (_) {}
    cancelAutoRelisten();
    setOverlayErr('');
    setBotReply('');
    setCaptureText('Recording…');
    setStatus('capturing');

    let stream;
    try {
      stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      setMediaStream(stream);
      mediaStreamRef.current = stream;
    } catch (_) {
      setOverlayErr('Microphone permission denied.');
      setStatus('idle');
      return;
    }

    const tryTypes = [
      'audio/webm;codecs=opus',
      'audio/webm',
      'audio/mp4',
      'audio/ogg;codecs=opus',
    ];
    let mimeType = '';
    for (const t of tryTypes) {
      if (MediaRecorder.isTypeSupported(t)) { mimeType = t; break; }
    }
    let rec;
    try {
      rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
    } catch (err) {
      setOverlayErr(`Recorder init failed: ${err?.message || err}`);
      setStatus('idle');
      return;
    }
    const chunks = [];
    rec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };

    // Live RMS meter — confirms the mic is producing audio.
    let audioCtx, analyser, srcNode, rafId;
    let peakLevel = 0;
    try {
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      srcNode = audioCtx.createMediaStreamSource(stream);
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 512;
      srcNode.connect(analyser);
      const buf = new Uint8Array(analyser.fftSize);
      const tick = () => {
        analyser.getByteTimeDomainData(buf);
        let sum = 0;
        for (let i = 0; i < buf.length; i++) {
          const v = (buf[i] - 128) / 128;
          sum += v * v;
        }
        const rms = Math.sqrt(sum / buf.length);
        if (rms > peakLevel) peakLevel = rms;
        rafId = requestAnimationFrame(tick);
      };
      tick();
    } catch (_) { /* meter optional */ }

    const cleanupMeter = () => {
      try { cancelAnimationFrame(rafId); } catch (_) {}
      try { audioCtx?.close(); } catch (_) {}
      try { srcNode?.disconnect(); } catch (_) {}
    };

    rec.onstop = async () => {
      cleanupMeter();
      const blob = new Blob(chunks, { type: rec.mimeType || mimeType || 'audio/webm' });
      // If the mic produced no measurable signal, surface a precise error.
      if (peakLevel < 0.005) {
        setOverlayErr("Mic didn't pick up any audio. Check that your mic is unmuted, selected as input, and not held by another app (Zoom, Slack huddle, etc.).");
        setStatus('idle');
        return;
      }
      if (blob.size < 500) {
        setOverlayErr('Recording was too short. Try again and speak for at least 1-2 seconds.');
        setStatus('idle');
        return;
      }
      setStatus('thinking');
      setCaptureText('Transcribing…');
      try {
        const ext = (blob.type.includes('mp4') ? 'mp4'
                  : blob.type.includes('ogg') ? 'ogg' : 'webm');
        const fd = new FormData();
        fd.append('file', blob, `clip.${ext}`);
        const resp = await fetch('/api/ai/transcribe', {
          method: 'POST',
          credentials: 'same-origin',
          body: fd,
        });
        if (!resp.ok) {
          const j = await resp.json().catch(() => ({}));
          setOverlayErr(j?.detail || `Transcribe failed: HTTP ${resp.status}`);
          setStatus('idle');
          return;
        }
        const j = await resp.json();
        const transcript = (j.transcript || '').trim();
        if (!transcript) {
          setOverlayErr("Mic worked but Whisper returned no transcript — try speaking more clearly or for longer.");
          setStatus('idle');
          return;
        }
        setCaptureText(transcript);
        doVoiceChat(transcript);
      } catch (err) {
        setOverlayErr(`Transcribe error: ${err?.message || err}`);
        setStatus('idle');
      }
    };

    rec.onerror = (e) => {
      cleanupMeter();
      setOverlayErr(`Recorder error: ${e?.error?.message || e?.error || 'unknown'}`);
      setStatus('idle');
    };

    rec.start();
    // Cap recording at 7s.
    setTimeout(() => {
      if (rec.state === 'recording') {
        try { rec.stop(); } catch (_) {}
      }
    }, 7000);
  };

  // V7: auto-relisten — no wake word for 30s
  const startAutoRelisten = () => {
    if (!voiceEnabledRef.current || stopRequestedRef.current) return;
    autoRelistenRef.current = true;
    setSleepCountdown(30);
    let remaining = 30;
    sleepCountdownRef.current = setInterval(() => {
      remaining -= 1;
      setSleepCountdown(remaining);
      if (remaining <= 0) {
        clearInterval(sleepCountdownRef.current);
        setSleepCountdown(null);
        autoRelistenRef.current = false;
        setStatus('idle');
      }
    }, 1000);
    setStatus('listening');
    startListening(true);
  };

  const cancelAutoRelisten = () => {
    autoRelistenRef.current = false;
    clearInterval(sleepCountdownRef.current);
    setSleepCountdown(null);
  };

  const startCapture = (recognition) => {
    wakingRef.current = true;
    captureTranscriptRef.current = '';
    setStatus('capturing');
    setCaptureText('');
    playWakeBeep();
    captureTimerRef.current = setTimeout(() => {
      if (wakingRef.current) finishCapture(recognition);
    }, 30000);
  };

  const finishCapture = (recognition) => {
    if (!wakingRef.current) return;
    wakingRef.current = false;
    clearTimeout(captureTimerRef.current);
    clearTimeout(silenceTimerRef.current);
    cancelAutoRelisten();
    const transcript = captureTranscriptRef.current.trim();
    if (transcript) {
      doVoiceChat(transcript);
    } else {
      setStatus('idle');
      setTimeout(() => {
        if (voiceEnabledRef.current && !stopRequestedRef.current) startListeningInternal();
      }, 500);
    }
  };

  // Internal: actually start recognition (assumes permission already granted)
  const startListeningInternal = async (autoRelisten = false) => {
    if (!voiceEnabledRef.current || stopRequestedRef.current) return;
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) { setErrMsg('SpeechRecognition not supported in this browser'); return; }

    try {
      stopRequestedRef.current = false;
      try { recognitionRef.current?.stop(); } catch {}

      // ── BUG FIX (mic-not-capturing): the waveform's getUserMedia call MUST
      // happen BEFORE r.start(). Chrome only allows one consumer per audio
      // device; if getUserMedia is called AFTER SpeechRecognition is already
      // listening, it silently steals the device — `onresult` never fires
      // (no error, no audio), so the user sees the mic icon "on" but nothing
      // is transcribed. Acquiring the stream first means SpeechRecognition
      // piggybacks on the already-held device permission.
      // We also skip the duplicate acquisition entirely if we still hold a
      // live stream from a previous run.
      if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
        const existing = mediaStreamRef.current;
        const stillLive = !!(existing && existing.getAudioTracks().some(t => t.readyState === 'live'));
        if (!stillLive) {
          try {
            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
            setMediaStream(stream);
            mediaStreamRef.current = stream;
          } catch (_) {
            setMediaStream(null);
          }
        }
      }

      const r = new SR();
      const runId = ++recognitionRunRef.current;
      r.continuous = true;
      r.interimResults = true;
      r.lang = voiceLang;
      recognitionRef.current = r;

      r.onresult = (e) => {
        const latest = Array.from(e.results).map(r => r[0].transcript).join(' ');

        if (autoRelistenRef.current && !wakingRef.current) {
          // P1 #1/#2: no wake phrase. Any speech during the auto-relisten
          // window starts a capture immediately.
          const spoken = String(latest || '').trim();
          if (spoken) {
            cancelAutoRelisten();
            startCapture(r);
            captureTranscriptRef.current = spoken;
            setCaptureText(spoken);
          }
          return;
        }

        if (!wakingRef.current) {
          const spoken = String(latest || '').trim();
          if (spoken) {
            startCapture(r);
            captureTranscriptRef.current = spoken;
            setCaptureText(spoken);
          }
        } else {
          const stripped = stripWakeWord(latest, [...DEFAULT_WAKE_PATTERNS, ...ARABIC_WAKE_PATTERNS]);
          captureTranscriptRef.current = stripped;
          setCaptureText(stripped);
          if (hasEOTCue(stripped)) {
            const cleaned = stripEOTCue(stripped);
            captureTranscriptRef.current = cleaned;
            setCaptureText(cleaned);
            finishCapture(r);
            return;
          }
          clearTimeout(silenceTimerRef.current);
          silenceTimerRef.current = setTimeout(() => finishCapture(r), 2000);
        }
      };

      r.onend = () => {
        if (recognitionRef.current !== r || runId !== recognitionRunRef.current) return;
        if (voiceEnabledRef.current && !stopRequestedRef.current && !['speaking', 'thinking', 'blocked'].includes(statusRef.current)) {
          try { r.start(); } catch {}
        }
      };

      r.onerror = async (e) => {
        if (recognitionRef.current !== r || runId !== recognitionRunRef.current) return;
        if (e.error === 'not-allowed') {
          // fix #22: only treat as 'blocked' if the Permissions API confirms
          // denial; otherwise this is usually a transient (page lost focus,
          // another tab grabbed the mic, etc.) and we shouldnt scare the user.
          let confirmed_denied = false;
          try {
            if (navigator.permissions && navigator.permissions.query) {
              const p = await navigator.permissions.query({ name: 'microphone' });
              confirmed_denied = p.state === 'denied';
            }
          } catch(_) {}
          if (confirmed_denied) {
            setStatus('blocked');
            setModal('blocked');
            setVoiceEnabled(false);
          } else {
            // Transient — soft retry
            setStatus('idle');
            setTimeout(() => { try { r.start(); } catch(_) {} }, 1500);
          }
        } else if (e.error === 'audio-capture') {
          setStatus('blocked');
          setModal('notfound');
          setVoiceEnabled(false);
        }
      };

      r.start();
      if (!autoRelisten) setStatus('idle');
    } catch (e) {
      setErrMsg('Could not start microphone: ' + e.message);
    }
  };

  // v14: startListening now checks permission state first
  const startListening = (autoRelisten = false) => {
    if (!isAdmin || !voiceEnabled) return;
    startListeningInternal(autoRelisten);
  };

  const stopListening = () => {
    stopRequestedRef.current = true;
    recognitionRunRef.current += 1;
    try { window.speechSynthesis?.cancel(); } catch(_) {}
    try {
      if (recognitionRef.current) {
        recognitionRef.current.onresult = null;
        recognitionRef.current.onend = null;
        recognitionRef.current.onerror = null;
      }
    } catch(_) {}
    try { recognitionRef.current?.abort?.(); } catch(_) {}
    try { recognitionRef.current?.stop(); } catch(_) {}
    recognitionRef.current = null;
    clearTimeout(captureTimerRef.current);
    clearTimeout(silenceTimerRef.current);
    cancelAutoRelisten();
    wakingRef.current = false;
    autoRelistenRef.current = false;
    setStatus('idle');
    setCaptureText('');
    setBotReply('');
    setOverlayErr('');
    if (mediaStream) { mediaStream.getTracks().forEach(t => t.stop()); setMediaStream(null); }
    mediaStreamRef.current = null;  // BUG FIX (mic-not-capturing)
  };

  // v14: check permission state before enabling
  const handleEnableVoice = async () => {
    let permState = 'prompt';
    try {
      const result = await navigator.permissions.query({ name: 'microphone' });
      permState = result.state; // 'granted' | 'prompt' | 'denied'
    } catch {
      // Permissions API not supported (older Safari/Firefox) — fall through to prompt
      permState = 'prompt';
    }

    if (permState === 'denied') {
      setModal('blocked');
      return;
    }

    if (permState === 'granted') {
      // Already have access — start immediately
      setVoiceEnabled(true);
      return;
    }

    // 'prompt' — show pre-flight explainer first
    setModal('preflight');
  };

  // Called when user confirms pre-flight modal
  const handlePreflightConfirm = async () => {
    setModal(null);
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      stream.getTracks().forEach(t => t.stop());
      // Permission granted — now enable
      setVoiceEnabled(true);
    } catch (err) {
      const isNotFound = err && (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError');
      setModal(isNotFound ? 'notfound' : 'blocked');
    }
  };

  // toggle handler — turn off is immediate (P0 #22 cancel/exit lag).
  // The confirm modal was removed: 'Voice off' click hard-stops listen,
  // releases mic + cancels TTS in one synchronous pass.
  const toggleVoice = () => {
    if (!isAdmin) return;
    if (voiceEnabled) {
      // Hard cancel — flip state, then sync-stop everything.
      setVoiceEnabled(false);
      setModal(null);
      setStopConfirm(false);
      try { window.speechSynthesis?.cancel(); } catch(_) {}
      stopListening();
    } else {
      handleEnableVoice();
    }
  };

  // Only auto-start when voiceEnabled flips to true (permission already checked)
  useEffect(() => {
    if (voiceEnabled && isAdmin) {
      stopRequestedRef.current = false;
      startListeningInternal();
    } else if (!voiceEnabled) {
      stopListening();
    }
    return stopListening;
  }, [voiceEnabled]);

  if (!isAdmin) return null;

  const voiceOptions = CURATED_VOICES.map(cv => {
    const resolved = resolveCuratedVoice(voices, cv.key);
    return { ...cv, available: !!resolved };
  });

  // v14: button appearance per status
  const btnConfig = (() => {
    if (!voiceEnabled) return {
      icon: '🎤', label: 'Voice off',
      bg: '#1f2937', color: '#d1d5db',
      border: '1px solid #374151',
    };
    if (status === 'blocked') return {
      icon: '🚫', label: 'Mic blocked — click to fix',
      bg: 'rgba(239,68,68,0.25)', color: '#fca5a5',
      border: '1px solid rgba(239,68,68,0.4)',
    };
    if (status === 'idle') return {
      icon: '👂', label: "Listening — speak any time",
      bg: 'rgba(16,185,129,0.18)', color: '#6ee7b7',
      border: '1px solid rgba(16,185,129,0.35)',
    };
    if (status === 'capturing') return {
      icon: '🎤', label: 'Listening…',
      bg: 'rgba(245,158,11,0.25)', color: '#fcd34d',
      border: '1px solid rgba(245,158,11,0.4)',
    };
    if (status === 'thinking') return {
      icon: '⏳', label: 'Thinking…',
      bg: 'rgba(139,92,246,0.25)', color: '#c4b5fd',
      border: '1px solid rgba(139,92,246,0.4)',
    };
    if (status === 'speaking') return {
      icon: '🗣️', label: 'Speaking…',
      bg: 'rgba(14,165,233,0.25)', color: '#7dd3fc',
      border: '1px solid rgba(14,165,233,0.4)',
    };
    if (status === 'listening') return {
      icon: '👂', label: sleepCountdown != null ? `Auto-relisten (${sleepCountdown}s)` : 'Listening…',
      bg: 'rgba(16,185,129,0.18)', color: '#6ee7b7',
      border: '1px solid rgba(16,185,129,0.35)',
    };
    return {
      icon: '🎤', label: 'Voice off',
      bg: '#1f2937', color: '#d1d5db',
      border: '1px solid #374151',
    };
  })();

  // v14: shared modal backdrop + container
  const ModalWrap = ({ children, onClose }) => (
    <div
      onClick={onClose}
      style={{
        position:'fixed', inset:0, zIndex:3000,
        background:'rgba(0,0,0,0.55)',
        display:'flex', alignItems:'center', justifyContent:'center',
        padding: 16,
      }}
    >
      <div
        onClick={e => e.stopPropagation()}
        style={{
          background:'var(--bg-elev-2)',
          border:'1px solid var(--line-hi)',
          borderRadius:'var(--r-lg)',
          padding:'24px 28px',
          maxWidth: 420, width:'100%',
          boxShadow:'0 16px 48px #0008',
          fontSize: 14,
        }}
      >
        {children}
      </div>
    </div>
  );

  const stateLabels = {
    idle:      "Listening",
    listening: sleepCountdown != null ? `Listening… (${sleepCountdown}s before sleep)` : "Listening…",
    capturing: "Capturing…",
    thinking:  "Thinking…",
    speaking:  "Speaking…",
    blocked:   "Mic blocked",
  };
  const statePillColor = {
    idle:      'var(--text-3)',
    listening: 'var(--brand)',
    capturing: 'var(--brand)',
    thinking:  'var(--warn)',
    speaking:  'var(--ok)',
    blocked:   'var(--err)',
  };

  return (
    <>
      {/* V6: Bottom-right overlay panel */}
      {voiceEnabled && (
        <div style={{
          position: 'fixed', bottom: 80, right: 20, zIndex: 2100,
          width: 360, maxHeight: 480,
          background: 'var(--bg-elev-2)',
          border: '1px solid var(--line-hi)',
          borderRadius: 'var(--r-lg)',
          boxShadow: '0 8px 40px #0006',
          display: 'flex', flexDirection: 'column', overflow: 'hidden',
          fontSize: 12,
        }}>
          <div style={{
            display:'flex', alignItems:'center', gap:8, padding:'10px 12px',
            borderBottom: '1px solid var(--line)', background:'var(--bg-elev-1)',
          }}>
            <span style={{ fontWeight:600, fontSize:13 }}>Voice mode</span>
            <span style={{
              flex:1, fontSize:11,
              color: statePillColor[status] || 'var(--text-3)',
              fontStyle: status === 'listening' ? 'italic' : 'normal',
            }}>
              {stateLabels[status] || status}
            </span>
            <button
              className="btn btn-pri"
              style={{ fontSize:10, padding:'2px 8px' }}
              onClick={tapToAskOnce}
              disabled={status === 'thinking' || status === 'speaking'}
              title="Tap to ask one question (no wake word, ~8s window)"
            >
              Tap → ask
            </button>
            <button
              className="btn btn-ghost btn-icon"
              style={{ fontSize:10 }}
              onClick={() => {
                try { recognitionRef.current?.stop(); } catch {}
                if (mediaStream) mediaStream.getTracks().forEach(t => { t.enabled = !t.enabled; });
              }}
              title="Mute mic"
            >
              <Icon name="mic" size={13}/>
            </button>
            <button
              className="btn btn-ghost btn-icon"
              style={{ fontSize:10 }}
              onClick={toggleVoice}
              title="Disable voice mode"
            >
              <Icon name="x" size={13}/>
            </button>
          </div>

          <div style={{ padding:'8px 12px 0' }}>
            {mediaStream ? (
              <WaveformCanvas mediaStream={mediaStream} />
            ) : (
              <div style={{ height:40, display:'flex', alignItems:'center', justifyContent:'center', color:'var(--text-3)', letterSpacing:4, fontSize:16 }}>
                • • •
              </div>
            )}
          </div>

          <div style={{ flex:1, overflowY:'auto', padding:'8px 12px' }}>
            {captureText && (
              <div style={{ color:'var(--text-3)', fontStyle:'italic', marginBottom:6, lineHeight:1.4 }}>
                {captureText}
              </div>
            )}
            {botReply && (
              <div style={{
                background:'var(--bg-1)', borderRadius:'var(--r-sm)',
                padding:'8px 10px', lineHeight:1.5,
              }}>
                <span style={{ color:'var(--text-3)', fontSize:10, display:'block', marginBottom:2 }}>Bot</span>
                {botReply}
              </div>
            )}
            {overlayErr && (
              <div style={{ color:'var(--err)', fontSize:11, marginTop:6 }}>Technical: {overlayErr}</div>
            )}
            {errMsg && (
              <div style={{ color:'var(--err)', fontSize:11, marginTop:6 }}>{errMsg}</div>
            )}
            {!captureText && !botReply && !overlayErr && !errMsg && (
              <div style={{ color:'var(--text-3)', textAlign:'center', padding:'8px 0' }}>
                Speak any time to start
              </div>
            )}
          </div>
        </div>
      )}

      {/* Voice toggle pill removed — replaced by Topbar MicToggle (top-right)
          and the inline mic button inside the Ask AI chatbot input row. The
          legacy always-on VoiceWidget panel below remains for the dictation
          flow but no longer has a standalone bottom-right entry point. */}

      {/* v14: Pre-flight explainer modal */}
      {modal === 'preflight' && (
        <ModalWrap onClose={() => setModal(null)}>
          <div style={{ fontWeight:700, fontSize:16, marginBottom:10 }}>Enable voice mode</div>
          <div style={{ color:'var(--text-2)', lineHeight:1.6, marginBottom:20 }}>
            Voice mode listens continuously and lets you ask the bot
            questions out loud without a wake phrase. We'll ask your browser for microphone access — click <strong>Allow</strong> when
            prompted. You can disable this at any time.
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button className="btn btn-ghost" onClick={() => setModal(null)}>Cancel</button>
            <button className="btn btn-primary" onClick={handlePreflightConfirm}>Continue</button>
          </div>
        </ModalWrap>
      )}

      {/* v15: Microphone blocked/notfound recovery modal — rich diagnostic + escalation */}
      {(modal === 'blocked' || modal === 'notfound') && (() => {
        const isNotFound = modal === 'notfound';

        const runDiag = async () => {
          setDiagLoading(true);
          setDiagResult(null);
          const result = {};
          // Permission state
          try {
            const ps = await navigator.permissions.query({ name: 'microphone' });
            result.permState = ps.state;
          } catch (e) {
            result.permState = 'Permissions API unavailable (' + (e.message || e.name) + ')';
          }
          // getUserMedia probe
          try {
            const s = await navigator.mediaDevices.getUserMedia({ audio: true });
            s.getTracks().forEach(t => t.stop());
            result.getUserMediaOk = true;
            result.getUserMediaErr = null;
          } catch (e) {
            result.getUserMediaOk = false;
            result.getUserMediaErrName = e.name || 'UnknownError';
            result.getUserMediaErrMsg = e.message || '';
          }
          // Audio devices
          try {
            const devices = await navigator.mediaDevices.enumerateDevices();
            const audioInputs = devices.filter(d => d.kind === 'audioinput');
            result.audioInputCount = audioInputs.length;
            result.firstDeviceLabel = audioInputs[0]?.label || '(blank — browser has not granted access this session)';
          } catch {
            result.audioInputCount = 'Error reading devices';
            result.firstDeviceLabel = 'N/A';
          }
          result.origin = location.origin;
          result.userAgent = navigator.userAgent;
          result.enterpriseNote = 'If your laptop is work-issued, check chrome://policy for AudioCaptureAllowed / AudioCaptureAllowedUrls.';
          setDiagResult(result);
          setDiagLoading(false);
        };

        const testMic = async () => {
          setTestMicRunning(true);
          setTestMicResult(null);
          try {
            const s = await navigator.mediaDevices.getUserMedia({ audio: true });
            s.getTracks().forEach(t => t.stop());
            setTestMicResult({ ok: true, name: null, message: 'Microphone access granted!' });
          } catch (e) {
            setTestMicResult({ ok: false, name: e.name || 'UnknownError', message: e.message || '' });
          }
          setTestMicRunning(false);
        };

        const openChromeSettings = () => {
          try {
            window.open('chrome://settings/content/microphone', '_blank');
          } catch {
            // chrome:// blocked by browser — copy to clipboard
            try {
              navigator.clipboard.writeText('chrome://settings/content/microphone').then(() => {
                setChromeCopied(true);
                setTimeout(() => setChromeCopied(false), 3000);
              });
            } catch {}
          }
          setChromeCopied(true);
          setTimeout(() => setChromeCopied(false), 3000);
        };

        const diagJson = diagResult ? JSON.stringify(diagResult, null, 2) : '';

        return (
          <ModalWrap onClose={() => { setModal(null); setDiagResult(null); setTestMicResult(null); setShowResetSteps(false); }}>
            <div style={{ fontWeight:700, fontSize:16, marginBottom:6 }}>
              {isNotFound ? 'Microphone not found' : 'Microphone is blocked'}
            </div>
            <div style={{ color:'var(--text-2)', fontSize:13, lineHeight:1.5, marginBottom:14 }}>
              {isNotFound
                ? 'No microphone device was detected. Plug in a microphone or headset, then reload the page.'
                : 'Chrome is blocking microphone access for this origin. Use one of the recovery options below.'}
            </div>

            {/* Diagnostic panel */}
            {!isNotFound && (
              <div style={{
                background:'var(--bg-1)', border:'1px solid var(--line)',
                borderRadius:'var(--r-sm)', padding:'10px 12px', marginBottom:14,
              }}>
                <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:6 }}>
                  <span style={{ fontSize:12, fontWeight:600, color:'var(--text-2)' }}>Diagnostic</span>
                  <button
                    className="btn btn-ghost"
                    style={{ fontSize:11, padding:'2px 8px' }}
                    onClick={runDiag}
                    disabled={diagLoading}
                  >
                    {diagLoading ? 'Running…' : diagResult ? 'Re-run' : 'Run diagnostic'}
                  </button>
                </div>
                {diagResult && (
                  <div style={{ fontSize:11, color:'var(--text-2)', lineHeight:1.7 }}>
                    <div>
                      <span style={{ color:'var(--text-3)' }}>Permission state: </span>
                      <strong style={{ color: diagResult.permState === 'granted' ? 'var(--ok)' : diagResult.permState === 'denied' ? 'var(--err)' : 'var(--warn)' }}>
                        {diagResult.permState}
                      </strong>
                    </div>
                    <div>
                      <span style={{ color:'var(--text-3)' }}>getUserMedia result: </span>
                      {diagResult.getUserMediaOk
                        ? <strong style={{ color:'var(--ok)' }}>Granted (try reloading the page)</strong>
                        : <><strong style={{ color:'var(--err)' }}>{diagResult.getUserMediaErrName}</strong>{diagResult.getUserMediaErrMsg ? ' — ' + diagResult.getUserMediaErrMsg : ''}</>
                      }
                    </div>
                    <div>
                      <span style={{ color:'var(--text-3)' }}>Audio inputs: </span>
                      {diagResult.audioInputCount}
                    </div>
                    <div>
                      <span style={{ color:'var(--text-3)' }}>First device label: </span>
                      <span style={{ fontStyle: diagResult.firstDeviceLabel.startsWith('(blank') ? 'italic' : 'normal' }}>
                        {diagResult.firstDeviceLabel}
                      </span>
                    </div>
                    <div><span style={{ color:'var(--text-3)' }}>Origin: </span>{diagResult.origin}</div>
                    <div style={{ marginTop:4, fontSize:10, color:'var(--text-3)' }}>{diagResult.enterpriseNote}</div>
                  </div>
                )}
                {!diagResult && !diagLoading && (
                  <div style={{ fontSize:11, color:'var(--text-3)' }}>Click "Run diagnostic" to inspect browser permission state and device list.</div>
                )}
              </div>
            )}

            {/* Three escalation paths */}
            {!isNotFound && (
              <div style={{ marginBottom:14 }}>
                <div style={{ fontSize:12, fontWeight:600, color:'var(--text-2)', marginBottom:8 }}>Recovery options</div>

                {/* Path 1: Open Chrome mic settings */}
                <div style={{ marginBottom:10 }}>
                  <button
                    className="btn btn-ghost"
                    style={{ width:'100%', justifyContent:'flex-start', fontSize:12, padding:'7px 10px', textAlign:'left' }}
                    onClick={openChromeSettings}
                  >
                    <span style={{ marginRight:6 }}>1.</span>
                    Open Chrome microphone settings
                    {chromeCopied && <span style={{ marginLeft:8, color:'var(--ok)', fontSize:11 }}>URL copied — paste in address bar</span>}
                  </button>
                  <div style={{ fontSize:11, color:'var(--text-3)', paddingLeft:18, marginTop:2 }}>
                    Opens <span style={{ fontFamily:'monospace' }}>chrome://settings/content/microphone</span> (or copies URL if blocked).
                    Find <strong>status.k9.ms</strong> in the Blocked list and set it to Allow.
                  </div>
                </div>

                {/* Path 2: Reset all permissions */}
                <div style={{ marginBottom:10 }}>
                  <button
                    className="btn btn-ghost"
                    style={{ width:'100%', justifyContent:'flex-start', fontSize:12, padding:'7px 10px', textAlign:'left' }}
                    onClick={() => setShowResetSteps(s => !s)}
                  >
                    <span style={{ marginRight:6 }}>2.</span>
                    Reset all permissions for this site
                    <span style={{ marginLeft:'auto', fontSize:10, color:'var(--text-3)' }}>{showResetSteps ? '▲ hide' : '▼ show steps'}</span>
                  </button>
                  {showResetSteps && (
                    <div style={{
                      fontSize:11, color:'var(--text-2)', lineHeight:1.8,
                      background:'var(--bg-1)', borderRadius:'var(--r-sm)',
                      padding:'8px 12px', marginTop:4,
                    }}>
                      <ol style={{ paddingLeft:16, margin:0 }}>
                        <li>In Chrome address bar type: <span style={{ fontFamily:'monospace', background:'var(--bg-2)', padding:'1px 4px', borderRadius:2 }}>chrome://settings/content/all</span> and press Enter</li>
                        <li>Search for <strong>status.k9.ms</strong></li>
                        <li>Click the entry, then click <strong>Reset permissions</strong></li>
                        <li>Close the tab and reload <strong>status.k9.ms</strong></li>
                        <li>Click "Voice off" again — Chrome will prompt fresh</li>
                      </ol>
                    </div>
                  )}
                </div>

                {/* Path 3: Incognito */}
                <div style={{ marginBottom:4 }}>
                  <button
                    className="btn btn-ghost"
                    style={{ width:'100%', justifyContent:'flex-start', fontSize:12, padding:'7px 10px', textAlign:'left' }}
                    onClick={() => {}}
                  >
                    <span style={{ marginRight:6 }}>3.</span>
                    Try Incognito to bypass cached deny
                  </button>
                  <div style={{ fontSize:11, color:'var(--text-3)', paddingLeft:18, marginTop:2 }}>
                    Press <kbd style={{ background:'var(--bg-2)', padding:'1px 4px', borderRadius:2, fontFamily:'monospace' }}>Cmd+Shift+N</kbd> (Mac) or <kbd style={{ background:'var(--bg-2)', padding:'1px 4px', borderRadius:2, fontFamily:'monospace' }}>Ctrl+Shift+N</kbd> (Windows).
                    Open <strong>status.k9.ms</strong> in Incognito — permission state resets fresh each session.
                  </div>
                </div>
              </div>
            )}

            {/* Test mic button */}
            {!isNotFound && (
              <div style={{ marginBottom:14 }}>
                <button
                  className="btn btn-ghost"
                  style={{ fontSize:12, width:'100%', padding:'7px 10px' }}
                  onClick={testMic}
                  disabled={testMicRunning}
                >
                  {testMicRunning ? 'Testing…' : 'Test microphone (bypasses permissions cache)'}
                </button>
                {testMicResult && (
                  <div style={{
                    marginTop:6, fontSize:11, padding:'6px 10px', borderRadius:'var(--r-sm)',
                    background: testMicResult.ok ? 'rgba(16,185,129,0.12)' : 'rgba(239,68,68,0.12)',
                    color: testMicResult.ok ? 'var(--ok)' : 'var(--err)',
                  }}>
                    {testMicResult.ok
                      ? 'Microphone access granted — reload the page and try enabling voice mode again.'
                      : <><strong>{testMicResult.name}</strong>{testMicResult.message ? ': ' + testMicResult.message : ''}</>
                    }
                  </div>
                )}
              </div>
            )}

            <div style={{ display:'flex', gap:8, justifyContent:'flex-end', marginBottom:12 }}>
              <button className="btn btn-ghost" onClick={() => { setModal(null); setDiagResult(null); setTestMicResult(null); setShowResetSteps(false); }}>Close</button>
            </div>

            {/* Native dictation fallback */}
            <div style={{
              borderTop:'1px solid var(--line)', paddingTop:10,
              fontSize:11, color:'var(--text-3)', lineHeight:1.6,
            }}>
              <strong>Can't get mic working?</strong>{' '}
              Use <strong>macOS native dictation</strong> (press <kbd style={{ background:'var(--bg-2)', padding:'1px 4px', borderRadius:2, fontFamily:'monospace' }}>Fn</kbd> twice) or{' '}
              <strong>Windows Voice Typing</strong> (<kbd style={{ background:'var(--bg-2)', padding:'1px 4px', borderRadius:2, fontFamily:'monospace' }}>Win+H</kbd>) and dictate directly into the{' '}
              <strong>Ask Pulse text box</strong> — no browser mic permission needed.
            </div>

            {/* Technical info console */}
            {diagResult && (
              <details style={{ marginTop:10 }}>
                <summary style={{ fontSize:11, color:'var(--text-3)', cursor:'pointer', userSelect:'none' }}>
                  Show technical info
                </summary>
                <pre style={{
                  marginTop:6, fontSize:10, color:'var(--text-3)',
                  background:'var(--bg-1)', borderRadius:'var(--r-sm)',
                  padding:'8px', overflowX:'auto', whiteSpace:'pre-wrap', wordBreak:'break-all',
                  maxHeight:160, overflowY:'auto',
                }}>
                  {diagJson}
                </pre>
                <button
                  className="btn btn-ghost"
                  style={{ fontSize:10, padding:'2px 8px', marginTop:4 }}
                  onClick={() => { try { navigator.clipboard.writeText(diagJson); } catch {} }}
                >
                  Copy to clipboard
                </button>
              </details>
            )}
          </ModalWrap>
        );
      })()}

      {/* v14: Stop confirm dialog */}
      {stopConfirm && (
        <ModalWrap onClose={() => setStopConfirm(false)}>
          <div style={{ fontWeight:700, fontSize:16, marginBottom:10 }}>Stop voice mode?</div>
          <div style={{ color:'var(--text-2)', lineHeight:1.6, marginBottom:20 }}>
            The microphone will be released and voice recognition will stop.
          </div>
          <div style={{ display:'flex', gap:8, justifyContent:'flex-end' }}>
            <button className="btn btn-ghost" onClick={() => setStopConfirm(false)}>Cancel</button>
            <button
              className="btn btn-primary"
              style={{ background:'var(--err)', borderColor:'var(--err)' }}
              onClick={() => {
                setStopConfirm(false);
                setVoiceEnabled(false);
              }}
            >
              Stop
            </button>
          </div>
        </ModalWrap>
      )}
    </>
  );
}

/* ============================================================
   VoiceSettingsSection — rendered inside the gear popover in shell.jsx
   V1: wake words editor
   V3: curated 4 voices
   V9: voice toggle (starts off, never persisted)
   V10: language radio
   V5: help blurb
   ============================================================ */
function VoiceSettingsSection() {
  const D = window.STATUS_DATA;
  const isAdmin = !!(window.isAdminOrAbove ? window.isAdminOrAbove(D?.ME)
    : (D && D.ME && (D.ME.is_admin || D.ME.org_role === 'admin' || (D.ME.role || '').toLowerCase() === 'admin')));
  if (!isAdmin) return null;

  // Read voice prefs from localStorage (sub-preferences only, not enabled state)
  const [selectedVoiceKey, setSelectedVoiceKey] = useState(() => {
    try { return localStorage.getItem('voice_persona_key') || 'uk-male'; } catch { return 'uk-male'; }
  });
  const [voiceLang, setVoiceLang] = useState(() => {
    try { return localStorage.getItem('voice_language') || 'en-US'; } catch { return 'en-US'; }
  });
  const [customWakeWords, setCustomWakeWords] = useState(() => {
    try { return JSON.parse(localStorage.getItem('voice_wake_words') || '[]'); } catch { return []; }
  });
  const [newWakeWord, setNewWakeWord] = useState('');
  const [voices, setVoices] = useState([]);

  // v17: premium TTS state
  const [premCfg, setPremCfg] = useState(null);           // null = not loaded yet
  const [premLoading, setPremLoading] = useState(false);
  const [premSaving, setPremSaving] = useState(false);
  const [premMsg, setPremMsg] = useState('');              // success / error feedback
  const [premVoices, setPremVoices] = useState([]);
  const [premVoicesLoading, setPremVoicesLoading] = useState(false);
  const [premTesting, setPremTesting] = useState(false);
  const [premTestMsg, setPremTestMsg] = useState('');
  // local editable form
  const [premForm, setPremForm] = useState({
    enabled: false, provider: 'openai', api_key: '', voice_id: 'onyx', model: 'tts-1',
  });

  useEffect(() => {
    const load = () => {
      const v = window.speechSynthesis ? window.speechSynthesis.getVoices() : [];
      if (v.length) setVoices(v);
    };
    load();
    if (window.speechSynthesis) window.speechSynthesis.onvoiceschanged = load;
  }, []);

  // Load premium TTS config on mount
  useEffect(() => {
    setPremLoading(true);
    fetch('/api/admin/tts/config', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        if (d) {
          setPremCfg(d);
          setPremForm({
            enabled:  d.enabled,
            provider: d.provider || 'openai',
            api_key:  '',   // never pre-fill — placeholder shows masked version
            voice_id: d.voice_id || 'onyx',
            model:    d.model || 'tts-1',
          });
        }
      })
      .catch(() => {})
      .finally(() => setPremLoading(false));
  }, []);

  // Load voice list when provider/api_key changes (only after a successful save)
  const loadPremVoices = () => {
    setPremVoicesLoading(true);
    fetch('/api/admin/tts/voices', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : { voices: [] })
      .then(d => setPremVoices(d.voices || []))
      .catch(() => setPremVoices([]))
      .finally(() => setPremVoicesLoading(false));
  };

  const saveVoiceKey = (k) => {
    setSelectedVoiceKey(k);
    try { localStorage.setItem('voice_persona_key', k); } catch {}
    window.dispatchEvent(new CustomEvent('voice-pref-change', { detail: { key: 'voice_persona_key', value: k } }));
  };
  const saveLang = (l) => {
    setVoiceLang(l);
    try { localStorage.setItem('voice_language', l); } catch {}
    window.dispatchEvent(new CustomEvent('voice-pref-change', { detail: { key: 'voice_language', value: l } }));
  };
  const addWakeWord = () => {
    const w = newWakeWord.trim();
    if (!w || customWakeWords.includes(w)) return;
    const next = [...customWakeWords, w];
    setCustomWakeWords(next);
    try { localStorage.setItem('voice_wake_words', JSON.stringify(next)); } catch {}
    setNewWakeWord('');
  };
  const removeWakeWord = (w) => {
    const next = customWakeWords.filter(x => x !== w);
    setCustomWakeWords(next);
    try { localStorage.setItem('voice_wake_words', JSON.stringify(next)); } catch {}
  };

  const setPF = (k, v) => setPremForm(f => ({ ...f, [k]: v }));

  const savePremium = async () => {
    setPremSaving(true); setPremMsg('');
    try {
      const body = {
        enabled:  premForm.enabled,
        provider: premForm.provider,
        voice_id: premForm.voice_id,
        model:    premForm.model,
      };
      // Only send api_key if user typed something new
      if (premForm.api_key && premForm.api_key.trim()) body.api_key = premForm.api_key.trim();

      const r = await fetch('/api/admin/tts/config', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify(body),
      });
      if (!r.ok) {
        const err = await r.json().catch(() => ({}));
        setPremMsg('Save failed: ' + (err.detail || r.status));
        return;
      }
      setPremMsg('Saved!');
      setPremForm(f => ({ ...f, api_key: '' }));  // clear input after save
      // Refresh config display and voice list
      const cfg = await fetch('/api/admin/tts/config', { credentials: 'same-origin' }).then(r => r.json()).catch(() => null);
      if (cfg) { setPremCfg(cfg); }
      loadPremVoices();
      setTimeout(() => setPremMsg(''), 3000);
    } catch (e) {
      setPremMsg('Error: ' + e.message);
    }
    setPremSaving(false);
  };

  const testPremium = async () => {
    setPremTesting(true); setPremTestMsg('');
    try {
      const r = await fetch('/api/admin/tts/test', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ text: 'Hello, this is a test of the premium voice assistant.' }),
      });
      if (!r.ok) {
        const err = await r.json().catch(() => ({}));
        setPremTestMsg('TTS test failed — ' + (err.detail || 'check API key'));
        return;
      }
      const blob = await r.blob();
      const url = URL.createObjectURL(blob);
      const audio = new Audio(url);
      audio.onended = () => URL.revokeObjectURL(url);
      audio.play().catch(() => setPremTestMsg('Audio playback blocked by browser'));
    } catch (e) {
      setPremTestMsg('Error: ' + e.message);
    }
    setPremTesting(false);
  };

  const premiumActive = premCfg && premCfg.enabled;
  const voiceOptions = CURATED_VOICES.map(cv => ({
    ...cv,
    available: !!resolveCuratedVoice(voices, cv.key),
  }));

  // Build voice dropdown options: use fetched list if available, else defaults
  const premVoiceOptions = premVoices.length > 0 ? premVoices : (
    premForm.provider === 'openai'
      ? [
          { id: 'alloy',   name: 'Alloy (neutral)' },
          { id: 'echo',    name: 'Echo (male)' },
          { id: 'fable',   name: 'Fable (British male)' },
          { id: 'onyx',    name: 'Onyx (deep male)' },
          { id: 'nova',    name: 'Nova (female)' },
          { id: 'nadia',   name: 'Nadia (warm female)' },
          { id: 'shimmer', name: 'Shimmer (female)' },
        ]
      : []
  );

  return (
    <div style={{ borderTop: '1px solid var(--line)', paddingTop: 10, marginTop: 2 }}>
      <div className="settings-popover-label" style={{ marginBottom:6 }}>
        Voice assistant
        {premiumActive && (
          <span style={{ marginLeft:6, fontSize:10, background:'var(--accent)', color:'#fff', borderRadius:3, padding:'1px 5px' }}>
            Premium voice active
          </span>
        )}
        {!premiumActive && (
          <span style={{ marginLeft:6, fontSize:10, color:'var(--text-3)' }}>
            Browser voice (free)
          </span>
        )}
      </div>

      {/* ── Premium voice (paid) — admin only ── */}
      <div style={{ marginBottom:10, background:'var(--bg-1)', borderRadius:6, padding:'8px 10px', border:'1px solid var(--line)' }}>
        <div style={{ fontSize:11, fontWeight:600, color:'var(--text-2)', marginBottom:6 }}>
          Premium voice (paid)
        </div>

        {premLoading ? (
          <div style={{ fontSize:11, color:'var(--text-3)' }}>Loading...</div>
        ) : (
          <>
            {/* Toggle */}
            <label style={{ display:'flex', alignItems:'center', gap:6, fontSize:12, cursor:'pointer', marginBottom:8 }}>
              <input
                type="checkbox"
                checked={premForm.enabled}
                onChange={e => setPF('enabled', e.target.checked)}
                style={{ margin:0 }}
              />
              Use premium TTS instead of browser voice
            </label>

            {/* Provider segmented control */}
            <div style={{ marginBottom:6 }}>
              <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:3 }}>Provider</div>
              <div style={{ display:'flex', gap:4 }}>
                {[{v:'openai', l:'OpenAI'}, {v:'elevenlabs', l:'ElevenLabs'}].map(p => (
                  <button
                    key={p.v}
                    className="btn btn-ghost"
                    style={{
                      fontSize:11, padding:'2px 8px',
                      background: premForm.provider === p.v ? 'var(--accent)' : undefined,
                      color: premForm.provider === p.v ? '#fff' : undefined,
                    }}
                    onClick={() => { setPF('provider', p.v); setPF('voice_id', p.v === 'openai' ? 'onyx' : ''); setPremVoices([]); }}
                  >{p.l}</button>
                ))}
              </div>
            </div>

            {/* API key input */}
            <div style={{ marginBottom:6 }}>
              <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:3 }}>API key</div>
              <input
                className="inp"
                type="password"
                placeholder={premCfg && premCfg.api_key ? premCfg.api_key : (premForm.provider === 'openai' ? 'sk-...' : 'Paste ElevenLabs key')}
                value={premForm.api_key}
                onChange={e => setPF('api_key', e.target.value)}
                style={{ fontSize:12, width:'100%' }}
              />
            </div>

            {/* Voice dropdown */}
            <div style={{ marginBottom:6 }}>
              <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:3 }}>
                Voice {premVoicesLoading && <span style={{ fontSize:10 }}>(loading...)</span>}
              </div>
              <select
                className="sel"
                value={premForm.voice_id}
                onChange={e => setPF('voice_id', e.target.value)}
                style={{ fontSize:12, width:'100%' }}
              >
                {premVoiceOptions.map(v => (
                  <option key={v.id} value={v.id}>{v.name}</option>
                ))}
                {premVoiceOptions.length === 0 && (
                  <option value="">Save config first to load voices</option>
                )}
              </select>
            </div>

            {/* Model dropdown — OpenAI only */}
            {premForm.provider === 'openai' && (
              <div style={{ marginBottom:6 }}>
                <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:3 }}>Model</div>
                <select
                  className="sel"
                  value={premForm.model}
                  onChange={e => setPF('model', e.target.value)}
                  style={{ fontSize:12, width:'100%' }}
                >
                  <option value="tts-1">tts-1 (fast, $0.015/1k chars)</option>
                  <option value="tts-1-hd">tts-1-hd (higher quality, $0.030/1k chars)</option>
                </select>
              </div>
            )}

            {/* Cost note */}
            <div style={{ fontSize:10, color:'var(--text-3)', marginBottom:8, lineHeight:1.5 }}>
              {premForm.provider === 'openai'
                ? 'OpenAI: ~$0.015/1k chars (tts-1) or ~$0.030/1k chars (tts-1-hd) · billed to your OpenAI account'
                : 'ElevenLabs: ~$0.30/1k chars · starter plan ~$5/mo for 30k chars · billed to your ElevenLabs account'}
              <br/>Daily hard limit: 25 min of synthesized audio per workspace.
            </div>

            {/* Action buttons */}
            <div style={{ display:'flex', gap:6, alignItems:'center', flexWrap:'wrap' }}>
              <button
                className="btn btn-primary"
                style={{ fontSize:11, padding:'3px 10px' }}
                disabled={premSaving}
                onClick={savePremium}
              >
                {premSaving ? 'Saving...' : 'Save'}
              </button>
              <button
                className="btn btn-ghost"
                style={{ fontSize:11, padding:'3px 10px' }}
                disabled={premTesting}
                onClick={testPremium}
                title="Uses currently saved config — save first"
              >
                {premTesting ? 'Testing...' : 'Test voice'}
              </button>
            </div>

            {premMsg && (
              <div style={{ marginTop:6, fontSize:11, color: premMsg.startsWith('Save') ? 'var(--ok)' : 'var(--err)' }}>
                {premMsg}
              </div>
            )}
            {premTestMsg && (
              <div style={{ marginTop:4, fontSize:11, color:'var(--err)' }}>{premTestMsg}</div>
            )}
          </>
        )}
      </div>

      {/* Language radio (V10) */}
      <div style={{ marginBottom:8 }}>
        <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:4 }}>Language</div>
        <div style={{ display:'flex', gap:8 }}>
          {[
            { value:'en-US', label:'English (en-US)' },
            { value:'ar-SA', label:'Arabic (ar-SA)' },
          ].map(opt => (
            <label key={opt.value} style={{ display:'flex', alignItems:'center', gap:4, fontSize:12, cursor:'pointer' }}>
              <input
                type="radio"
                name="voice-lang"
                value={opt.value}
                checked={voiceLang === opt.value}
                onChange={() => saveLang(opt.value)}
                style={{ margin:0 }}
              />
              {opt.label}
            </label>
          ))}
        </div>
      </div>

      {/* Curated 4 voices (V3) — grayed out when premium is active */}
      <div style={{ marginBottom:8, opacity: premiumActive ? 0.4 : 1 }}>
        <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:4 }}>
          Browser voice {premiumActive && <span style={{ fontSize:10 }}>(premium voice in use — configure above)</span>}
        </div>
        {voiceOptions.map(opt => (
          <label
            key={opt.key}
            style={{
              display:'flex', alignItems:'center', gap:6, marginBottom:3, fontSize:12,
              cursor: (opt.available && !premiumActive) ? 'pointer' : 'default',
              color: opt.available ? 'var(--text-1)' : 'var(--text-3)',
            }}
            title={opt.available ? '' : 'Not available in this browser'}
          >
            <input
              type="radio"
              name="voice-persona"
              value={opt.key}
              checked={selectedVoiceKey === opt.key}
              onChange={() => opt.available && !premiumActive && saveVoiceKey(opt.key)}
              disabled={!opt.available || premiumActive}
              style={{ margin:0 }}
            />
            {opt.label}
            {!opt.available && (
              <span style={{ fontSize:10, color:'var(--text-3)' }}>(not available)</span>
            )}
          </label>
        ))}
      </div>

      {/* Custom wake words (V1) */}
      <div style={{ marginBottom:8 }}>
        <div style={{ fontSize:11, color:'var(--text-3)', marginBottom:4 }}>Voice wake words</div>
        <div style={{ fontSize:10, color:'var(--text-3)', marginBottom:4, lineHeight:1.4 }}>
          Wake phrases are optional. Once enabled, normal speech starts the conversation.
        </div>
        {customWakeWords.map(w => (
          <div key={w} style={{ display:'flex', alignItems:'center', gap:4, marginBottom:2 }}>
            <span style={{ flex:1, fontSize:12, background:'var(--bg-1)', borderRadius:3, padding:'2px 6px' }}>{w}</span>
            <button
              className="btn btn-ghost"
              style={{ fontSize:10, padding:'1px 6px', color:'var(--err)' }}
              onClick={() => removeWakeWord(w)}
            >remove</button>
          </div>
        ))}
        <div style={{ display:'flex', gap:4, marginTop:4 }}>
          <input
            className="sel"
            style={{ flex:1, fontSize:12, padding:'3px 6px' }}
            placeholder="Add wake phrase..."
            value={newWakeWord}
            onChange={e => setNewWakeWord(e.target.value)}
            onKeyDown={e => e.key === 'Enter' && addWakeWord()}
          />
          <button className="btn btn-ghost" style={{ fontSize:11, padding:'3px 8px' }} onClick={addWakeWord}>Add</button>
        </div>
      </div>

      {/* Voice can / cannot blurb (V5) */}
      <div style={{ fontSize:10, color:'var(--text-3)', lineHeight:1.6, background:'var(--bg-1)', borderRadius:4, padding:'6px 8px' }}>
        <strong>Voice can:</strong> answer questions about team attendance, PTO, strikes, presence. Create PTO requests. Set progress scores.<br/>
        <strong>Voice cannot:</strong> delete users, rooms, bots, or teams — use the dashboard for destructive actions.
      </div>
    </div>
  );
}

window.AskAIWidget = AskAIWidget;
window.VoiceWidget = VoiceWidget;
window.VoiceSettingsSection = VoiceSettingsSection;
