// Voice + speech management
// STT: Lemonfox Whisper (if API key) → fallback to browser SpeechRecognition
// TTS: ElevenLabs (if API key) → fallback to browser SpeechSynthesis
// Both paths drive lip-sync amplitude via a callback.

const VoiceController = (() => {

  // ===== Browser STT (fallback) =====
  function createSTT() {
    const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
    if (!SR) return null;
    const rec = new SR();
    rec.continuous = false;
    rec.interimResults = true;
    rec.lang = 'en-US';
    return rec;
  }

  // ===== MediaRecorder for Whisper =====
  let mediaStream = null;
  let mediaRec = null;
  let chunks = [];
  let recStartTime = 0;

  async function startMicRecording() {
    try {
      if (!mediaStream) {
        mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
      }
      chunks = [];
      // Try preferred mime types
      const mimes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg'];
      let mimeType = '';
      for (const m of mimes) {
        if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) { mimeType = m; break; }
      }
      mediaRec = new MediaRecorder(mediaStream, mimeType ? { mimeType } : undefined);
      mediaRec.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.push(e.data); };
      recStartTime = performance.now();
      mediaRec.start();
      return true;
    } catch (e) {
      console.warn('Mic permission/init failed', e);
      return false;
    }
  }

  function stopMicRecording() {
    return new Promise((resolve) => {
      if (!mediaRec || mediaRec.state === 'inactive') { resolve(null); return; }
      mediaRec.onstop = () => {
        const blob = new Blob(chunks, { type: mediaRec.mimeType || 'audio/webm' });
        const dur = (performance.now() - recStartTime) / 1000;
        resolve({ blob, durationSec: dur });
      };
      try { mediaRec.stop(); } catch (e) { resolve(null); }
    });
  }

  // ===== Lemonfox Whisper STT =====
  async function transcribeLemonfox({ blob, apiKey }) {
    if (!apiKey || !blob) return null;
    const fd = new FormData();
    fd.append('file', blob, 'speech.webm');
    fd.append('language', 'english');
    fd.append('response_format', 'json');
    try {
      const r = await fetch('https://api.lemonfox.ai/v1/audio/transcriptions', {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${apiKey}` },
        body: fd,
      });
      if (!r.ok) {
        const errTxt = await r.text();
        console.warn('Lemonfox error', r.status, errTxt);
        return null;
      }
      const j = await r.json();
      return (j.text || '').trim();
    } catch (e) {
      console.warn('Lemonfox fetch failed', e);
      return null;
    }
  }

  // ===== ElevenLabs TTS =====
  // Voice IDs: pre-made public voices from ElevenLabs default catalog
  const EL_VOICES = {
    female: '21m00Tcm4TlvDq8ikWAM', // "Rachel"
    male:   'TxGEqnHWrfWFTfGW9XjX', // "Josh"
  };

  async function speakElevenLabs({ text, voiceKey, voiceIdOverride, apiKey, rate = 1, onAmplitude }) {
    const voiceId = (voiceIdOverride && voiceIdOverride.trim()) || EL_VOICES[voiceKey] || EL_VOICES.female;
    const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}?output_format=mp3_44100_128`, {
      method: 'POST',
      headers: {
        'xi-api-key': apiKey,
        'Content-Type': 'application/json',
        'Accept': 'audio/mpeg',
      },
      body: JSON.stringify({
        text,
        model_id: 'eleven_turbo_v2_5',
        voice_settings: { stability: 0.5, similarity_boost: 0.75, style: 0.2, use_speaker_boost: true },
      }),
    });
    if (!r.ok) {
      const errTxt = await r.text();
      throw new Error(`ElevenLabs ${r.status}: ${errTxt}`);
    }
    const arrayBuf = await r.arrayBuffer();
    return playAudioBuffer(arrayBuf, { rate, onAmplitude });
  }

  // Plays MP3/audio bytes through Web Audio + analyser for true amplitude lip-sync
  let audioCtx = null;
  let currentSource = null;
  function getAudioCtx() {
    if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    if (audioCtx.state === 'suspended') audioCtx.resume();
    return audioCtx;
  }

  function playAudioBuffer(arrayBuf, { rate = 1, onAmplitude }) {
    return new Promise(async (resolve) => {
      try {
        const ctx = getAudioCtx();
        const audio = await ctx.decodeAudioData(arrayBuf.slice(0));
        const src = ctx.createBufferSource();
        src.buffer = audio;
        src.playbackRate.value = rate;

        const analyser = ctx.createAnalyser();
        analyser.fftSize = 512;
        analyser.smoothingTimeConstant = 0.55;
        const dataArr = new Uint8Array(analyser.frequencyBinCount);

        src.connect(analyser);
        analyser.connect(ctx.destination);

        let active = true;
        function tick() {
          if (!active) return;
          analyser.getByteTimeDomainData(dataArr);
          // RMS deviation from 128 center
          let sum = 0;
          for (let i = 0; i < dataArr.length; i++) {
            const v = (dataArr[i] - 128) / 128;
            sum += v * v;
          }
          const rms = Math.sqrt(sum / dataArr.length);
          const amp = Math.min(1, rms * 4.5);
          onAmplitude?.(amp);
          requestAnimationFrame(tick);
        }
        src.onended = () => { active = false; onAmplitude?.(0); resolve(); };
        currentSource = src;
        src.start(0);
        tick();
      } catch (e) {
        console.warn('Audio playback failed', e);
        onAmplitude?.(0);
        resolve();
      }
    });
  }

  // ===== Browser TTS (fallback) =====
  function pickVoices() {
    const voices = window.speechSynthesis.getVoices();
    if (!voices.length) return { male: null, female: null };
    const male = voices.find(v => /male|david|daniel|alex|fred|google uk english male/i.test(v.name) && /en/i.test(v.lang)) ||
                 voices.find(v => /en/i.test(v.lang));
    const female = voices.find(v => /female|samantha|victoria|karen|tessa|google uk english female/i.test(v.name) && /en/i.test(v.lang)) ||
                   voices.find(v => /en/i.test(v.lang) && v !== male) || male;
    return { male, female };
  }

  function speakBrowser({ text, voiceKey = 'male', rate = 1, pitch = 1, onAmplitude }) {
    return new Promise((resolve) => {
      if (!('speechSynthesis' in window)) { onAmplitude?.(0); resolve(); return; }
      const utter = new SpeechSynthesisUtterance(text);
      const voices = pickVoices();
      utter.voice = voiceKey === 'male' ? voices.male : voices.female;
      utter.rate = rate;
      utter.pitch = pitch;
      utter.volume = 1;

      let active = true;
      let startTime = performance.now();
      let lastBoundary = 0;
      utter.onboundary = () => { lastBoundary = performance.now(); };
      utter.onstart = () => { startTime = performance.now(); };
      utter.onend = () => { active = false; onAmplitude?.(0); resolve(); };
      utter.onerror = () => { active = false; onAmplitude?.(0); resolve(); };

      function tick() {
        if (!active) return;
        const t = (performance.now() - startTime) / 1000;
        const sinceBoundary = (performance.now() - lastBoundary) / 1000;
        const syllable = Math.abs(Math.sin(t * 14)) * 0.55 + Math.abs(Math.sin(t * 8.7)) * 0.35;
        const boundary = Math.max(0, 1 - sinceBoundary * 4) * 0.4;
        const amp = Math.min(1, syllable * 0.6 + boundary + 0.15);
        onAmplitude?.(amp);
        requestAnimationFrame(tick);
      }
      requestAnimationFrame(tick);

      window.speechSynthesis.cancel();
      window.speechSynthesis.speak(utter);
    });
  }

  // unified speak: ElevenLabs if key, else browser
  async function speak({ text, voiceKey, rate, pitch, onAmplitude, elevenlabsKey, elevenlabsVoiceId }) {
    // Guard against empty/null text — would otherwise hit ElevenLabs 422
    // or browser TTS no-op silently.
    if (!text || typeof text !== 'string' || !text.trim()) {
      console.warn('VoiceController.speak called with empty text — skipping');
      return;
    }
    if (elevenlabsKey) {
      try {
        await speakElevenLabs({ text, voiceKey, voiceIdOverride: elevenlabsVoiceId, apiKey: elevenlabsKey, rate, onAmplitude });
        return;
      } catch (e) {
        console.warn('ElevenLabs failed, falling back to browser TTS', e);
      }
    }
    await speakBrowser({ text, voiceKey, rate, pitch, onAmplitude });
  }

  function cancel() {
    if ('speechSynthesis' in window) window.speechSynthesis.cancel();
    if (currentSource) { try { currentSource.stop(); } catch {} currentSource = null; }
  }

  return { createSTT, speak, cancel, pickVoices,
           startMicRecording, stopMicRecording, transcribeLemonfox };
})();

