/**
 * voice-orb.mjs — Voice Orb component for AI-Do v4.0
 *
 * A living, responsive voice input orb with four visual states:
 *   idle (green), listening (blue), processing (yellow), speaking (purple)
 *
 * Exports:
 *   init()                 — Subscribe to store (call once at startup)
 *   createOrb(size)        — Create and return a voice orb DOM element
 */

import store from '../store.mjs';
import eventBus, { EVENTS } from '../event-bus.mjs';
import api from '../api.mjs';
import { createElement, icon } from '../utils/dom.mjs';

// ── All live orb elements ──
const orbs = [];

// ── Web Speech API singleton ──
let speechRecognition = null;

// ── Voice-initiated conversation tracking ──
// When true, the next agent CHAT_MESSAGE should be spoken aloud via TTS.
let awaitingVoiceResponse = false;

// ── Active TTS utterance (so we can cancel on click) ──
let currentUtterance = null;

// ── Browser-local voice state tracking ──
// When the browser is actively using Web Speech (user clicked the orb),
// ignore server-side voice state broadcasts so they don't override local state.
let browserVoiceActive = false;

// ── MediaRecorder fallback (for Electron where Web Speech hits network errors) ──
let mediaRecorder = null;
let mediaStream = null;
let webSpeechFailed = false; // true after first network/service error → skip Web Speech next time

// ─────────────────────────────────────────────
// SVG icon builders
// ─────────────────────────────────────────────

function createMicIcon(size) {
  return icon('mic', { size });
}

function createSpinnerIcon(size) {
  const ns = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(ns, 'svg');
  svg.setAttribute('width', size);
  svg.setAttribute('height', size);
  svg.setAttribute('viewBox', '0 0 24 24');
  svg.setAttribute('fill', 'none');
  svg.setAttribute('aria-hidden', 'true');

  const circle = document.createElementNS(ns, 'circle');
  circle.setAttribute('cx', '12');
  circle.setAttribute('cy', '12');
  circle.setAttribute('r', '9');
  circle.setAttribute('stroke', 'currentColor');
  circle.setAttribute('stroke-width', '2.5');
  circle.setAttribute('stroke-linecap', 'round');
  circle.setAttribute('stroke-dasharray', '42');
  circle.setAttribute('stroke-dashoffset', '14');
  svg.appendChild(circle);

  svg.classList.add('animate-spin');
  return svg;
}

function createSpeakerIcon(size) {
  const ns = 'http://www.w3.org/2000/svg';
  const svg = document.createElementNS(ns, 'svg');
  svg.setAttribute('width', size);
  svg.setAttribute('height', size);
  svg.setAttribute('viewBox', '0 0 24 24');
  svg.setAttribute('fill', 'none');
  svg.setAttribute('stroke', 'currentColor');
  svg.setAttribute('stroke-width', '2');
  svg.setAttribute('stroke-linecap', 'round');
  svg.setAttribute('stroke-linejoin', 'round');
  svg.setAttribute('aria-hidden', 'true');

  // Speaker body
  const polygon = document.createElementNS(ns, 'polygon');
  polygon.setAttribute('points', '11 5 6 9 2 9 2 15 6 15 11 19 11 5');
  svg.appendChild(polygon);

  // Sound wave arcs
  const path1 = document.createElementNS(ns, 'path');
  path1.setAttribute('d', 'M15.54 8.46a5 5 0 0 1 0 7.07');
  svg.appendChild(path1);

  const path2 = document.createElementNS(ns, 'path');
  path2.setAttribute('d', 'M19.07 4.93a10 10 0 0 1 0 14.14');
  svg.appendChild(path2);

  return svg;
}

// ─────────────────────────────────────────────
// Icon size per orb size variant
// ─────────────────────────────────────────────

function getIconSize(orbSize) {
  return orbSize === 'compact' ? 18 : 36;
}

// ─────────────────────────────────────────────
// Build the appropriate icon for a state
// ─────────────────────────────────────────────

