/**
 * voice-mode.mjs — Full-screen voice conversation overlay
 *
 * Click the sidebar avatar to enter an immersive voice conversation mode.
 * Features: large animated avatar, live transcript, VAD (voice activity
 * detection), TTS output with lip-sync, interrupt support, noise cancellation.
 */

import store from '../store.mjs';
import eventBus, { EVENTS } from '../event-bus.mjs';
import api from '../api.mjs';
import { createAvatar, startSpeaking, stopSpeaking } from './avatar.mjs';
import { createElement, icon, empty } from '../utils/dom.mjs';
import * as grokVoice from './grok-voice.mjs';
import * as lipSync from './lip-sync.mjs';

// ── State ──
let isClosing = false;
let overlayEl = null;
let transcriptEl = null;
let statusEl = null;
let waveformEl = null;
let avatarEl = null;

let mediaStream = null;
let mediaRecorder = null;
let audioContext = null;
let analyser = null;
let waveformAnimId = null;

let isListening = false;
let isProcessing = false;
let isTTSSpeaking = false;

// VAD state
let vadSilenceTimer = null;
const VAD_SILENCE_MS = 1500;
const VAD_THRESHOLD = 0.015; // RMS threshold for voice activity

// TTS
let currentUtterance = null;

// Grok mode
let useGrokMode = false;
let grokTranscriptAccumulator = '';
let interruptedCurrentResponse = false;

// Cleanup subscriptions
let unsubStream = null;
let unsubStreamEnd = null;
let unsubVoiceMode = null;

// ── Public API ──

export async function openVoiceMode() {
  if (overlayEl) return;
  store.dispatch('OPEN_VOICE_MODE');
  buildOverlay();
  document.body.appendChild(overlayEl);

  // Animate in
  requestAnimationFrame(() => {
    overlayEl.style.opacity = '1';
  });

  // Detect which voice mode to use
  await detectGrokMode();

  if (useGrokMode) {
    // Grok mode: direct WebSocket with real-time audio
    lipSync.enableAudioDriver(true);
    await startGrokSession();
  } else {
    // Fallback: whisper transcription + browser SpeechSynthesis
    setTimeout(() => startListening(), 300);
    unsubStream = eventBus.on(EVENTS.CHAT_STREAM_CHUNK, handleStreamChunk);
    unsubStreamEnd = eventBus.on(EVENTS.CHAT_STREAM_END, handleStreamEnd);
  }
}

export function closeVoiceMode() {
  if (!overlayEl || isClosing) return; // Prevent re-entry during fade-out
  isClosing = true;

  if (useGrokMode) {
    grokVoice.disconnect();
    lipSync.enableAudioDriver(false);
    lipSync.stopSpeaking();

    // Sync voice transcript to main chat so the agent can take action
    syncGrokTranscriptToChat();

    grokTranscriptAccumulator = '';
  } else {
    stopListening();
    cancelTTS();
    cleanupAudio();
  }

  if (unsubStream) { unsubStream(); unsubStream = null; }
  if (unsubStreamEnd) { unsubStreamEnd(); unsubStreamEnd = null; }

  if (overlayEl) {
    overlayEl.style.opacity = '0';
    setTimeout(() => {
      overlayEl?.remove();
      overlayEl = null;
      transcriptEl = null;
      statusEl = null;
      waveformEl = null;
      avatarEl = null;
      isClosing = false;
    }, 300);
  }

  store.dispatch('CLOSE_VOICE_MODE');
  store.dispatch('SET_VOICE_STATUS', 'idle');
}

export function init() {
  // Listen for voice mode state changes
  unsubVoiceMode = store.subscribe(
    (s) => s.voice.voiceModeOpen,
    (open) => {
      if (!open && overlayEl) closeVoiceMode();
    }
  );

  // ESC key closes voice mode
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && overlayEl) {
      closeVoiceMode();
    }
  });
}

// ── Build overlay DOM ──

