/**
 * grok-voice.mjs — Realtime Voice Agent WebSocket client
 *
 * Supports three connection modes:
 *   1. Direct xAI:    ephemeral token → wss://api.x.ai/v1/realtime
 *   2. Direct OpenAI:  ephemeral token → wss://api.openai.com/v1/realtime
 *   3. Worker proxy:   WebSocket to Cloudflare Worker → proxied to provider
 *
 * Manages bidirectional PCM audio streaming and playback with
 * amplitude-driven lip-sync.
 */

import api from '../api.mjs';
import * as lipSync from './lip-sync.mjs';

// ── Constants ──
const PROVIDER_WS_URLS = {
  xai: 'wss://api.x.ai/v1/realtime',
  openai: 'wss://api.openai.com/v1/realtime'
};
const SAMPLE_RATE = 24000;

// Tools available during voice sessions
const VOICE_TOOLS = [
  {
    type: 'function',
    name: 'delegate_task',
    description: 'Delegate a task to the background agent. Use this when the user asks you to build, create, write, research, analyze, or automate something. The agent will handle it in the background and notify the user when done. Available skills: build-page (HTML games/tools/art), create-file (code/text/HTML files), write-doc (markdown documents), research (web research), analyze (analysis), diagram (visual diagrams), app-build (full applications), mockup (UI mockups), summarize (summaries), ingest (add content to knowledge base), self-improve (create NEW skills, CLI tools, or automation scripts — use when the user wants to add a new capability or skill to the agent).',
    parameters: {
      type: 'object',
      properties: {
        skill_name: {
          type: 'string',
          description: 'The skill to invoke: build-page, create-file, write-doc, research, analyze, diagram, app-build, mockup, summarize, ingest, or self-improve (for creating new agent skills/tools)'
        },
        description: {
          type: 'string',
          description: 'Detailed description of what to create or do, including all user requirements'
        }
      },
      required: ['skill_name', 'description']
    }
  },
  {
    type: 'function',
    name: 'send_message',
    description: 'Send a brief note to the persistent chat log. Use ONLY for critical action items or a final summary of what was discussed — do NOT relay every utterance or repeat what you just said.',
    parameters: {
      type: 'object',
      properties: {
        message: {
          type: 'string',
          description: 'The message text to add to the main chat'
        },
        role: {
          type: 'string',
          description: 'Who the message is from: "user" to relay user intent, or "assistant" to add a note'
        }
      },
      required: ['message']
    }
  }
];

// ── State ──
let ws = null;
let audioContext = null;
let micStream = null;
let micWorklet = null;
let scriptProcessor = null;
let analyserNode = null;
let playbackQueue = [];
let isPlaying = false;
let isConnected = false;
let micMuted = false;
let isWorkerProxy = false;

// Next playback time for gapless audio scheduling
let nextPlayTime = 0;

// Callbacks set by voice-mode.mjs
let onStatusChange = null;
let onTranscriptDelta = null;
let onTranscriptDone = null;
let onSpeechStarted = null;
let onSpeechStopped = null;
let onError = null;
let onResponseDone = null;
let onToolCall = null;

// ── Public API ──

/**
 * Connect to a realtime voice provider.
 * Automatically selects connection mode based on server response.
 * @param {Object} callbacks - Event callbacks
 * @returns {Promise<boolean>} true if connected successfully
 */