window.VoiceController = VoiceController;

// Pixabay client + procedural placeholder fallback (no Unsplash — no CORS issues)
const PixabayClient = (() => {
  let cache = {};

  // Procedural SVG placeholder (data URI). No network. Uses query for color seed.
  function placeholderFor(query) {
    let h = 0;
    for (const c of query) h = (h * 31 + c.charCodeAt(0)) & 0xffffffff;
    const hue1 = Math.abs(h) % 360;
    const hue2 = (hue1 + 60 + (Math.abs(h >> 8) % 90)) % 360;
    const safe = query.replace(/[<>&]/g, '').slice(0, 60);
    const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720">
      <defs>
        <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stop-color="hsl(${hue1},65%,32%)"/>
          <stop offset="1" stop-color="hsl(${hue2},65%,18%)"/>
        </linearGradient>
        <radialGradient id="r" cx="0.5" cy="0.4" r="0.7">
          <stop offset="0" stop-color="hsla(${hue2},80%,70%,0.5)"/>
          <stop offset="1" stop-color="hsla(${hue1},60%,30%,0)"/>
        </radialGradient>
      </defs>
      <rect width="1280" height="720" fill="url(#g)"/>
      <rect width="1280" height="720" fill="url(#r)"/>
      <text x="640" y="560" font-family="system-ui,sans-serif" font-size="42" fill="rgba(255,255,255,0.85)" text-anchor="middle" font-weight="600">${safe}</text>
      <text x="640" y="610" font-family="system-ui,sans-serif" font-size="22" fill="rgba(255,255,255,0.5)" text-anchor="middle" letter-spacing="3">METASPEAK · STAGE</text>
    </svg>`;
    return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
  }

  // ── Cloudinary search (primary if configured) ────────────────────────
  // Uses Cloudinary's client-side LIST API to fetch all assets sharing a
  // given tag. No API secret in browser, no CORS issues.
  //
  // CRITICAL setup the user must do in Cloudinary:
  //   1. Settings → Security → enable "Resource list" for delivery
  //   2. Each uploaded image must have "Access mode = Public" (NOT
  //      "Authenticated" — Cloudinary defaults to Authenticated which
  //      returns 401 from the list endpoint, even if Resource list is on)
  //   3. Tag images with topic keywords (langkawi, penang, etc.)
  //
  // To make uploads default to public: Settings → Upload → "Default upload
  // preset" → set Access mode to "public".
  let cloudinaryWarned401 = false;
  async function cloudinarySearch(query, cfg) {
    const { cloudName, preferredTags } = cfg || {};
    if (!cloudName) return null;

    // Build tag candidate list — try preferred tags first (from tree node
    // IDs which match the user's tagging taxonomy), then query keywords
    const trimmed = trimQueryForPixabay(query);
    const queryTags = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
    const allTags = [...(preferredTags || []), ...queryTags];
    if (!allTags.length) return null;

    let saw401 = false;
    const tried = new Set();
    for (const rawTag of allTags) {
      const safeTag = String(rawTag).toLowerCase().replace(/[^a-z0-9_]/g, '_');
      if (!safeTag || tried.has(safeTag)) continue;
      tried.add(safeTag);
      const url = `https://res.cloudinary.com/${cloudName}/image/list/${safeTag}.json`;
      try {
        const r = await fetch(url);
        if (r.status === 401) {
          saw401 = true;
          continue;
        }
        if (!r.ok) continue;
        const j = await r.json();
        if (!j.resources?.length) continue;
        const pick = j.resources[Math.floor(Math.random() * j.resources.length)];
        const deliveryUrl = `https://res.cloudinary.com/${cloudName}/image/upload/v${pick.version}/${pick.public_id}.${pick.format}`;
        console.log(`[Cloudinary] picked tag="${safeTag}" → ${pick.public_id}`);
        return {
          url: deliveryUrl,
          source: 'cloudinary',
          query: trimmed,
          tag: safeTag,
          public_id: pick.public_id,
        };
      } catch (e) { /* try next tag */ }
    }
    if (saw401 && !cloudinaryWarned401) {
      cloudinaryWarned401 = true;
      console.warn(
        '[Cloudinary] All list endpoints returned 401 — your uploaded assets ' +
        'are likely set to "Authenticated" access mode. To enable kiosk fetch:\n' +
        '  1. In Cloudinary Settings → Upload → set default Access mode = Public\n' +
        '  2. For existing assets: open each → Manage → Access Mode → Public\n' +
        '  3. OR upload new copies with Public access mode\n' +
        '\nFalling back to Pixabay/placeholder for now.'
      );
    }
    return null;
  }

  // ── Wikimedia Commons search (free, no key, browser-friendly) ─────
  // Uses the MediaWiki API to find images matching the query. Wikimedia
  // Commons has excellent coverage of Malaysian heritage, landmarks,
  // government buildings, food, and tourism — much better than Pixabay
  // for region-specific photography. Returns thumbnails up to 1280px.
  //
  // API endpoint: https://commons.wikimedia.org/w/api.php
  // CORS is enabled with `origin=*` parameter (Wikimedia explicitly
  // supports cross-origin browser calls).
  async function wikimediaSearch(query, opts = {}) {
    const { preferredTags } = opts;
    const trimmed = trimQueryForPixabay(query);

    // If we have preferred tags from the topic tree (e.g. "langkawi" from
    // node ID "beach_langkawi"), use the most specific one as the query. The
    // tree's destination word is usually a proper noun that Wikimedia indexes
    // perfectly. Drop generic category words.
    let baseQuery = trimmed;
    if (preferredTags?.length) {
      const GENERIC = new Set(['beach','city','nature','food','heritage','root']);
      const specific = preferredTags
        .filter(t => t && !GENERIC.has(t.toLowerCase()))
        .pop();   // last is usually the most specific (bare destination word)
      if (specific) {
        // Convert "nasi_lemak" → "nasi lemak", strip leading category if any
        let cleaned = specific.replace(/_/g, ' ').trim();
        const parts = cleaned.split(' ');
        if (parts.length > 1 && GENERIC.has(parts[0])) {
          cleaned = parts.slice(1).join(' ');
        }
        if (cleaned) baseQuery = cleaned;
      }
    }
    if (!baseQuery) return null;

    // Build a Malaysia-biased query. If the query already contains a known
    // Malaysian place name or "Malaysia", use as-is. Otherwise, append
    // "Malaysia" to dramatically improve relevance for this kiosk.
    const lower = baseQuery.toLowerCase();
    const hasMalaysiaContext = /\b(malaysia|kuala|langkawi|penang|sabah|sarawak|perak|johor|melaka|malacca|kelantan|terengganu|kedah|pahang|selangor|kinabalu|borneo|tioman|redang|perhentian|cameron|petronas|batu caves|jonker|nyonya|kampung|nasi|laksa|satay|cendol|rendang|mee|teh)\b/i.test(lower);
    const finalQuery = hasMalaysiaContext ? baseQuery : `${baseQuery} Malaysia`;

    // Step 1: search for files matching query (gets file titles)
    const searchUrl = `https://commons.wikimedia.org/w/api.php?` + [
      'action=query',
      'format=json',
      'origin=*',
      'generator=search',
      'gsrsearch=' + encodeURIComponent(`${finalQuery} filetype:bitmap`),
      'gsrlimit=15',
      'gsrnamespace=6',                 // namespace 6 = File:
      'prop=imageinfo',
      'iiprop=url|size|mime|extmetadata',
      'iiurlwidth=1280',                // request 1280px wide thumbnail
    ].join('&');

    try {
      const r = await fetch(searchUrl);
      if (!r.ok) return null;
      const j = await r.json();
      const pages = j?.query?.pages;
      if (!pages) return null;

      // Filter: must have imageinfo, not be SVG/icon, decent size
      const candidates = Object.values(pages).filter(p => {
        const info = p.imageinfo?.[0];
        if (!info) return false;
        const mime = info.mime || '';
        if (mime.includes('svg')) return false;        // skip vector icons
        if (mime.includes('tiff')) return false;       // skip uncompressed
        if ((info.width || 0) < 600) return false;     // skip tiny thumbs
        // Skip obvious junk: maps, diagrams, charts, flags, logos
        const title = (p.title || '').toLowerCase();
        if (/\b(map|chart|diagram|graph|logo|coat[- ]of[- ]arms|flag|seal|emblem|banner)\b/.test(title)) return false;
        return true;
      });

      if (!candidates.length) return null;

      // Sort by size (bigger = usually higher quality), then pick from top half
      candidates.sort((a, b) => (b.imageinfo[0].size || 0) - (a.imageinfo[0].size || 0));
      const top = candidates.slice(0, Math.max(3, Math.ceil(candidates.length / 2)));
      const pick = top[Math.floor(Math.random() * top.length)];
      const info = pick.imageinfo[0];
      // Use thumburl (1280px scaled) instead of full-res to save bandwidth
      const url = info.thumburl || info.url;

      console.log(`[Wikimedia] picked "${pick.title}" → ${url}`);
      return {
        url,
        source: 'wikimedia',
        query: finalQuery,
        title: pick.title,
        attribution: info.extmetadata?.Artist?.value || 'Wikimedia Commons',
      };
    } catch (e) {
      console.warn('Wikimedia search failed', e);
      return null;
    }
  }


  /**
   * Unified image search.
   * @param {string} query  - search term (e.g. "Langkawi", "nasi lemak")
   * @param {object} cfg    - { pixabayKey, cloudinaryCloudName, preferredTags,
   *                            imageUrl, useWikimedia (default true) }
   *                          or legacy: a string treated as pixabayKey
   * @returns {Promise<{url, source, query}>}
   */
  async function search(query, cfg) {
    // Backward compat: if cfg is a string, it's the old pixabayKey-only signature
    if (typeof cfg === 'string') cfg = { pixabayKey: cfg };
    cfg = cfg || {};

    // Highest priority: explicit image URL (from topic-tree node.image_url)
    if (cfg.imageUrl) {
      return { url: cfg.imageUrl, source: 'tree', query };
    }

    const cacheKey = `${query}|${cfg.cloudinaryCloudName||''}|${cfg.pixabayKey||'free'}`;
    if (cache[cacheKey]) return cache[cacheKey];

    // 1. Try Cloudinary first if configured (curated content)
    if (cfg.cloudinaryCloudName) {
      const cloudResult = await cloudinarySearch(query, {
        cloudName: cfg.cloudinaryCloudName,
        preferredTags: cfg.preferredTags,
      });
      if (cloudResult) {
        cache[cacheKey] = cloudResult;
        return cloudResult;
      }
      // Cloudinary returned no match — fall through to Wikimedia
    }

    // 2. Wikimedia Commons — strong for Malaysia-specific landmarks/heritage.
    // No key needed, no rate limits, CORS-friendly. Skip if user explicitly
    // disabled it (cfg.useWikimedia === false).
    if (cfg.useWikimedia !== false) {
      const wikiResult = await wikimediaSearch(query, { preferredTags: cfg.preferredTags });
      if (wikiResult) {
        cache[cacheKey] = wikiResult;
        return wikiResult;
      }
      // Wikimedia returned nothing — fall through to Pixabay
    }

    // 3. Pixabay fallback (or primary if Cloudinary + Wikimedia both miss)
    if (cfg.pixabayKey) {
      const trimmed = trimQueryForPixabay(query);
      const passes = [
        `${makePixabayUrl(trimmed, cfg.pixabayKey)}&editors_choice=true`,
        makePixabayUrl(trimmed, cfg.pixabayKey),
      ];
      for (const url of passes) {
        try {
          const r = await fetch(url);
          const j = await r.json();
          if (j.hits && j.hits.length) {
            const top = j.hits.slice(0, Math.max(3, Math.floor(j.hits.length / 2)));
            const pick = top[Math.floor(Math.random() * top.length)];
            const result = { url: pick.largeImageURL || pick.webformatURL, source: 'pixabay', query: trimmed, tags: pick.tags };
            cache[cacheKey] = result;
            return result;
          }
        } catch (e) { console.warn('Pixabay fetch failed', e); }
      }
      // Try with original query if trim missed something
      if (trimmed !== query) {
        try {
          const url = makePixabayUrl(query, cfg.pixabayKey);
          const r = await fetch(url);
          const j = await r.json();
          if (j.hits && j.hits.length) {
            const pick = j.hits[Math.floor(Math.random() * Math.min(5, j.hits.length))];
            const result = { url: pick.largeImageURL || pick.webformatURL, source: 'pixabay', query, tags: pick.tags };
            cache[cacheKey] = result;
            return result;
          }
        } catch (e) { /* silent */ }
      }
    }

    // 4. No providers worked — placeholder
    const result = { url: placeholderFor(query), source: 'placeholder', query };
    cache[cacheKey] = result;
    return result;
  }

  function makePixabayUrl(query, key) {
    return `https://pixabay.com/api/?key=${key}&q=${encodeURIComponent(query)}&image_type=photo&orientation=horizontal&safesearch=true&per_page=20`;
  }

  // Common stopwords / generic terms that hurt Pixabay relevance.
  // These are dropped first when trimming a query down to 3 keywords.
  const STOPWORDS = new Set([
    'a','an','the','of','in','at','on','to','for','with','and','or',
    'best','top','beautiful','nice','great','popular','famous','must',
    'visit','see','do','go','travel','world','adventure','beautiful',
    'amazing','awesome','interesting','nearby','around',
  ]);

  // Reduce a query to its 3 most specific keywords (by Pixabay relevance heuristic):
  //   1. Drop stopwords
  //   2. Prefer proper nouns (Capitalized words) — places, names
  //   3. Keep 3 most specific (typically the first 3 after stopword removal,
  //      since Claude is prompted to put the most specific term first)
  function trimQueryForPixabay(query) {
    if (!query) return query;
    const words = query.trim().split(/\s+/);
    if (words.length <= 3) return query;

    // Keep proper nouns first (capitalized, not at sentence start)
    const proper = [];
    const common = [];
    for (let i = 0; i < words.length; i++) {
      const w = words[i];
      if (STOPWORDS.has(w.toLowerCase())) continue;
      // Heuristic: capitalized word is a proper noun
      if (i > 0 && /^[A-Z]/.test(w)) {
        proper.push(w);
      } else {
        common.push(w);
      }
    }
    const ordered = [...proper, ...common];
    return ordered.slice(0, 3).join(' ') || query;
  }
  return { search };
})();

window.PixabayClient = PixabayClient;