function buildOverlay() {
  overlayEl = createElement('div', { className: 'voice-mode-overlay' });
  overlayEl.style.cssText = `
    position: fixed; inset: 0; z-index: 10000;
    background: rgba(8, 8, 20, 0.96);
    display: flex; flex-direction: row;
    padding: 0;
    opacity: 0; transition: opacity 0.3s ease;
    backdrop-filter: blur(20px);
  `;

  // Close button
  const closeBtn = createElement('button');
  closeBtn.innerHTML = '&times;';
  closeBtn.style.cssText = `
    position: absolute; top: 16px; right: 20px;
    background: none; border: none; color: rgba(255,255,255,0.6);
    font-size: 32px; cursor: pointer; line-height: 1;
    transition: color 0.2s; z-index: 1;
  `;
  closeBtn.addEventListener('mouseenter', () => closeBtn.style.color = '#fff');
  closeBtn.addEventListener('mouseleave', () => closeBtn.style.color = 'rgba(255,255,255,0.6)');
  closeBtn.addEventListener('click', closeVoiceMode);
  overlayEl.appendChild(closeBtn);

  // ── Left panel: avatar + controls ──
  const leftPanel = createElement('div');
  leftPanel.style.cssText = `
    width: 300px; flex-shrink: 0;
    display: flex; flex-direction: column; align-items: center;
    justify-content: center; gap: 16px;
    padding: 40px 20px;
    border-right: 1px solid rgba(255,255,255,0.06);
  `;

  // Avatar area
  const avatarWrap = createElement('div');
  avatarWrap.style.cssText = `
    cursor: pointer; transition: transform 0.2s;
  `;
  avatarWrap.addEventListener('click', handleAvatarClick);
  avatarEl = createAvatar('voice-mode');
  avatarWrap.appendChild(avatarEl);
  leftPanel.appendChild(avatarWrap);

  // Status indicator
  statusEl = createElement('div');
  statusEl.style.cssText = `
    color: rgba(255,255,255,0.5); font-size: 14px;
    text-transform: uppercase; letter-spacing: 2px;
    font-weight: 500; text-align: center;
  `;
  statusEl.textContent = 'Tap avatar to speak';
  leftPanel.appendChild(statusEl);

  // Waveform / audio level indicator
  waveformEl = createElement('canvas');
  waveformEl.width = 260;
  waveformEl.height = 40;
  waveformEl.style.cssText = `
    border-radius: 8px; opacity: 0.6;
  `;
  leftPanel.appendChild(waveformEl);

  // Microphone mute toggle
  const micMuteBtn = createElement('button');
  micMuteBtn.style.cssText = `
    display: flex; align-items: center; gap: 8px;
    padding: 8px 20px;
    background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
    border-radius: 24px; color: rgba(255,255,255,0.7);
    font-size: 13px; cursor: pointer; transition: all 0.2s;
  `;
  micMuteBtn.appendChild(icon('mic', { size: 16 }));
  micMuteBtn.appendChild(document.createTextNode('Mute'));
  let micMuted = false;
  micMuteBtn.addEventListener('mouseenter', () => {
    micMuteBtn.style.background = 'rgba(255,255,255,0.12)';
  });
  micMuteBtn.addEventListener('mouseleave', () => {
    micMuteBtn.style.background = micMuted ? 'rgba(244,68,68,0.15)' : 'rgba(255,255,255,0.08)';
  });
  micMuteBtn.addEventListener('click', () => {
    micMuted = !micMuted;
    empty(micMuteBtn);
    micMuteBtn.appendChild(icon(micMuted ? 'mic-off' : 'mic', { size: 16 }));
    micMuteBtn.appendChild(document.createTextNode(micMuted ? 'Unmute' : 'Mute'));
    micMuteBtn.style.background = micMuted ? 'rgba(244,68,68,0.15)' : 'rgba(255,255,255,0.08)';
    micMuteBtn.style.color = micMuted ? 'rgba(244,128,128,0.9)' : 'rgba(255,255,255,0.7)';
    store.dispatch('SET_MIC_MUTED', micMuted);
    if (useGrokMode) {
      grokVoice.setMicMuted(micMuted);
      if (micMuted) updateStatus('Microphone muted');
      else updateStatus('Listening...');
    } else if (micMuted && isListening) {
      stopListening();
      cleanupAudio();
      updateStatus('Microphone muted');
      store.dispatch('SET_VOICE_STATUS', 'idle');
    }
  });
  leftPanel.appendChild(micMuteBtn);
  overlayEl.appendChild(leftPanel);

  // ── Right panel: transcript conversation ──
  const rightPanel = createElement('div');
  rightPanel.style.cssText = `
    flex: 1; display: flex; flex-direction: column;
    padding: 40px 24px 20px; min-width: 0;
  `;

  // Transcript area
  transcriptEl = createElement('div', { className: 'voice-transcript' });
  transcriptEl.style.cssText = `
    flex: 1; width: 100%;
    overflow-y: auto; padding: 16px;
    display: flex; flex-direction: column; gap: 12px;
  `;
  rightPanel.appendChild(transcriptEl);
  overlayEl.appendChild(rightPanel);

  // Add existing transcript entries from store
  const transcript = store.getState().voice.transcript;
  for (const entry of transcript) {
    appendTranscriptBubble(entry.role, entry.text);
  }
}