export async function connect(callbacks = {}) {
  onStatusChange = callbacks.onStatusChange || (() => {});
  onTranscriptDelta = callbacks.onTranscriptDelta || (() => {});
  onTranscriptDone = callbacks.onTranscriptDone || (() => {});
  onSpeechStarted = callbacks.onSpeechStarted || (() => {});
  onSpeechStopped = callbacks.onSpeechStopped || (() => {});
  onError = callbacks.onError || (() => {});
  onResponseDone = callbacks.onResponseDone || (() => {});
  onToolCall = callbacks.onToolCall || (() => {});

  try {
    onStatusChange('connecting');

    // 1. Get token + config from server (handles provider detection + worker fallback)
    const result = await api.getGrokToken();
    console.log('[voice] Token response:', JSON.stringify(result));
    if (!result.ok) {
      onError(result.error || 'Failed to get voice token');
      return false;
    }

    // 2. Dispatch to appropriate connection mode
    if (result.useWorkerProxy) {
      return await connectViaWorker(result);
    }
    return await connectDirect(result);
  } catch (err) {
    console.error('[voice] Connection failed:', err);
    onError(err.message);
    disconnect();
    return false;
  }
}

/**
 * Disconnect and clean up all resources.
 */
export function disconnect() {
  isConnected = false;
  isWorkerProxy = false;

  if (ws) {
    if (isWorkerProxy) {
      try { ws.send(JSON.stringify({ type: 'disconnect' })); } catch {}
    }
    try { ws.close(); } catch {}
    ws = null;
  }

  stopMicCapture();
  stopPlayback();

  if (audioContext && audioContext.state !== 'closed') {
    audioContext.close().catch(() => {});
  }
  audioContext = null;
  analyserNode = null;
}

/**
 * Mute/unmute the microphone (stops sending PCM, keeps connection alive).
 */
export function setMicMuted(muted) {
  micMuted = muted;
}

/**
 * Check if currently connected.
 */
export function isActive() {
  return isConnected;
}

/**
 * Get the AnalyserNode for waveform visualization.
 */
export function getAnalyser() {
  return analyserNode;
}

// ── Connection Modes ──

/**
 * Connect directly to a voice provider using an ephemeral token.
 */
async function connectDirect(result) {
  const provider = result.voiceProvider || 'xai';
  const wsUrl = PROVIDER_WS_URLS[provider];
  if (!wsUrl) {
    onError(`Unknown voice provider: ${provider}`);
    return false;
  }

  // Extract the client secret from the token response
  const secret = result.token?.client_secret?.value
    || result.token?.client_secret
    || result.token?.value
    || result.token;

  if (!secret || typeof secret !== 'string') {
    console.error('[voice] Cannot extract secret from token:', result.token);
    onError('Invalid token received from server');
    return false;
  }
  console.log(`[voice] Direct ${provider} connection, secret: ${secret.substring(0, 10)}...`);

  // Build subprotocols based on provider
  const subprotocols = provider === 'openai'
    ? ['realtime', `openai-insecure-api-key.${secret}`, 'openai-beta.realtime-v1']
    : [`xai-client-secret.${secret}`];

  ws = new WebSocket(wsUrl, subprotocols);
  isWorkerProxy = false;

  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Connection timeout')), 10000);
    ws.onopen = () => { clearTimeout(timeout); resolve(); };
    ws.onerror = () => { clearTimeout(timeout); reject(new Error('WebSocket connection failed')); };
  });

  // Set up event routing
  ws.onmessage = handleServerEvent;
  ws.onclose = handleClose;
  ws.onerror = handleWsError;

  // Send session config
  sendSessionUpdate(result);

  // Start mic capture
  await startMicCapture();

  isConnected = true;
  onStatusChange('listening');
  return true;
}

/**
 * Connect through the Cloudflare Worker WebSocket proxy.
 */
