#!/usr/bin/env node
/**
 * multimodal/speech.mjs — Text-to-speech and speech-to-text utilities.
 *
 * Wraps platform-native TTS (macOS `say`, Linux `espeak`, or `piper`)
 * and STT (`whisper` CLI) with availability detection and caching.
 *
 * Pure ESM. No build step. Uses only Node.js standard APIs.
 */

import { execSync, spawn } from 'child_process';
import { existsSync, readFileSync, unlinkSync, mkdirSync, createWriteStream, readdirSync, renameSync, copyFileSync, rmdirSync } from 'fs';
import { resolve, dirname, basename, join } from 'path';
import { fileURLToPath } from 'url';
import https from 'https';
import http from 'http';
import os from 'os';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// ── Data directory (supports AGENT_DATA_DIR env override) ──
const DATA_DIR = process.env.AGENT_DATA_DIR
  ? resolve(process.env.AGENT_DATA_DIR)
  : join(__dirname, '..', 'data');

// ── Availability caches ──────────────────────────────────────────

let _ttsAvailable = null;   // null = not yet checked
let _ttsBackend = null;     // 'say' | 'espeak' | 'piper' | null
let _sttAvailable = null;   // null = not yet checked

/**
 * Checks whether a CLI command exists on the PATH.
 * @param {string} cmd
 * @returns {boolean}
 */