// ── Transcript UI ──

let currentAssistantBubble = null;

function appendTranscriptBubble(role, text) {
  if (!transcriptEl) return null;
  const bubble = createElement('div');
  const isUser = role === 'user';
  bubble.style.cssText = `
    padding: 10px 16px; border-radius: 16px;
    max-width: 80%; font-size: 15px; line-height: 1.5;
    word-wrap: break-word; animation: fadeIn 0.2s ease;
    ${isUser
      ? 'align-self: flex-end; background: rgba(74,158,255,0.2); color: #a8d4ff;'
      : 'align-self: flex-start; background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.85);'}
  `;
  bubble.textContent = text;
  transcriptEl.appendChild(bubble);
  transcriptEl.scrollTop = transcriptEl.scrollHeight;
  return bubble;
}

function appendSystemBubble(text) {
  if (!transcriptEl) return;
  const bubble = createElement('div');
  bubble.style.cssText = `
    padding: 6px 14px; border-radius: 12px;
    align-self: center; background: rgba(255,200,50,0.12);
    color: rgba(255,220,100,0.8); font-size: 12px;
    font-style: italic; text-align: center;
  `;
  bubble.textContent = text;
  transcriptEl.appendChild(bubble);
  transcriptEl.scrollTop = transcriptEl.scrollHeight;
}

function handleStreamChunk({ text }) {
  if (!overlayEl) return;
  if (!currentAssistantBubble) {
    currentAssistantBubble = appendTranscriptBubble('assistant', '');
  }
  if (currentAssistantBubble) {
    currentAssistantBubble.textContent = text;
    transcriptEl.scrollTop = transcriptEl.scrollHeight;
  }
}

function handleStreamEnd({ text }) {
  if (!overlayEl) return;

  // Store final transcript
  store.dispatch('ADD_VOICE_TRANSCRIPT', { role: 'assistant', text });
  currentAssistantBubble = null;

  // Speak the response via TTS
  speakResponse(text);

  // After TTS finishes, auto-listen for next input
}

// ── Voice Input (MediaRecorder + VAD) ──

async function startListening() {
  if (isListening || isProcessing) return;

  // Check mic mute — don't listen if muted
  if (store.getState().voice.micMuted) {
    updateStatus('Microphone muted');
    return;
  }

  // If TTS is speaking, interrupt it
  if (isTTSSpeaking) {
    cancelTTS();
  }

  try {
    store.dispatch('SET_VOICE_STATUS', 'listening');
    mediaStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true
      }
    });

    // Setup audio analysis for VAD + waveform
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    const source = audioContext.createMediaStreamSource(mediaStream);
    analyser = audioContext.createAnalyser();
    analyser.fftSize = 256;
    source.connect(analyser);

    // Start MediaRecorder — prefer ogg (supported by whisper-cli) over webm
    const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
      ? 'audio/ogg;codecs=opus'
      : MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
        ? 'audio/webm;codecs=opus'
        : 'audio/webm';
    mediaRecorder = new MediaRecorder(mediaStream, { mimeType });
    const chunks = [];

    mediaRecorder.ondataavailable = (e) => {
      if (e.data.size > 0) chunks.push(e.data);
    };

    mediaRecorder.onstop = async () => {
      isListening = false;
      if (chunks.length === 0) {
        updateStatus('Tap avatar to speak');
        store.dispatch('SET_VOICE_STATUS', 'idle');
        return;
      }

      isProcessing = true;
      updateStatus('Processing...');
      store.dispatch('SET_VOICE_STATUS', 'processing');

      const blob = new Blob(chunks, { type: mimeType });
      await transcribeAudio(blob, mimeType);
      isProcessing = false;
    };

    mediaRecorder.start();
    isListening = true;
    updateStatus('Listening...');
    startWaveform();
    startVAD();

  } catch (err) {
    console.error('[voice-mode] Microphone access failed:', err);
    updateStatus('Microphone access denied');
    store.dispatch('SET_VOICE_STATUS', 'idle');
  }
}