async function connectViaWorker(result) {
  const workerUrl = result.workerUrl;
  const provider = result.voiceProvider || 'xai';

  console.log(`[voice] Worker proxy connection via ${workerUrl}, provider: ${provider}`);

  // Connect to the worker's WebSocket endpoint
  const wsUrl = workerUrl.replace(/^http/, 'ws');
  ws = new WebSocket(wsUrl);
  isWorkerProxy = true;

  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Worker connection timeout')), 10000);
    ws.onopen = () => { clearTimeout(timeout); resolve(); };
    ws.onerror = () => { clearTimeout(timeout); reject(new Error('Worker WebSocket connection failed')); };
  });

  // Set up event routing (before connect message so we catch errors)
  ws.onmessage = handleWorkerEvent;
  ws.onclose = handleClose;
  ws.onerror = handleWsError;

  // Build tool instructions
  const toolInstructions = `\n\nIMPORTANT: You have tools available. When the user asks you to build, create, write, generate, or make something (a game, page, file, document, diagram, app, etc.), you MUST use the delegate_task tool to hand it off to the background agent. NEVER generate code or HTML in your voice response — always delegate. After delegating, tell the user the task has been queued and they'll be notified when it's done. Use send_message to relay important context to the main chat when needed.`;

  // Send connect message with session config and auth token
  ws.send(JSON.stringify({
    type: 'connect',
    token: result.workerToken,
    provider,
    session: {
      voice: result.voice || 'Sal',
      instructions: (result.instructions || 'You are a helpful AI assistant.') + toolInstructions,
      turn_detection: { type: 'server_vad' },
      tools: VOICE_TOOLS,
      tool_choice: 'auto'
    }
  }));

  // Wait for connected confirmation
  await new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Worker proxy timed out waiting for provider')), 15000);
    const handler = (event) => {
      let data;
      try { data = JSON.parse(event.data); } catch { return; }
      if (data.type === 'connected') {
        clearTimeout(timeout);
        ws.removeEventListener('message', handler);
        // Re-route all future messages through the standard handler
        ws.onmessage = handleServerEvent;
        resolve();
      } else if (data.type === 'error') {
        clearTimeout(timeout);
        ws.removeEventListener('message', handler);
        reject(new Error(data.error || 'Worker proxy error'));
      }
    };
    ws.addEventListener('message', handler);
  });

  // Start mic capture
  await startMicCapture();

  isConnected = true;
  onStatusChange('listening');
  return true;
}

/**
 * Send session.update to configure the voice session.
 */
function sendSessionUpdate(result) {
  const toolInstructions = `\n\nIMPORTANT: You have tools available. When the user asks you to build, create, write, generate, or make something (a game, page, file, document, diagram, app, etc.), you MUST use the delegate_task tool to hand it off to the background agent. NEVER generate code or HTML in your voice response — always delegate. After delegating, tell the user the task has been queued and they'll be notified when it's done. Use send_message to relay important context to the main chat when needed.`;

  const sessionConfig = {
    type: 'session.update',
    session: {
      voice: result.voice || 'Sal',
      instructions: (result.instructions || 'You are a helpful AI assistant.') + toolInstructions,
      turn_detection: { type: 'server_vad' },
      tools: VOICE_TOOLS,
      tool_choice: 'auto'
    }
  };
  console.log('[voice] Sending session.update:', JSON.stringify(sessionConfig));
  ws.send(JSON.stringify(sessionConfig));
}

// ── Server Event Routing ──

function handleServerEvent(event) {
  let data;
  try {
    data = JSON.parse(event.data);
  } catch {
    console.warn('[voice] Non-JSON message:', event.data);
    return;
  }

  // Log all events for debugging (except frequent audio deltas)
  if (data.type !== 'response.output_audio.delta') {
    console.log('[voice] Event:', data.type, data);
  }

  switch (data.type) {
    case 'input_audio_buffer.speech_started':
      onSpeechStarted();
      onStatusChange('listening');
      // Interrupt any ongoing playback when user speaks
      stopPlayback();
      lipSync.setAmplitude(0);
      break;

    case 'input_audio_buffer.speech_stopped':
      onSpeechStopped();
      onStatusChange('processing');
      break;

    case 'conversation.item.input_audio_transcription.completed':
      if (data.transcript) {
        onTranscriptDone(data.transcript, 'user');
      }
      break;

    case 'response.output_audio.delta':
      if (data.delta) {
        const pcmBytes = base64ToArrayBuffer(data.delta);
        queueAudioChunk(pcmBytes);
        analyzeLipSync(pcmBytes);
      }
      onStatusChange('speaking');
      break;

    case 'response.output_audio_transcript.delta':
      if (data.delta) {
        onTranscriptDelta(data.delta, 'assistant');
      }
      break;

    case 'response.output_audio_transcript.done':
      if (data.transcript) {
        onTranscriptDone(data.transcript, 'assistant');
      }
      break;

    case 'response.function_call_arguments.done':
      // Voice AI is calling a tool — execute it and return the result
      handleToolCall(data);
      break;

    case 'response.done':
      onResponseDone();
      // Status will transition to 'listening' once playback queue drains
      break;

    case 'error':
      console.error('[voice] Server error:', JSON.stringify(data));
      onError(data.error?.message || data.error?.code || JSON.stringify(data.error) || 'Unknown error');
      break;
  }
}