function commandExists(cmd) {
  try {
    const checkCmd = process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`;
    execSync(checkCmd, { stdio: 'ignore' });
    return true;
  } catch {
    return false;
  }
}

// ── TTS ──────────────────────────────────────────────────────────

/**
 * Returns true if at least one TTS backend is available.
 * Result is cached after the first call.
 * @returns {boolean}
 */
export function isTTSAvailable() {
  if (_ttsAvailable !== null) return _ttsAvailable;

  for (const cmd of ['say', 'espeak', 'piper']) {
    if (commandExists(cmd)) {
      _ttsBackend = cmd;
      _ttsAvailable = true;
      return true;
    }
  }
  _ttsBackend = null;
  _ttsAvailable = false;
  return false;
}

/**
 * Convert text to speech using the first available TTS backend.
 *
 * @param {string} text       The text to speak.
 * @param {object} [opts]     Options.
 * @param {string} [opts.voice]      Voice name (backend-specific).
 * @param {string} [opts.outputPath] If provided, write audio to this file instead of playing.
 * @returns {Promise<{success: boolean, outputPath?: string}>}
 */
export async function textToSpeech(text, opts = {}) {
  if (!text || typeof text !== 'string' || text.trim().length === 0) {
    throw new Error('textToSpeech requires non-empty text');
  }

  if (!isTTSAvailable()) {
    throw new Error('No TTS backend available (need say, espeak, or piper)');
  }

  const { voice, outputPath } = opts;

  return new Promise((resolvePromise, reject) => {
    let args;

    switch (_ttsBackend) {
      case 'say': {
        // macOS say
        args = [];
        if (voice) args.push('-v', voice);
        if (outputPath) args.push('-o', outputPath);
        args.push(text);
        break;
      }
      case 'espeak': {
        args = [];
        if (voice) args.push('-v', voice);
        if (outputPath) args.push('-w', outputPath);
        args.push(text);
        break;
      }
      case 'piper': {
        // piper reads text from stdin
        args = ['--output_file', outputPath || '/dev/null'];
        if (voice) args.push('--model', voice);
        break;
      }
      default:
        return reject(new Error('No TTS backend resolved'));
    }

    const child = spawn(_ttsBackend, args, { stdio: _ttsBackend === 'piper' ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'] });

    let stderr = '';
    if (child.stderr) {
      child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
    }

    // For piper, pipe text via stdin
    if (_ttsBackend === 'piper') {
      child.stdin.write(text);
      child.stdin.end();
    }

    child.on('error', (err) => reject(new Error(`TTS spawn error: ${err.message}`)));

    child.on('close', (code) => {
      if (code !== 0) {
        return reject(new Error(`TTS exited with code ${code}: ${stderr.trim()}`));
      }
      const result = { success: true };
      if (outputPath) result.outputPath = outputPath;
      resolvePromise(result);
    });
  });
}

// ── STT ──────────────────────────────────────────────────────────

// ── Whisper model auto-download ──

const WHISPER_MODELS_DIR = join(DATA_DIR, 'whisper-models');
const WHISPER_MODEL_FILE = 'ggml-base.en.bin';
const WHISPER_MODEL_PATH = join(WHISPER_MODELS_DIR, WHISPER_MODEL_FILE);
const WHISPER_MODEL_URL = `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${WHISPER_MODEL_FILE}`;

// ── Whisper binary auto-download (Windows) ──

const WHISPER_BIN_DIR = join(DATA_DIR, 'whisper-bin');
const WHISPER_VERSION = 'v1.8.3';
const WHISPER_CLI_NAME = process.platform === 'win32' ? 'whisper-cli.exe' : 'whisper-cli';
const WHISPER_LOCAL_BIN = join(WHISPER_BIN_DIR, WHISPER_CLI_NAME);

// Release asset for Windows x64 (OpenBLAS build for better performance)
const WHISPER_RELEASE_ASSETS = {
  'win32-x64': `https://github.com/ggml-org/whisper.cpp/releases/download/${WHISPER_VERSION}/whisper-blas-bin-x64.zip`,
  'win32-ia32': `https://github.com/ggml-org/whisper.cpp/releases/download/${WHISPER_VERSION}/whisper-blas-bin-Win32.zip`
};

let _modelDownloading = false;
let _binaryDownloading = false;

/**
 * Ensure the whisper model file exists. Downloads it automatically if missing.
 * @returns {Promise<string>} Path to the model file
 */
export async function ensureWhisperModel() {
  if (existsSync(WHISPER_MODEL_PATH)) return WHISPER_MODEL_PATH;
  if (_modelDownloading) {
    // Wait for in-progress download
    while (_modelDownloading) {
      await new Promise(r => setTimeout(r, 500));
    }
    if (existsSync(WHISPER_MODEL_PATH)) return WHISPER_MODEL_PATH;
    throw new Error('Whisper model download failed');
  }

  _modelDownloading = true;
  console.log(`[speech] Downloading Whisper model: ${WHISPER_MODEL_FILE} (~142 MB)...`);

  try {
    mkdirSync(WHISPER_MODELS_DIR, { recursive: true });
    await downloadFile(WHISPER_MODEL_URL, WHISPER_MODEL_PATH, null);
    console.log(`[speech] Whisper model downloaded to ${WHISPER_MODEL_PATH}`);
    return WHISPER_MODEL_PATH;
  } catch (err) {
    // Clean up partial download
    try { unlinkSync(WHISPER_MODEL_PATH); } catch {}
    throw new Error(`Failed to download Whisper model: ${err.message}`);
  } finally {
    _modelDownloading = false;
  }
}

/**
 * Ensure the whisper-cli binary exists. Downloads it automatically on Windows.
 * On macOS/Linux, provides install instructions.
 * @param {function} [onProgress] - Optional callback for progress updates: (message) => void
 * @returns {Promise<string>} Path to the whisper-cli binary
 */
export async function ensureWhisperBinary(onProgress) {
  // Check if already downloaded locally
  if (existsSync(WHISPER_LOCAL_BIN)) return WHISPER_LOCAL_BIN;

  // Check if available on system PATH
  if (commandExists('whisper-cli') || commandExists('whisper')) {
    return null; // signal to use system binary
  }

  if (_binaryDownloading) {
    while (_binaryDownloading) {
      await new Promise(r => setTimeout(r, 500));
    }
    if (existsSync(WHISPER_LOCAL_BIN)) return WHISPER_LOCAL_BIN;
    throw new Error('Whisper binary download failed');
  }

  const platform = process.platform;
  const arch = os.arch();
  const assetKey = `${platform}-${arch}`;
  const assetUrl = WHISPER_RELEASE_ASSETS[assetKey];

  if (!assetUrl) {
    const instructions = platform === 'darwin'
      ? 'brew install whisper-cpp'
      : platform === 'linux'
        ? 'See https://github.com/ggerganov/whisper.cpp#building for build instructions'
        : 'No pre-built binary available for your platform';
    throw new Error(`Auto-download not available for ${platform}/${arch}. Install manually: ${instructions}`);
  }

  _binaryDownloading = true;
  const msg = `Downloading whisper.cpp ${WHISPER_VERSION} for ${platform}/${arch}...`;
  console.log(`[speech] ${msg}`);
  if (onProgress) onProgress(msg);

  try {
    mkdirSync(WHISPER_BIN_DIR, { recursive: true });

    // Download the zip to a temp file
    const tmpZip = join(WHISPER_BIN_DIR, 'whisper-download.zip');
    await downloadFile(assetUrl, tmpZip, onProgress);

    // Extract using Node.js built-in zip support or child_process
    const extractMsg = 'Extracting whisper binary...';
    console.log(`[speech] ${extractMsg}`);
    if (onProgress) onProgress(extractMsg);

    await extractZip(tmpZip, WHISPER_BIN_DIR);

    // Clean up zip
    try { unlinkSync(tmpZip); } catch {}

    if (!existsSync(WHISPER_LOCAL_BIN)) {
      // The zip extracts into a subdirectory (e.g. Release/).
      // Move ALL files (exe + DLLs) to WHISPER_BIN_DIR so DLLs are alongside the exe.
      const found = findFileRecursive(WHISPER_BIN_DIR, WHISPER_CLI_NAME);
      if (found) {
        const srcDir = dirname(found);
        if (srcDir !== WHISPER_BIN_DIR) {
          const files = readdirSync(srcDir, { withFileTypes: true });
          for (const f of files) {
            if (f.isFile()) {
              const src = join(srcDir, f.name);
              const dest = join(WHISPER_BIN_DIR, f.name);
              try { renameSync(src, dest); } catch {
                // Cross-device: fallback to copy + delete
                copyFileSync(src, dest);
                try { unlinkSync(src); } catch {}
              }
            }
          }
          // Clean up empty subdirectory
          try { rmdirSync(srcDir); } catch {}
        }
      } else {
        throw new Error(`whisper-cli binary not found after extraction`);
      }
    }

    console.log(`[speech] Whisper binary ready at ${WHISPER_LOCAL_BIN}`);
    if (onProgress) onProgress('Whisper binary ready');
    return WHISPER_LOCAL_BIN;
  } catch (err) {
    // Clean up on failure
    try { unlinkSync(join(WHISPER_BIN_DIR, 'whisper-download.zip')); } catch {}
    throw new Error(`Failed to download Whisper binary: ${err.message}`);
  } finally {
    _binaryDownloading = false;
  }
}

/**
 * Extract a zip file using platform tools.
 */
async function extractZip(zipPath, destDir) {
  return new Promise((resolveP, reject) => {
    let cmd, args;
    if (process.platform === 'win32') {
      // Use PowerShell's Expand-Archive
      cmd = 'powershell';
      args = ['-NoProfile', '-Command',
        `Expand-Archive -Path '${zipPath}' -DestinationPath '${destDir}' -Force`];
    } else {
      cmd = 'unzip';
      args = ['-o', zipPath, '-d', destDir];
    }

    const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
    let stderr = '';
    if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString(); });
    child.on('error', (err) => reject(new Error(`Zip extraction error: ${err.message}`)));
    child.on('close', (code) => {
      if (code !== 0) return reject(new Error(`Zip extraction failed (code ${code}): ${stderr.trim()}`));
      resolveP();
    });
  });
}