function stopListening() {
  clearVADTimer();
  isListening = false;

  if (mediaRecorder && mediaRecorder.state === 'recording') {
    mediaRecorder.stop();
  }
  stopWaveform();
}

function cleanupAudio() {
  if (mediaStream) {
    mediaStream.getTracks().forEach(t => t.stop());
    mediaStream = null;
  }
  if (audioContext && audioContext.state !== 'closed') {
    audioContext.close().catch(() => {});
    audioContext = null;
    analyser = null;
  }
  mediaRecorder = null;
}

// ── Grok Voice Mode ──

async function detectGrokMode() {
  try {
    const config = await api.get('/api/config');
    // Enable realtime voice if any voice API key is set OR worker proxy is available
    const hasDirectKey = !!(config.voice?.xaiApiKey || config.voice?.openaiApiKey);
    const hasWorkerProxy = !!(config.worker?.url && config.worker?.token);
    useGrokMode = hasDirectKey || hasWorkerProxy;
  } catch {
    useGrokMode = false;
  }
}

async function startGrokSession() {
  grokTranscriptAccumulator = '';
  currentAssistantBubble = null;

  const success = await grokVoice.connect({
    onStatusChange: (status) => {
      const labels = {
        connecting: 'Connecting to Grok...',
        listening: 'Listening...',
        processing: 'Processing...',
        speaking: 'Speaking...'
      };
      updateStatus(labels[status] || status);

      const storeMap = { listening: 'listening', processing: 'processing', speaking: 'speaking' };
      if (storeMap[status]) {
        store.dispatch('SET_VOICE_STATUS', storeMap[status]);
      }
    },

    onTranscriptDelta: (delta, role) => {
      if (role === 'assistant') {
        interruptedCurrentResponse = false;
        grokTranscriptAccumulator += delta;
        if (!currentAssistantBubble) {
          currentAssistantBubble = appendTranscriptBubble('assistant', '');
        }
        if (currentAssistantBubble) {
          currentAssistantBubble.textContent = grokTranscriptAccumulator;
          if (transcriptEl) transcriptEl.scrollTop = transcriptEl.scrollHeight;
        }
        store.dispatch('SET_VOICE_STATUS', 'speaking');
      }
    },

    onTranscriptDone: (text, role) => {
      if (role === 'user' && text.trim()) {
        store.dispatch('ADD_VOICE_TRANSCRIPT', { role: 'user', text: text.trim() });
        appendTranscriptBubble('user', text.trim());
      } else if (role === 'assistant') {
        // If this done event is for an interrupted response whose partial
        // was already saved in onSpeechStarted, skip to avoid duplicates
        if (interruptedCurrentResponse && !grokTranscriptAccumulator.trim()) {
          currentAssistantBubble = null;
          grokTranscriptAccumulator = '';
          return;
        }
        const finalText = grokTranscriptAccumulator || text;
        if (finalText.trim()) {
          store.dispatch('ADD_VOICE_TRANSCRIPT', { role: 'assistant', text: finalText.trim() });
        }
        currentAssistantBubble = null;
        grokTranscriptAccumulator = '';
      }
    },

    onSpeechStarted: () => {
      // If user interrupts mid-response, finalize the partial transcript
      if (grokTranscriptAccumulator.trim() && currentAssistantBubble) {
        store.dispatch('ADD_VOICE_TRANSCRIPT', {
          role: 'assistant',
          text: grokTranscriptAccumulator.trim()
        });
      }
      // Reset so the next response starts fresh
      currentAssistantBubble = null;
      grokTranscriptAccumulator = '';
      interruptedCurrentResponse = true;
      updateStatus('Listening...');
    },

    onSpeechStopped: () => {
      updateStatus('Processing...');
    },

    onResponseDone: () => {
      // Playback queue will transition status to 'listening' when drained
    },

    onError: (error) => {
      console.error('[voice-mode] Grok error:', error);
      updateStatus('Error: ' + error);
    },

    onToolCall: (toolName, args) => {
      // Show a system notification in the transcript when a tool is used
      const labels = {
        delegate_task: `Queuing task: ${args.skill_name || 'unknown'}`,
        send_message: 'Syncing to chat...'
      };
      const label = labels[toolName] || `Tool: ${toolName}`;
      appendSystemBubble(label);
    }
  });

  if (success) {
    startGrokWaveform();
    return;
  } else {
    // Fall back to whisper mode
    updateStatus('Grok unavailable — using fallback voice');
    useGrokMode = false;
    lipSync.enableAudioDriver(false);
    setTimeout(() => startListening(), 300);
    unsubStream = eventBus.on(EVENTS.CHAT_STREAM_CHUNK, handleStreamChunk);
    unsubStreamEnd = eventBus.on(EVENTS.CHAT_STREAM_END, handleStreamEnd);
  }
}