/**
 * Handle worker-specific events (connected, disconnected, error).
 * Standard voice events are handled by handleServerEvent after connection.
 */
function handleWorkerEvent(event) {
  let data;
  try {
    data = JSON.parse(event.data);
  } catch {
    return;
  }

  if (data.type === 'disconnected') {
    console.log('[voice] Worker proxy disconnected');
    isConnected = false;
    stopPlayback();
    lipSync.setAmplitude(0);
  } else if (data.type === 'error') {
    console.error('[voice] Worker proxy error:', data.error);
    onError(data.error || 'Worker proxy error');
  } else {
    // Forward to standard event handler
    handleServerEvent(event);
  }
}

function handleClose(event) {
  console.log('[voice] WebSocket closed:', event.code, event.reason);
  isConnected = false;
  isWorkerProxy = false;
  stopPlayback();
  lipSync.setAmplitude(0);
}

function handleWsError(event) {
  console.error('[voice] WebSocket error:', event);
  onError('WebSocket error');
}

// ── Tool Call Handling ──

async function handleToolCall(data) {
  const callId = data.call_id;
  const fnName = data.name;
  let args = {};

  try {
    args = JSON.parse(data.arguments || '{}');
  } catch {
    console.warn('[voice] Failed to parse tool arguments:', data.arguments);
  }

  console.log('[voice] Tool call:', fnName, args);
  onToolCall(fnName, args);

  // Execute the tool via the server
  let result;
  try {
    result = await api.executeGrokTool(fnName, args);
  } catch (err) {
    result = { ok: false, error: err.message };
  }

  console.log('[voice] Tool result:', result);

  // Send the result back to the voice AI
  if (ws && ws.readyState === WebSocket.OPEN) {
    // Create conversation item with function output
    ws.send(JSON.stringify({
      type: 'conversation.item.create',
      item: {
        type: 'function_call_output',
        call_id: callId,
        output: JSON.stringify(result)
      }
    }));

    // Request continuation of the conversation
    ws.send(JSON.stringify({ type: 'response.create' }));
  }
}

// ── Mic Capture ──

async function startMicCapture() {
  micStream = await navigator.mediaDevices.getUserMedia({
    audio: {
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl: true,
      sampleRate: SAMPLE_RATE
    }
  });

  audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
  const source = audioContext.createMediaStreamSource(micStream);

  // Analyser for waveform visualization
  analyserNode = audioContext.createAnalyser();
  analyserNode.fftSize = 256;
  source.connect(analyserNode);

  // Try AudioWorklet, fall back to ScriptProcessor
  try {
    await startMicWorklet(source);
  } catch (err) {
    console.warn('[voice] AudioWorklet unavailable, using ScriptProcessor:', err.message);
    startMicScriptProcessor(source);
  }
}