/**
 * Recursively search for a file by name within a directory.
 */
function findFileRecursive(dir, filename) {
  try {
    const entries = readdirSync(dir, { withFileTypes: true });
    for (const entry of entries) {
      const fullPath = join(dir, entry.name);
      if (entry.isFile() && entry.name === filename) return fullPath;
      if (entry.isDirectory()) {
        const found = findFileRecursive(fullPath, filename);
        if (found) return found;
      }
    }
  } catch {}
  return null;
}

/**
 * Download a file with redirect support.
 * @param {string} url
 * @param {string} destPath
 * @param {function} [onProgress] - Optional progress callback
 * @param {number} [maxRedirects]
 */
function downloadFile(url, destPath, onProgress, maxRedirects = 5) {
  return new Promise((resolve, reject) => {
    if (maxRedirects <= 0) return reject(new Error('Too many redirects'));
    const lib = url.startsWith('https') ? https : http;
    lib.get(url, (res) => {
      // Handle redirects (HuggingFace uses 302)
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        res.resume(); // consume response to free socket
        return downloadFile(res.headers.location, destPath, onProgress, maxRedirects - 1)
          .then(resolve).catch(reject);
      }
      if (res.statusCode !== 200) {
        res.resume();
        return reject(new Error(`HTTP ${res.statusCode}`));
      }

      const total = parseInt(res.headers['content-length'] || '0', 10);
      let downloaded = 0;
      let lastLog = 0;
      const file = createWriteStream(destPath);

      res.on('data', (chunk) => {
        downloaded += chunk.length;
        const now = Date.now();
        if (total && now - lastLog > 5000) {
          const pct = Math.round((downloaded / total) * 100);
          const msg = `Downloading: ${pct}% (${Math.round(downloaded / 1048576)} MB / ${Math.round(total / 1048576)} MB)`;
          console.log(`[speech] ${msg}`);
          if (onProgress) onProgress(msg);
          lastLog = now;
        }
      });

      res.pipe(file);
      file.on('finish', () => { file.close(); resolve(); });
      file.on('error', (err) => { file.close(); reject(err); });
      res.on('error', reject);
    }).on('error', reject);
  });
}