/**
 * Sync the Grok voice transcript to the main chat system.
 * Persists messages in the DB and triggers skill detection so the agent
 * can take action on what was discussed in voice mode.
 */
function syncGrokTranscriptToChat() {
  const transcript = store.getState().voice.transcript;
  if (!transcript || transcript.length === 0) return;

  // Build conversation array for the server
  const conversation = transcript
    .filter(e => e.text?.trim())
    .map(e => ({ role: e.role, text: e.text.trim() }));

  if (conversation.length === 0) return;

  // Fire and forget — don't block the close animation
  api.syncGrokConversation(conversation).catch(err => {
    console.error('[voice-mode] Failed to sync Grok transcript to chat:', err);
  });
}

// ── Waveform for Grok mode ──

function startGrokWaveform() {
  const grokAnalyser = grokVoice.getAnalyser();
  if (!grokAnalyser || !waveformEl) return;

  const ctx = waveformEl.getContext('2d');
  const bufferLength = grokAnalyser.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);

  function draw() {
    if (!grokVoice.isActive()) {
      ctx.clearRect(0, 0, waveformEl.width, waveformEl.height);
      return;
    }

    waveformAnimId = requestAnimationFrame(draw);
    grokAnalyser.getByteTimeDomainData(dataArray);

    ctx.clearRect(0, 0, waveformEl.width, waveformEl.height);
    ctx.lineWidth = 2;
    ctx.strokeStyle = 'rgba(74,158,255,0.6)';
    ctx.beginPath();

    const sliceWidth = waveformEl.width / bufferLength;
    let x = 0;
    for (let i = 0; i < bufferLength; i++) {
      const v = dataArray[i] / 128.0;
      const y = (v * waveformEl.height) / 2;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
      x += sliceWidth;
    }
    ctx.lineTo(waveformEl.width, waveformEl.height / 2);
    ctx.stroke();
  }

  draw();
}

// ── VAD (Voice Activity Detection) ──

function startVAD() {
  if (!analyser) return;

  const dataArray = new Float32Array(analyser.fftSize);
  let hasVoice = false;

  function checkVAD() {
    if (!isListening || !analyser) return;

    analyser.getFloatTimeDomainData(dataArray);
    let rms = 0;
    for (let i = 0; i < dataArray.length; i++) {
      rms += dataArray[i] * dataArray[i];
    }
    rms = Math.sqrt(rms / dataArray.length);

    if (rms > VAD_THRESHOLD) {
      hasVoice = true;
      clearVADTimer();
    } else if (hasVoice) {
      // Voice stopped — start silence timer
      if (!vadSilenceTimer) {
        vadSilenceTimer = setTimeout(() => {
          // Silence detected after voice — stop recording
          stopListening();
        }, VAD_SILENCE_MS);
      }
    }

    if (isListening) {
      requestAnimationFrame(checkVAD);
    }
  }

  requestAnimationFrame(checkVAD);
}