function buildIconForState(state, orbSize) {
  const sz = getIconSize(orbSize);
  switch (state) {
    case 'listening':
      return createMicIcon(sz);
    case 'processing':
      return createSpinnerIcon(sz);
    case 'speaking':
      return createSpeakerIcon(sz);
    case 'idle':
    default:
      return createMicIcon(sz);
  }
}

// ─────────────────────────────────────────────
// Update all orbs from store state
// ─────────────────────────────────────────────

function syncState(status) {
  const state = status || 'idle';
  for (const entry of orbs) {
    const { el, size } = entry;
    const prev = el.dataset.state;
    if (prev === state) continue;

    el.dataset.state = state;

    // Swap icon
    const core = el.querySelector('.voice-orb-core');
    if (core) {
      const oldIcon = core.querySelector('svg');
      const newIcon = buildIconForState(state, size);
      if (oldIcon) {
        core.replaceChild(newIcon, oldIcon);
      } else {
        core.appendChild(newIcon);
      }
    }

    // Update aria-label
    const labels = {
      idle: 'Voice input - click to start listening',
      listening: 'Listening - click to stop',
      processing: 'Processing voice input',
      speaking: 'Speaking - click to stop'
    };
    el.setAttribute('aria-label', labels[state] || 'Voice input');
  }
}

function syncAudioLevel(level) {
  const val = typeof level === 'number' ? Math.min(Math.max(level, 0), 1) : 0;
  for (const entry of orbs) {
    entry.el.style.setProperty('--audio-level', val);
  }
}

// ─────────────────────────────────────────────
// Web Speech API helpers
// ─────────────────────────────────────────────

/**
 * Build a BCP-47 language tag suitable for SpeechRecognition.
 * Chrome/Edge reject bare language codes like 'en' — they need a region
 * suffix (e.g. 'en-US'). Safari is more forgiving but still works with
 * full tags. We map common bare codes to their most widely-supported variant.
 */
function getSpeechLang() {
  const nav = navigator.language || 'en-US';
  if (nav.includes('-')) return nav;          // already has region
  // Common bare → region mappings
  const map = { en: 'en-US', fr: 'fr-FR', de: 'de-DE', es: 'es-ES',
                it: 'it-IT', pt: 'pt-BR', ja: 'ja-JP', zh: 'zh-CN',
                ko: 'ko-KR', ru: 'ru-RU', nl: 'nl-NL', ar: 'ar-SA' };
  return map[nav.toLowerCase()] || `${nav}-${nav.toUpperCase()}`;
}

/**
 * Handle a successful transcript from speech recognition.
 */
function handleTranscript(transcript) {
  store.dispatch('SET_VOICE_STATUS', 'processing');
  eventBus.emit(EVENTS.VOICE_TRANSCRIPT, { text: transcript });

  // Switch user to chat view so they see the conversation
  store.dispatch('SET_VIEW', 'chat');

  // Mark that we expect a voice response (for TTS)
  awaitingVoiceResponse = true;

  // Send transcript as a chat message
  store.dispatch('ADD_MESSAGE', {
    role: 'user',
    content: transcript,
    timestamp: Date.now(),
    metadata: { source: 'voice' }
  });
  api.sendMessage(transcript).catch((err) => {
    console.error('[voice-orb] Error sending voice message:', err);
    awaitingVoiceResponse = false;
    store.dispatch('SET_VOICE_STATUS', 'idle');
    browserVoiceActive = false;
  });
}

// ─────────────────────────────────────────────
// MediaRecorder fallback — record audio + server Whisper
// Used when Web Speech API fails (Electron/network issues)
// ─────────────────────────────────────────────