// ── OpenAI-compatible Whisper API transcription ──

/**
 * Transcribe audio using an OpenAI-compatible /v1/audio/transcriptions API.
 * Works with OpenAI, Groq, and any compatible provider.
 *
 * @param {string} audioPath - Path to audio file
 * @param {object} opts - { apiKey, baseUrl }
 * @returns {Promise<string|null>} transcript or null if not available
 */
export async function transcribeViaApi(audioPath, { apiKey, baseUrl = 'https://api.openai.com' } = {}) {
  if (!apiKey) return null;

  const { readFile: readFileAsync } = await import('fs/promises');
  const audioData = await readFileAsync(audioPath);
  const filename = basename(audioPath);

  // Build multipart form data manually (no external deps)
  const boundary = '----WhisperBoundary' + Date.now();
  const parts = [];

  // model field
  parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-1`);

  // file field
  const ext = filename.split('.').pop()?.toLowerCase() || 'webm';
  const mimeMap = { webm: 'audio/webm', wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg' };
  const mime = mimeMap[ext] || 'audio/webm';
  parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`);

  const header = Buffer.from(parts.join('\r\n') + '\r\n', 'utf-8');
  const footer = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf-8');
  const body = Buffer.concat([header, audioData, footer]);

  const url = `${baseUrl.replace(/\/$/, '')}/v1/audio/transcriptions`;
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': `multipart/form-data; boundary=${boundary}`
    },
    body
  });

  if (!response.ok) {
    const text = await response.text().catch(() => '');
    console.warn(`[speech] Whisper API error: ${response.status} ${text}`);
    return null; // Let caller fall through to local whisper
  }

  const result = await response.json();
  return result.text || null;
}

// ── STT backend resolution ──
let _sttBackend = null;  // 'whisper' | 'whisper-cli' | path to local binary | null

/**
 * Returns true if a whisper CLI is available (openai-whisper, whisper.cpp on PATH, or local binary).
 * Result is cached after the first call.
 * @returns {boolean}
 */
export function isSTTAvailable() {
  if (_sttAvailable !== null) return _sttAvailable;

  // 1. Check for locally downloaded binary first
  if (existsSync(WHISPER_LOCAL_BIN)) {
    _sttBackend = WHISPER_LOCAL_BIN;
    _sttAvailable = true;
    return true;
  }

  // 2. Try system-wide whisper-cli or openai-whisper
  for (const cmd of ['whisper-cli', 'whisper']) {
    if (commandExists(cmd)) {
      _sttBackend = cmd;
      _sttAvailable = true;
      return true;
    }
  }

  _sttBackend = null;
  _sttAvailable = false;
  return false;
}

/**
 * Reset the STT availability cache (call after downloading binary).
 */
export function resetSTTCache() {
  _sttAvailable = null;
  _sttBackend = null;
}