function clearVADTimer() {
  if (vadSilenceTimer) {
    clearTimeout(vadSilenceTimer);
    vadSilenceTimer = null;
  }
}

// ── Waveform visualization ──

function startWaveform() {
  if (!analyser || !waveformEl) return;

  const ctx = waveformEl.getContext('2d');
  const bufferLength = analyser.frequencyBinCount;
  const dataArray = new Uint8Array(bufferLength);

  function draw() {
    if (!isListening && !isTTSSpeaking) {
      // Clear when idle
      ctx.clearRect(0, 0, waveformEl.width, waveformEl.height);
      return;
    }

    waveformAnimId = requestAnimationFrame(draw);
    analyser.getByteTimeDomainData(dataArray);

    ctx.clearRect(0, 0, waveformEl.width, waveformEl.height);
    ctx.lineWidth = 2;
    ctx.strokeStyle = isListening ? 'rgba(74,158,255,0.6)' : 'rgba(168,100,255,0.6)';
    ctx.beginPath();

    const sliceWidth = waveformEl.width / bufferLength;
    let x = 0;
    for (let i = 0; i < bufferLength; i++) {
      const v = dataArray[i] / 128.0;
      const y = (v * waveformEl.height) / 2;
      if (i === 0) ctx.moveTo(x, y);
      else ctx.lineTo(x, y);
      x += sliceWidth;
    }

    ctx.lineTo(waveformEl.width, waveformEl.height / 2);
    ctx.stroke();
  }

  draw();
}

function stopWaveform() {
  if (waveformAnimId) {
    cancelAnimationFrame(waveformAnimId);
    waveformAnimId = null;
  }
}

// ── Audio conversion ──

/**
 * Convert an audio blob to WAV format using Web Audio API.
 * This ensures whisper-cli compatibility without needing ffmpeg on the server.
 */
async function convertBlobToWav(blob) {
  const arrayBuffer = await blob.arrayBuffer();
  const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 16000);

  let audioBuffer;
  try {
    audioBuffer = await ctx.decodeAudioData(arrayBuffer);
  } catch {
    // Decoding failed — return original blob unchanged
    return null;
  }

  // Resample to 16kHz mono
  const offlineCtx = new OfflineAudioContext(1, Math.ceil(audioBuffer.duration * 16000), 16000);
  const source = offlineCtx.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(offlineCtx.destination);
  source.start(0);
  const rendered = await offlineCtx.startRendering();

  // Encode to WAV
  const pcm = rendered.getChannelData(0);
  const wavBuffer = encodeWav(pcm, 16000);
  return new Blob([wavBuffer], { type: 'audio/wav' });
}

function encodeWav(samples, sampleRate) {
  const numSamples = samples.length;
  const buffer = new ArrayBuffer(44 + numSamples * 2);
  const view = new DataView(buffer);

  // RIFF header
  writeString(view, 0, 'RIFF');
  view.setUint32(4, 36 + numSamples * 2, true);
  writeString(view, 8, 'WAVE');

  // fmt chunk
  writeString(view, 12, 'fmt ');
  view.setUint32(16, 16, true);          // chunk size
  view.setUint16(20, 1, true);           // PCM format
  view.setUint16(22, 1, true);           // mono
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true); // byte rate
  view.setUint16(32, 2, true);           // block align
  view.setUint16(34, 16, true);          // bits per sample

  // data chunk
  writeString(view, 36, 'data');
  view.setUint32(40, numSamples * 2, true);

  // PCM data — clamp float32 to int16
  let offset = 44;
  for (let i = 0; i < numSamples; i++) {
    const s = Math.max(-1, Math.min(1, samples[i]));
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
    offset += 2;
  }
  return buffer;
}

function writeString(view, offset, str) {
  for (let i = 0; i < str.length; i++) {
    view.setUint8(offset + i, str.charCodeAt(i));
  }
}

// ── Transcription ──