function startMediaRecorder() {
  if (mediaRecorder) {
    stopMediaRecorder();
    return;
  }

  navigator.mediaDevices.getUserMedia({ audio: true })
    .then((stream) => {
      mediaStream = stream;
      browserVoiceActive = true;
      store.dispatch('SET_VOICE_STATUS', 'listening');

      const chunks = [];
      const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
        ? 'audio/ogg;codecs=opus'
        : MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
          ? 'audio/webm;codecs=opus'
          : MediaRecorder.isTypeSupported('audio/webm')
            ? 'audio/webm'
            : '';

      mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {});

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

      mediaRecorder.onstop = () => {
        // Release microphone
        for (const track of mediaStream.getTracks()) track.stop();
        mediaStream = null;

        if (chunks.length === 0) {
          browserVoiceActive = false;
          store.dispatch('SET_VOICE_STATUS', 'idle');
          return;
        }

        store.dispatch('SET_VOICE_STATUS', 'processing');
        const blob = new Blob(chunks, { type: mediaRecorder.mimeType || 'audio/webm' });

        // Convert to WAV client-side for whisper-cli compatibility, then send
        convertToWavAndSend(blob).catch((err) => {
          console.error('[voice-orb] Audio conversion failed:', err);
          browserVoiceActive = false;
          store.dispatch('SET_VOICE_STATUS', 'idle');
        });
      };

      function convertToWavAndSend(blob) {
        return blob.arrayBuffer()
          .then(ab => {
            const ctx = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)(1, 1, 16000);
            return ctx.decodeAudioData(ab);
          })
          .then(audioBuffer => {
            const offCtx = new OfflineAudioContext(1, Math.ceil(audioBuffer.duration * 16000), 16000);
            const src = offCtx.createBufferSource();
            src.buffer = audioBuffer;
            src.connect(offCtx.destination);
            src.start(0);
            return offCtx.startRendering();
          })
          .then(rendered => {
            const pcm = rendered.getChannelData(0);
            const numSamples = pcm.length;
            const buf = new ArrayBuffer(44 + numSamples * 2);
            const v = new DataView(buf);
            // RIFF header
            [...'RIFF'].forEach((c, i) => v.setUint8(i, c.charCodeAt(0)));
            v.setUint32(4, 36 + numSamples * 2, true);
            [...'WAVE'].forEach((c, i) => v.setUint8(8 + i, c.charCodeAt(0)));
            [...'fmt '].forEach((c, i) => v.setUint8(12 + i, c.charCodeAt(0)));
            v.setUint32(16, 16, true);
            v.setUint16(20, 1, true);
            v.setUint16(22, 1, true);
            v.setUint32(24, 16000, true);
            v.setUint32(28, 32000, true);
            v.setUint16(32, 2, true);
            v.setUint16(34, 16, true);
            [...'data'].forEach((c, i) => v.setUint8(36 + i, c.charCodeAt(0)));
            v.setUint32(40, numSamples * 2, true);
            let off = 44;
            for (let i = 0; i < numSamples; i++) {
              const s = Math.max(-1, Math.min(1, pcm[i]));
              v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
              off += 2;
            }
            return new Blob([buf], { type: 'audio/wav' });
          })
          .then(wavBlob => wavBlob.arrayBuffer())
          .then(ab => {
            const bytes = new Uint8Array(ab);
            let binary = '';
            for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
            const base64 = btoa(binary);
            return api.post('/api/voice/transcribe', { audio: base64, mimeType: 'audio/wav' });
          })
            .then((result) => {
              const transcript = result?.transcript?.trim();
              if (transcript) {
                handleTranscript(transcript);
              } else {
                browserVoiceActive = false;
                store.dispatch('SET_VOICE_STATUS', 'idle');
              }
            })
            .catch((err) => {
              console.error('[voice-orb] Server transcription failed:', err);
              browserVoiceActive = false;
              store.dispatch('SET_VOICE_STATUS', 'idle');
              const errMsg = err.message || '';
              const hint = errMsg.includes('503')
                ? 'Voice not available. Configure an OpenAI API key in Settings, or install whisper-cli.'
                : errMsg.includes('Downloading')
                  ? 'Downloading speech model (first time only). Please try again in a moment.'
                  : 'Speech transcription failed. Please try again.';
              eventBus.emit(EVENTS.NOTIFICATION, { message: hint, type: 'error' });
            });
      }

      mediaRecorder.start();
      console.log('[voice-orb] MediaRecorder started (Whisper fallback)');
    })
    .catch((err) => {
      console.error('[voice-orb] Microphone access denied:', err);
      eventBus.emit(EVENTS.NOTIFICATION, {
        message: 'Microphone access was denied.',
        type: 'error'
      });
    });
}