/**
 * Convert an audio file to WAV using ffmpeg.
 * Returns true if conversion succeeded, false if ffmpeg is not available.
 */
async function convertAudioToWav(inputPath, outputPath) {
  if (!commandExists('ffmpeg')) return false;

  return new Promise((res) => {
    const child = spawn('ffmpeg', [
      '-i', inputPath, '-ar', '16000', '-ac', '1', '-f', 'wav', '-y', outputPath
    ], { stdio: ['ignore', 'pipe', 'pipe'] });

    child.on('error', () => res(false));
    child.on('close', (code) => {
      res(code === 0 && existsSync(outputPath));
    });
  });
}

/**
 * Transcribe an audio file to text using OpenAI Whisper CLI.
 *
 * @param {string} audioPath  Path to the audio file (wav, mp3, etc.)
 * @returns {Promise<string>} The transcribed text.
 */
export async function speechToText(audioPath) {
  if (!audioPath || typeof audioPath !== 'string') {
    throw new Error('speechToText requires a valid audio file path');
  }

  if (!existsSync(audioPath)) {
    throw new Error(`Audio file not found: ${audioPath}`);
  }

  if (!isSTTAvailable()) {
    throw new Error('Whisper CLI is not available (install with: pip install openai-whisper  OR  brew install whisper-cpp)');
  }

  // whisper-cli supports: flac, mp3, ogg, wav. Convert webm to wav if needed.
  let actualPath = audioPath;
  let convertedPath = null;
  const ext = basename(audioPath).split('.').pop()?.toLowerCase();
  if (ext === 'webm' || ext === 'm4a') {
    convertedPath = audioPath.replace(/\.[^.]+$/, '.wav');
    const converted = await convertAudioToWav(audioPath, convertedPath);
    if (converted) {
      actualPath = convertedPath;
    } else {
      // No conversion possible — try passing the original, whisper may handle it
      convertedPath = null;
      console.warn(`[speech] Cannot convert ${ext} to wav (ffmpeg not available). Trying direct transcription...`);
    }
  }

  const audioDir = dirname(resolve(actualPath));
  const audioBase = basename(actualPath).replace(/\.[^.]+$/, '');

  // Determine if this is a whisper.cpp backend (local binary or system whisper-cli)
  const isWhisperCpp = _sttBackend !== 'whisper';

  // For whisper.cpp backends, ensure the model is downloaded
  if (isWhisperCpp) {
    await ensureWhisperModel();
  }

  return new Promise((resolvePromise, reject) => {
    let args;
    if (isWhisperCpp) {
      // whisper.cpp: whisper-cli -f <file> -m <model> --no-timestamps
      args = ['-f', actualPath, '--no-timestamps', '-m', WHISPER_MODEL_PATH];
    } else {
      // openai-whisper (Python): whisper <file> --output_format txt --output_dir <dir>
      args = [actualPath, '--output_format', 'txt', '--output_dir', audioDir];
    }

    const child = spawn(_sttBackend, args, {
      stdio: ['ignore', 'pipe', 'pipe']
    });

    let stdout = '';
    let stderr = '';
    if (child.stdout) {
      child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
    }
    if (child.stderr) {
      child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
    }

    child.on('error', (err) => reject(new Error(`Whisper spawn error: ${err.message}`)));

    child.on('close', (code) => {
      // Clean up converted file if we created one
      if (convertedPath) {
        try { unlinkSync(convertedPath); } catch {}
      }

      if (code !== 0) {
        return reject(new Error(`Whisper exited with code ${code}: ${stderr.trim()}`));
      }

      if (isWhisperCpp) {
        // whisper.cpp outputs transcript to stdout
        const transcript = stdout.trim();
        resolvePromise(transcript);
      } else {
        // openai-whisper writes <basename>.txt alongside the input file
        const txtPath = join(audioDir, `${audioBase}.txt`);
        if (!existsSync(txtPath)) {
          return reject(new Error(`Whisper output not found at ${txtPath}`));
        }

        const transcript = readFileSync(txtPath, 'utf-8').trim();

        // Clean up the generated txt file
        try { unlinkSync(txtPath); } catch { /* ignore cleanup errors */ }

        resolvePromise(transcript);
      }
    });
  });
}