async function transcribeAudio(blob, mimeType) {
  try {
    updateStatus('Processing audio...');

    // Convert to WAV client-side for whisper-cli compatibility
    let sendBlob = blob;
    let sendMimeType = mimeType;
    if (!mimeType.includes('wav')) {
      const wavBlob = await convertBlobToWav(blob);
      if (wavBlob) {
        sendBlob = wavBlob;
        sendMimeType = 'audio/wav';
      }
    }

    // Convert blob to base64
    const buffer = await sendBlob.arrayBuffer();
    const base64 = btoa(
      new Uint8Array(buffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
    );

    updateStatus('Transcribing...');

    const result = await api.post('/api/voice/transcribe', { audio: base64, mimeType: sendMimeType });

    if (result.transcript) {
      const text = result.transcript.trim();
      if (text) {
        // Add user message to transcript
        store.dispatch('ADD_VOICE_TRANSCRIPT', { role: 'user', text });
        appendTranscriptBubble('user', text);

        // Send to chat
        updateStatus('Thinking...');
        store.dispatch('SET_VOICE_STATUS', 'processing');
        await api.streamMessage(text);
      } else {
        updateStatus('No speech detected');
        store.dispatch('SET_VOICE_STATUS', 'idle');
        setTimeout(() => startListening(), 1000);
      }
    } else {
      updateStatus('Transcription failed');
      store.dispatch('SET_VOICE_STATUS', 'idle');
      setTimeout(() => startListening(), 2000);
    }
  } catch (err) {
    console.error('[voice-mode] Transcription error:', err);
    updateStatus('Error — tap to retry');
    store.dispatch('SET_VOICE_STATUS', 'idle');
  }
}

// ── TTS Output ──

function speakResponse(text) {
  if (!text || !window.speechSynthesis) {
    // No TTS available — just go back to listening
    setTimeout(() => {
      if (overlayEl) startListening();
    }, 500);
    return;
  }

  // Strip markdown formatting
  const cleanText = text
    .replace(/\*\*(.+?)\*\*/g, '$1')
    .replace(/\*(.+?)\*/g, '$1')
    .replace(/`(.+?)`/g, '$1')
    .replace(/#{1,6}\s/g, '')
    .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
    .replace(/[_~]/g, '');

  isTTSSpeaking = true;
  store.dispatch('SET_VOICE_STATUS', 'speaking');
  startSpeaking(); // avatar lip-sync
  updateStatus('Speaking...');

  currentUtterance = new SpeechSynthesisUtterance(cleanText);
  currentUtterance.rate = 0.95;
  currentUtterance.pitch = 1.0;

  currentUtterance.onend = () => {
    isTTSSpeaking = false;
    currentUtterance = null;
    stopSpeaking(); // stop lip-sync
    store.dispatch('SET_VOICE_STATUS', 'idle');
    // Auto-listen for next input
    if (overlayEl) {
      setTimeout(() => startListening(), 300);
    }
  };

  currentUtterance.onerror = () => {
    isTTSSpeaking = false;
    currentUtterance = null;
    stopSpeaking();
    store.dispatch('SET_VOICE_STATUS', 'idle');
    if (overlayEl) {
      setTimeout(() => startListening(), 300);
    }
  };

  window.speechSynthesis.speak(currentUtterance);
}

function cancelTTS() {
  if (window.speechSynthesis) {
    window.speechSynthesis.cancel();
  }
  isTTSSpeaking = false;
  currentUtterance = null;
  stopSpeaking();
  store.dispatch('SET_VOICE_STATUS', 'idle');
}

// ── UI Helpers ──

function updateStatus(text) {
  if (statusEl) statusEl.textContent = text;
}

function handleAvatarClick() {
  if (useGrokMode) {
    // In Grok mode, tapping avatar cycles connection
    if (grokVoice.isActive()) {
      grokVoice.disconnect();
      lipSync.setAmplitude(0);
      updateStatus('Reconnecting...');
      startGrokSession();
    }
    return;
  }

  if (isListening) {
    stopListening();
  } else if (isTTSSpeaking) {
    cancelTTS();
    setTimeout(() => startListening(), 200);
  } else {
    startListening();
  }
}