function stopMediaRecorder() {
  if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop();
  }
  mediaRecorder = null;
  // Stream is stopped in onstop handler
}

// ─────────────────────────────────────────────
// Web Speech API — start / stop
// ─────────────────────────────────────────────

function startWebSpeech() {
  // If Web Speech previously failed with network error (common in Electron),
  // skip directly to MediaRecorder + server Whisper.
  if (webSpeechFailed) {
    startMediaRecorder();
    return true;
  }

  const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
  if (!SpeechRec) {
    console.warn('[voice-orb] Web Speech API not available, trying MediaRecorder fallback');
    startMediaRecorder();
    return true;
  }

  // If already running, stop it first
  if (speechRecognition) {
    stopWebSpeech();
    return false;
  }

  return startRecognitionInstance(SpeechRec, getSpeechLang(), true);
}

/**
 * Create, configure, and start a SpeechRecognition instance.
 * @param {function} SpeechRec - constructor (SpeechRecognition or webkitSpeechRecognition)
 * @param {string}   lang      - BCP-47 language tag
 * @param {boolean}  allowRetry - if true, will retry once with 'en-US' on language error
 */
function startRecognitionInstance(SpeechRec, lang, allowRetry) {
  const rec = new SpeechRec();
  rec.continuous = false;
  rec.interimResults = false;
  rec.lang = lang;

  speechRecognition = rec;
  browserVoiceActive = true;
  store.dispatch('SET_VOICE_STATUS', 'listening');

  rec.onresult = (event) => {
    const transcript = event.results[0]?.[0]?.transcript;
    if (transcript) {
      handleTranscript(transcript);
      // Stay in 'processing' state — will move to 'speaking' or 'idle'
      // when the agent response arrives via the CHAT_MESSAGE handler.
    } else {
      browserVoiceActive = false;
      store.dispatch('SET_VOICE_STATUS', 'idle');
    }
  };

  rec.onerror = (event) => {
    console.warn('[voice-orb] Web Speech error:', event.error);

    // 'language-not-supported': retry once with explicit 'en-US'
    if (event.error === 'language-not-supported' && allowRetry) {
      console.log('[voice-orb] Language not supported, retrying with en-US…');
      try { rec.abort(); } catch {}
      speechRecognition = null;
      startRecognitionInstance(SpeechRec, 'en-US', false);
      return;
    }

    // Network/service errors (common in Electron) → fall back to MediaRecorder + Whisper
    if (event.error === 'network' || event.error === 'service-not-allowed') {
      console.log('[voice-orb] Web Speech unavailable, falling back to MediaRecorder + Whisper');
      webSpeechFailed = true; // Skip Web Speech on future clicks
      try { rec.abort(); } catch {}
      speechRecognition = null;
      browserVoiceActive = false;
      store.dispatch('SET_VOICE_STATUS', 'idle');
      startMediaRecorder();
      return;
    }

    // Show helpful error to user
    if (event.error !== 'no-speech' && event.error !== 'aborted') {
      const hint = event.error === 'not-allowed'
        ? 'Microphone access was denied. Check browser permissions.'
        : `Voice error: ${event.error}`;
      eventBus.emit(EVENTS.NOTIFICATION, { message: hint, type: 'error' });
    }

    browserVoiceActive = false;
    store.dispatch('SET_VOICE_STATUS', 'idle');
  };

  rec.onend = () => {
    // The recognition session has ended (browser released the mic).
    // If we're in 'processing' (transcript was sent) or 'speaking' (TTS),
    // don't reset to idle — let the chat flow handle it.
    // Otherwise (idle, listening, or any unexpected state), clean up.
    const currentStatus = store.getState().voice.status;
    if (currentStatus === 'listening') {
      // Session ended without a result (e.g. no speech detected, timeout)
      browserVoiceActive = false;
      store.dispatch('SET_VOICE_STATUS', 'idle');
    }
    // Always null out the reference so we know the mic is released
    speechRecognition = null;
  };

  try {
    rec.start();
    return true;
  } catch (err) {
    console.error('[voice-orb] Failed to start speech recognition:', err);
    speechRecognition = null;
    browserVoiceActive = false;
    store.dispatch('SET_VOICE_STATUS', 'idle');
    eventBus.emit(EVENTS.NOTIFICATION, {
      message: 'Could not start speech recognition.',
      type: 'error'
    });
    return false;
  }
}