async function startMicWorklet(source) {
  const workletCode = `
    class PcmProcessor extends AudioWorkletProcessor {
      process(inputs) {
        const input = inputs[0];
        if (input.length > 0 && input[0].length > 0) {
          this.port.postMessage(input[0]);
        }
        return true;
      }
    }
    registerProcessor('pcm-processor', PcmProcessor);
  `;
  const blob = new Blob([workletCode], { type: 'application/javascript' });
  const url = URL.createObjectURL(blob);

  await audioContext.audioWorklet.addModule(url);
  URL.revokeObjectURL(url);

  micWorklet = new AudioWorkletNode(audioContext, 'pcm-processor');
  micWorklet.port.onmessage = (e) => sendPcm(e.data);
  source.connect(micWorklet);
  // Connect to destination so worklet stays alive (output is silent)
  micWorklet.connect(audioContext.destination);
}

function startMicScriptProcessor(source) {
  scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
  scriptProcessor.onaudioprocess = (e) => {
    sendPcm(e.inputBuffer.getChannelData(0));
  };
  source.connect(scriptProcessor);
  scriptProcessor.connect(audioContext.destination);
}

function sendPcm(float32Samples) {
  if (!ws || ws.readyState !== WebSocket.OPEN || micMuted) return;

  // Convert float32 to int16 PCM
  const int16 = new Int16Array(float32Samples.length);
  for (let i = 0; i < float32Samples.length; i++) {
    const s = Math.max(-1, Math.min(1, float32Samples[i]));
    int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
  }

  ws.send(JSON.stringify({
    type: 'input_audio_buffer.append',
    audio: arrayBufferToBase64(int16.buffer)
  }));
}

function stopMicCapture() {
  if (micWorklet) {
    try { micWorklet.disconnect(); } catch {}
    micWorklet = null;
  }
  if (scriptProcessor) {
    try { scriptProcessor.disconnect(); } catch {}
    scriptProcessor = null;
  }
  if (micStream) {
    micStream.getTracks().forEach(t => t.stop());
    micStream = null;
  }
}

// ── Audio Playback ──

function queueAudioChunk(pcmArrayBuffer) {
  playbackQueue.push(pcmArrayBuffer);
  if (!isPlaying) {
    isPlaying = true;
    nextPlayTime = audioContext.currentTime;
    drainPlaybackQueue();
  }
}

function drainPlaybackQueue() {
  if (!audioContext || playbackQueue.length === 0) {
    isPlaying = false;
    lipSync.setAmplitude(0);
    onStatusChange('listening');
    return;
  }

  const pcmData = playbackQueue.shift();

  // Convert int16 PCM to float32
  const int16 = new Int16Array(pcmData);
  const float32 = new Float32Array(int16.length);
  for (let i = 0; i < int16.length; i++) {
    float32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
  }

  const buffer = audioContext.createBuffer(1, float32.length, SAMPLE_RATE);
  buffer.getChannelData(0).set(float32);

  const source = audioContext.createBufferSource();
  source.buffer = buffer;
  source.connect(audioContext.destination);

  // Schedule gapless playback
  const startTime = Math.max(nextPlayTime, audioContext.currentTime);
  source.start(startTime);
  nextPlayTime = startTime + buffer.duration;

  source.onended = () => drainPlaybackQueue();
}

function stopPlayback() {
  playbackQueue = [];
  isPlaying = false;
  nextPlayTime = 0;
}

// ── Lip-Sync Analysis ──

function analyzeLipSync(pcmArrayBuffer) {
  const int16 = new Int16Array(pcmArrayBuffer);
  if (int16.length === 0) return;

  let sumSquares = 0;
  for (let i = 0; i < int16.length; i++) {
    const normalized = int16[i] / 32768;
    sumSquares += normalized * normalized;
  }
  const rms = Math.sqrt(sumSquares / int16.length);

  lipSync.setAmplitude(rms);
}

// ── Base64 Utilities ──

function base64ToArrayBuffer(base64) {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes.buffer;
}

function arrayBufferToBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}