function stopWebSpeech() {
  // Abort the recognition instance to release the microphone immediately
  const rec = speechRecognition;
  speechRecognition = null;    // Clear reference FIRST to prevent re-entry
  browserVoiceActive = false;
  awaitingVoiceResponse = false;

  if (rec) {
    try {
      rec.abort();   // This triggers onend, but we already nulled the ref
    } catch (err) {
      console.warn('[voice-orb] Error aborting speech recognition:', err);
    }
  }

  // Also stop MediaRecorder if active
  stopMediaRecorder();

  // Also cancel any in-progress TTS
  if (window.speechSynthesis) {
    try { window.speechSynthesis.cancel(); } catch {}
  }
  currentUtterance = null;

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

// ─────────────────────────────────────────────
// Click handler — state-dependent behavior
// ─────────────────────────────────────────────

function handleActivation() {
  const { status } = store.getState().voice;

  switch (status) {
    case 'idle': {
      // Always use browser Web Speech API for orb clicks.
      // The desktop bridge voiceListen routes through the server-side
      // voice-activation system which requires wake-word detection to be
      // running. Web Speech works directly in both browser and Electron.
      startWebSpeech();
      break;
    }

    case 'listening': {
      // Stop listening — could be Web Speech, MediaRecorder, or server wake-word
      if (speechRecognition) {
        stopWebSpeech();
      } else if (mediaRecorder && mediaRecorder.state !== 'inactive') {
        // MediaRecorder recording → stop to trigger transcription
        mediaRecorder.stop();
      } else {
        // Server-initiated listen state — force back to idle
        browserVoiceActive = false;
        store.dispatch('SET_VOICE_STATUS', 'idle');
      }
      break;
    }

    case 'processing': {
      // Processing means the transcript was sent and we're waiting for the agent.
      if (speechRecognition) {
        stopWebSpeech();
      } else {
        // Server-initiated or stale state — force idle
        browserVoiceActive = false;
        awaitingVoiceResponse = false;
        store.dispatch('SET_VOICE_STATUS', 'idle');
      }
      break;
    }

    case 'speaking': {
      // Stop TTS
      browserVoiceActive = false;
      awaitingVoiceResponse = false;
      currentUtterance = null;
      if (window.speechSynthesis) {
        window.speechSynthesis.cancel();
      }
      store.dispatch('SET_VOICE_STATUS', 'idle');
      break;
    }

    default: {
      // Unknown state (e.g. 'detecting', 'injecting' from server) — reset to idle
      browserVoiceActive = false;
      awaitingVoiceResponse = false;
      store.dispatch('SET_VOICE_STATUS', 'idle');
      break;
    }
  }
}

// ─────────────────────────────────────────────
// createOrb — build and return a DOM element
// ─────────────────────────────────────────────

export function createOrb(size = 'full') {
  const currentState = store.getState().voice.status || 'idle';

  // Glow layer
  const glow = createElement('div', { className: 'voice-orb-glow' });

  // Core with initial icon
  const core = createElement('div', { className: 'voice-orb-core' });
  core.appendChild(buildIconForState(currentState, size));

  // Ripple rings (3 spans)
  const ripple = createElement('div', { className: 'voice-orb-ripple' }, [
    createElement('span'),
    createElement('span'),
    createElement('span')
  ]);

  // Orb container
  const el = createElement('div', {
    className: `voice-orb voice-orb--${size}`,
    dataset: { state: currentState },
    role: 'button',
    'aria-label': 'Voice input',
    tabindex: '0'
  }, [glow, core, ripple]);

  // Apply current audio level
  const audioLevel = store.getState().voice.audioLevel || 0;
  el.style.setProperty('--audio-level', audioLevel);

  // Click handler
  el.addEventListener('click', (e) => {
    e.preventDefault();
    handleActivation();
  });

  // Keyboard: Enter or Space triggers same as click
  el.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleActivation();
    }
  });

  // Track for store-driven updates
  orbs.push({ el, size });

  return el;
}

// ─────────────────────────────────────────────
// TTS — speak agent response aloud
// ─────────────────────────────────────────────

function stripMarkdown(text) {
  // Remove common markdown formatting for cleaner TTS
  return text
    .replace(/```[\s\S]*?```/g, ' (code block omitted) ')   // code blocks
    .replace(/`([^`]+)`/g, '$1')                              // inline code
    .replace(/!\[.*?\]\(.*?\)/g, '')                          // images
    .replace(/\[([^\]]+)\]\(.*?\)/g, '$1')                    // links → text
    .replace(/#{1,6}\s+/g, '')                                // headings
    .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')            // bold/italic
    .replace(/[-*+]\s+/g, '')                                 // list bullets
    .replace(/\n{2,}/g, '. ')                                 // double newlines → pause
    .replace(/\n/g, ' ')                                      // single newlines
    .trim();
}

function speakText(text) {
  if (!window.speechSynthesis) {
    console.warn('[voice-orb] speechSynthesis not available');
    store.dispatch('SET_VOICE_STATUS', 'idle');
    return;
  }

  // Cancel any in-progress speech
  window.speechSynthesis.cancel();

  const clean = stripMarkdown(text);
  if (!clean) {
    store.dispatch('SET_VOICE_STATUS', 'idle');
    return;
  }

  const utterance = new SpeechSynthesisUtterance(clean);
  utterance.rate = 0.95;
  utterance.pitch = 1.0;
  utterance.volume = 1.0;
  utterance.lang = navigator.language || 'en-US';

  currentUtterance = utterance;
  store.dispatch('SET_VOICE_STATUS', 'speaking');

  utterance.onend = () => {
    currentUtterance = null;
    store.dispatch('SET_VOICE_STATUS', 'idle');
  };

  utterance.onerror = (e) => {
    console.warn('[voice-orb] TTS error:', e.error);
    currentUtterance = null;
    store.dispatch('SET_VOICE_STATUS', 'idle');
  };

  window.speechSynthesis.speak(utterance);
}

// ─────────────────────────────────────────────
// init — subscribe to store (called once)
// ─────────────────────────────────────────────

export function init() {
  // Voice status changes — only update orb visuals for states that make sense
  // in the browser. Server-side states like 'detecting' or 'injecting' should
  // show as idle in the orb unless the browser initiated the action.
  store.subscribe(
    (s) => s.voice.status,
    (status) => {
      // Map server-only states to idle for orb display
      const browserStates = ['idle', 'listening', 'processing', 'speaking'];
      const displayState = browserStates.includes(status) ? status : 'idle';
      syncState(displayState);
    }
  );

  // Audio level changes
  store.subscribe(
    (s) => s.voice.audioLevel,
    (level) => syncAudioLevel(level)
  );

  // Listen for agent chat replies — if voice-initiated, speak them aloud.
  // Check both source and role fields since different code paths may set either.
  eventBus.on(EVENTS.CHAT_MESSAGE, (data) => {
    const isAgent = data?.source === 'agent' || data?.role === 'agent';

    if (!awaitingVoiceResponse) return;
    if (!isAgent) return;

    awaitingVoiceResponse = false;
    browserVoiceActive = false;
    const content = data.content || '';
    if (content) {
      speakText(content);
    } else {
      store.dispatch('SET_VOICE_STATUS', 'idle');
    }
  });

  // When server-side wake word triggers listening (whisper unavailable),
  // start browser Web Speech API so the user can speak via browser STT.
  // Only if the browser isn't already doing something with voice.
  eventBus.on(EVENTS.VOICE_STATE_CHANGE, (data) => {
    const state = data?.state || '';
    if (state === 'listening' && !speechRecognition && !browserVoiceActive) {
      // Server heard the wake word but can't transcribe — use browser STT
      startWebSpeech();
    }
  });
}
