// ── TTS preview ───────────────────────────────────────────────────────────

function backendVoiceId(value) {
  return typeof value === 'string' ? value : (value?.id || value?.voice || value?.name || JSON.stringify(value));
}

function shouldFilterBackendVoices(backend) {
  return ['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend || '');
}

async function activeLibraryVoiceIds() {
  if (!_voices.length) await loadVoiceLibrary();
  return new Set((_voices || []).filter(v => v.enabled !== false).map(v => v.id));
}

function cleanReferenceText(text) {
  return String(text || '').trim();
}

function selectedPreviewLibraryVoice() {
  const id = $('tts-voice-select')?.value || '';
  return id ? (_voices || []).find(v => v.id === id) : null;
}

function previewVoiceWarnings(v) {
  const warnings = [];
  const backend = backendById($('tts-backend-select')?.value || '');
  if (backend && backend.id && !['voice_clone', 'streaming', 'nvidia_zeroshot', 'nvidia_flow'].includes(backend.id)) {
    warnings.push(backend.id === 'nvidia_magpie' ? 'NVIDIA Magpie uses fixed speaker voices, not saved WAV clone identity.' : 'This backend may follow style/model voice more than the saved WAV identity.');
  }
  if (backend && backend.id === 'nvidia_zeroshot' && v.duration && (Number(v.duration) < 3 || Number(v.duration) > 10)) {
    warnings.push('NVIDIA Zeroshot works best with a clear 3-10 second prompt.');
  }
  if (backend && backend.id === 'nvidia_flow' && !v.transcript) {
    warnings.push('NVIDIA Flow requires the exact saved reference transcript for this voice.');
  }
  if (!v.transcript) warnings.push('No reference transcript is saved; cloned identity is harder to judge.');
  if (v.duration && (Number(v.duration) < 3 || Number(v.duration) > 20)) warnings.push('Reference clip length is outside the 3-20 second sweet spot.');
  if (v.needs_tts_restart) warnings.push('This voice changed since the last backend refresh; restart or clear restart flags before judging it.');
  const healthWarnings = v.health && Array.isArray(v.health.warnings) ? v.health.warnings : [];
  warnings.push(...healthWarnings.slice(0, 3));
  return warnings;
}

function updatePreviewVoiceMatchPanel() {
  const panel = $('preview-match-panel');
  if (!panel) return;
  const v = selectedPreviewLibraryVoice();
  if (!v) { panel.hidden = true; return; }
  panel.hidden = false;
  const lang = v.language || v.lang || (v.id || '').split('_')[0] || '-';
  const gender = v.gender || (v.id || '').split('_')[1] || '-';
  const db = fmtDbfs(v);
  const dur = v.duration ? fmtDuration(v.duration) : '-';
  $('preview-match-title').textContent = v.id;
  $('preview-match-detail').textContent = `${lang} · ${gender} · ${dur} · ${db} dBFS`;
  const warnings = previewVoiceWarnings(v);
  $('preview-match-warning').textContent = warnings.length ? warnings.join(' ') : 'For a fair voice match check, play the WAV and synthesize the exact saved reference text.';
  const transcript = cleanReferenceText(v.transcript || '');
  $('preview-match-transcript').textContent = transcript || 'No reference text saved for this voice.';
  $('preview-ref-use-text').disabled = !transcript;
  $('preview-ref-synth').disabled = !transcript;
  const audio = $('preview-ref-audio');
  const expected = voiceFileUrl(v);
  if (audio.dataset.src !== expected) {
    audio.pause();
    audio.src = expected;
    audio.dataset.src = expected;
  }

  // Persona rewrite button — shown only when voice has a persona
  const actionsEl = panel.querySelector('.preview-match-actions');
  let personaBtn = panel.querySelector('.preview-persona-btn');
  if (v.persona) {
    if (!personaBtn) {
      personaBtn = document.createElement('button');
      personaBtn.className = 'btn-secondary preview-persona-btn';
      personaBtn.type = 'button';
      personaBtn.textContent = 'Rewrite with persona';
      actionsEl?.appendChild(personaBtn);
      personaBtn.addEventListener('click', async () => {
        const text = $('preview-text-area').value.trim();
        if (!text) { toast('Enter text to rewrite', 'error'); return; }
        const lv = selectedPreviewLibraryVoice();
        if (!lv?.persona) { toast('This voice has no persona', 'error'); return; }
        personaBtn.disabled = true;
        personaBtn.textContent = 'Rewriting…';
        try {
          const llmUrl = localStorage.getItem('refine-llm-url') || _appSettings?.llm_url || 'http://localhost:11434/v1';
          const r = await fetch('/api/rewrite-with-persona', { method:'POST', headers:{'Content-Type':'application/json'},
            body: JSON.stringify({ text, persona: lv.persona, llm_url: llmUrl, model: _appSettings?.llm_model || '', mode:'rewrite' }) });
          if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
          const d = await r.json();
          $('preview-text-area').value = d.text;
          toast('Text rewritten in persona style', 'success');
        } catch(e) { toast('Persona rewrite failed: ' + e.message, 'error'); }
        finally { personaBtn.disabled = false; personaBtn.textContent = 'Rewrite with persona'; }
      });
    }
  } else {
    personaBtn?.remove();
  }
}

async function synthesizeSelectedReferenceText() {
  const v = selectedPreviewLibraryVoice();
  if (!v) { toast('Select a library voice first', 'error'); return; }
  let text = cleanReferenceText(v.transcript || '');
  if (!text) { toast('This voice has no reference text', 'error'); return; }
  if (v.needs_tts_restart) {
    const ok = confirm('This voice is marked as needing a TTS restart. If you already restarted the backend, clear the flag and synthesize anyway?');
    if (!ok) return;
    await clearTtsRestartFlags();
    v.needs_tts_restart = false;
    updatePreviewVoiceMatchPanel();
  }
  const backend = $('tts-backend-select').value;
  if (!backend) { toast('No available TTS backend', 'error'); return; }
  const btn = $('preview-ref-synth');
  btn.disabled = true;
  try {
    $('preview-text-area').value = text;
    const source = await createTtsAudioSource(v.id, text, backend, $('preview-playback-mode').value, $('preview-style-instruction').value.trim());
    previewBlob = source.blob;
    const audio = $('preview-audio');
    audio.src = source.url;
    audio.style.display = '';
    await audio.play();
    $('save-preview-mp3-btn').disabled = false;
    $('save-preview-btn').disabled = source.streaming;
    toast(source.streaming ? 'Reference text streaming' : 'Reference text synthesized', 'success');
  } catch(e) { toast('Reference synthesis failed: ' + e.message, 'error'); }
  finally { btn.disabled = false; }
}

$('fetch-tts-voices-btn').addEventListener('click', async () => {
  $('fetch-tts-voices-btn').disabled = true;
  try {
    const backend = $('tts-backend-select')?.value;
    if (!backend) throw new Error('No available TTS backend');
    const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
    let voices = Array.isArray(rawVoices) ? rawVoices : [];
    if (shouldFilterBackendVoices(backend)) {
      const activeIds = await activeLibraryVoiceIds();
      voices = voices.filter(v => activeIds.has(backendVoiceId(v)));
    }
    const sel = $('tts-voice-select'), prev = sel.value;
    const ids = voices.map(backendVoiceId);
    if (window.VoicePicker) {
      VoicePicker.upgrade('tts-voice-select');
      VoicePicker.populate('tts-voice-select', ids);
      if (prev && ids.includes(prev)) VoicePicker.setValue('tts-voice-select', prev);
    } else {
      sel.innerHTML = '<option value="">— select after fetch —</option>';
      ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
      if (prev && ids.includes(prev)) sel.value = prev;
    }
    updatePreviewVoiceMatchPanel();
    const suffix = shouldFilterBackendVoices(backend) ? ' active voices' : ' voices';
    toast('Fetched '+voices.length+suffix,'success');
  } catch(e) { toast('Fetch failed: '+e.message,'error'); }
  finally { $('fetch-tts-voices-btn').disabled = false; }
});

$('tts-backend-select').addEventListener('change', () => {
  const sel = $('tts-voice-select');
  sel.innerHTML = '<option value="">— select after fetch —</option>';
  updateBackendHelp();
  updatePreviewVoiceMatchPanel();
  previewBlob = null;
  $('save-preview-mp3-btn').disabled = true;
  $('save-preview-btn').disabled = true;
});

$('tts-voice-select').addEventListener('change', updatePreviewVoiceMatchPanel);
$('preview-ref-play').addEventListener('click', async () => {
  updatePreviewVoiceMatchPanel();
  const audio = $('preview-ref-audio');
  try { await audio.play(); }
  catch(e) { toast('Reference playback failed: ' + e.message, 'error'); }
});
$('preview-ref-use-text').addEventListener('click', () => {
  const v = selectedPreviewLibraryVoice();
  const text = cleanReferenceText(v?.transcript || '');
  if (!text) { toast('This voice has no reference text', 'error'); return; }
  $('preview-text-area').value = text;
  toast('Reference text copied to target text', 'success');
});
$('preview-ref-synth').addEventListener('click', synthesizeSelectedReferenceText);

let _ttsStreamHealth = null;
function effectiveTtsPlaybackMode(override = 'settings') {
  if (override && override !== 'settings') return override;
  return _appSettings.tts_stream_mode || 'auto';
}
async function isTtsStreamAvailable(force = false) {
  if (_ttsStreamHealth && !force) return _ttsStreamHealth.ok;
  try {
    _ttsStreamHealth = await fetch('/api/tts-stream-health').then(r => r.json());
    return !!_ttsStreamHealth.ok;
  } catch (_) {
    _ttsStreamHealth = {ok:false};
    return false;
  }
}
async function createTtsStreamUrl(voice, text, instruct = '') {
  if (!await isTtsStreamAvailable()) throw new Error('streaming backend unavailable');
  const r = await fetch('/api/tts-stream-session', {method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({text,voice,instruct})});
  if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
  const data = await r.json();
  return data.url;
}
async function fetchTtsPreviewBlob(voice, text, responseFormat = 'wav', instruct = '', backend = 'voice_clone', applyPersona = false, extra = null) {
  const body = {text, voice, response_format: responseFormat, instruct, backend};
  if (applyPersona) body.apply_persona = true;
  if (extra && typeof extra === 'object') Object.assign(body, extra);   // e.g. {seed, temperature}
  const r = await fetch('/api/tts-preview', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
  if (!r.ok) { const e=await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
  return await r.blob();
}
async function createTtsAudioSource(voice, text, backend = 'voice_clone', modeOverride = 'settings', instruct = '', applyPersona = false, extra = null) {
  const mode = effectiveTtsPlaybackMode(modeOverride);
  if (backend !== 'streaming' || mode === 'buffered') {
    const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra);
    return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
  }
  try {
    return {url: await createTtsStreamUrl(voice, text, instruct), blob:null, streaming:true, label:'streaming'};
  } catch (e) {
    if (mode === 'streaming') throw e;
    const blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend, applyPersona, extra);
    return {url: URL.createObjectURL(blob), blob, streaming:false, label:'buffered'};
  }
}
let previewBlob = null;
const PREVIEW_SAMPLE_TEXT = 'Hello! This is a voice preview from TTS Voice Creator - Clone and Design.';
$('preview-text-area').addEventListener('focus', () => {
  if ($('preview-text-area').value === PREVIEW_SAMPLE_TEXT) $('preview-text-area').value = '';
}, { once:true });

function _onPreviewGenerated(source, voice, text, backend) {
  if (typeof effectsSourceBlob !== 'undefined') window._effectsSourceBlob = null;
  const ea = $('effects-apply-btn'); if (ea && source.blob) ea.disabled = false;
  const ap = $('add-to-playlist-btn'); if (ap && source.blob) ap.disabled = false;
  if (typeof historyPush === 'function' && source.blob) historyPush(voice, text, backend, source.blob, source.url);
}

const _TRYOUT_SPEED_KEY = 'ttsvc_tryout_native_speed';
(function _restoreTryoutSpeed() {
  const saved = localStorage.getItem(_TRYOUT_SPEED_KEY);
  if (saved) { const el = $('preview-native-speed'); if (el) el.value = saved; }
})();
$('preview-native-speed')?.addEventListener('change', function () {
  localStorage.setItem(_TRYOUT_SPEED_KEY, this.value);
});

$('preview-btn').addEventListener('click', async () => {
  const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
  const applyPersona = $('preview-persona-toggle')?.checked || false;
  if(!backend) { toast('No available TTS backend','error'); return; }
  if(!voice) { toast('Select a TTS voice','error'); return; }
  if(!text)  { toast('Enter preview text','error'); return; }
  $('preview-btn').disabled=true; $('save-preview-mp3-btn').disabled=true; $('save-preview-btn').disabled=true;
  if($('add-to-playlist-btn')) $('add-to-playlist-btn').disabled=true;
  if($('effects-apply-btn')) $('effects-apply-btn').disabled=true;
  const _nspd = parseFloat($('preview-native-speed')?.value);
  const _extra = (!isNaN(_nspd) && _nspd !== 1) ? {speed: _nspd} : null;
  try {
    const audio = $('preview-audio');
    const useChunked = $('preview-chunked-toggle')?.checked && text.length > 200 && typeof generateChunkedTts === 'function';
    const source = useChunked
      ? await generateChunkedTts(voice, text, backend, instruct, _extra)
      : await createTtsAudioSource(voice, text, backend, $('preview-playback-mode').value, instruct, applyPersona, _extra);
    previewBlob = source.blob;
    window._previewVoice = voice; window._previewBackend = backend; window._previewText = text;
    audio.src = source.url;
    audio.style.display='';
    await audio.play();
    $('save-preview-mp3-btn').disabled = false;
    $('save-preview-btn').disabled = source.streaming;
    _onPreviewGenerated(source, voice, text, backend);
    toast(source.streaming ? 'Streaming preview playing' : source.label === 'chunked' ? `Chunked (${text.length} chars) playing` : 'Preview playing', 'success');
  } catch(e) { toast('TTS failed: '+e.message,'error'); }
  finally { $('preview-btn').disabled=false; }
});
$('save-preview-mp3-btn').addEventListener('click', async () => {
  const voice=$('tts-voice-select').value, backend=$('tts-backend-select').value, text=$('preview-text-area').value.trim(), instruct=$('preview-style-instruction').value.trim();
  if(!backend) { toast('No available TTS backend','error'); return; }
  if(!voice || !text) return;
  const btn = $('save-preview-mp3-btn');
  btn.disabled = true;
  try {
    const blob = await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, backend);
    const a = document.createElement('a');
    a.href = URL.createObjectURL(blob);
    a.download = (voice||'preview')+'_preview.mp3'; a.click();
    toast('MP3 saved', 'success');
  } catch(e) { toast('MP3 save failed: '+e.message,'error'); }
  finally { btn.disabled = false; }
});
$('save-preview-btn').addEventListener('click', () => {
  if(!previewBlob) return;
  const a = document.createElement('a');
  a.href = URL.createObjectURL(previewBlob);
  a.download = ($('tts-voice-select').value||'preview')+'_preview.wav'; a.click();
});


// ── Performance benchmark ─────────────────────────────────────────────────

const PERF_HISTORY_KEY = 'vcf-perf-history';
const PERF_HISTORY_MAX = 50;
const PERF_HISTORY_SORT = { key: 'ts', dir: 'desc' };

function perfHistoryLoad() {
  try { return JSON.parse(localStorage.getItem(PERF_HISTORY_KEY) || '[]'); } catch(_) { return []; }
}
function perfHistorySave(entries) {
  try { localStorage.setItem(PERF_HISTORY_KEY, JSON.stringify(entries.slice(-PERF_HISTORY_MAX))); } catch(_) {}
}
function perfHistoryAdd(entry) {
  const h = perfHistoryLoad();
  h.push(entry);
  perfHistorySave(h);
}

function perfSparklineSvg(rtfValues) {
  if (!rtfValues.length) return '';
  const W = 120, H = 32, PAD = 2, barW = Math.max(4, Math.floor((W - PAD * 2) / rtfValues.length) - 1);
  const maxV = Math.max(...rtfValues, 1);
  const bars = rtfValues.map((v, i) => {
    const bh = Math.max(3, Math.round((v / maxV) * (H - PAD * 2)));
    const x  = PAD + i * (barW + 1);
    const y  = H - PAD - bh;
    const col = v < 1 ? 'var(--green)' : 'var(--yellow)';
    return `<rect x="${x}" y="${y}" width="${barW}" height="${bh}" rx="1" fill="${col}" opacity=".8"/>`;
  }).join('');
  return `<svg viewBox="0 0 ${W} ${H}" class="perf-sparkline" aria-hidden="true">${bars}</svg>`;
}

function perfHistoryVoiceLookup(voiceId) {
  const list = Array.isArray(window._voices) && window._voices.length
    ? window._voices
    : (typeof _voices !== 'undefined' && Array.isArray(_voices) ? _voices : []);
  return list.find(v => v && (v.id === voiceId || v.name === voiceId || v.voice_id === voiceId)) || null;
}

function perfHistoryVoiceMeta(entry) {
  const voice = entry?.voice || '';
  const saved = entry?.voiceMeta || {};
  const lib = perfHistoryVoiceLookup(voice) || {};
  const language = saved.lang || saved.language || entry?.lang || entry?.language || lib.lang || lib.language || '';
  const gender = String(saved.gender || entry?.gender || lib.gender || '').trim().toUpperCase().charAt(0);
  return {
    id: voice,
    label: saved.label || saved.display_name || entry?.voiceLabel || lib.display_name || lib.name || voice,
    lang: language,
    gender: ['F', 'M', 'N'].includes(gender) ? gender : '',
    flag: saved.flag || entry?.flag || lib.flag || '',
    avatar: saved.avatar || entry?.avatar || lib.avatar || '',
    hasPicture: Boolean(saved.has_picture ?? saved.hasPicture ?? entry?.hasPicture ?? lib.has_picture),
  };
}

function perfHistoryExtraFields(voice, backend) {
  const lib = perfHistoryVoiceLookup(voice) || {};
  return {
    voiceMeta: {
      label: lib.display_name || lib.name || voice,
      lang: lib.lang || lib.language || '',
      gender: lib.gender || '',
      flag: lib.flag || '',
      avatar: lib.avatar || '',
      has_picture: !!lib.has_picture,
    },
    device: typeof backendComputeDevice === 'function' ? backendComputeDevice(backend) : '',
  };
}

function perfHistoryGenderLabel(gender) {
  return ({F:'Female', M:'Male', N:'Diverse'}[gender] || '');
}

function perfHistoryDeviceLabel(entry) {
  return entry?.device || (typeof backendComputeDevice === 'function' ? backendComputeDevice(entry?.backend || '') : '') || 'Unknown';
}

function perfHistoryDeviceHtml(entry) {
  const label = perfHistoryDeviceLabel(entry);
  const cls = typeof backendComputeDeviceClass === 'function'
    ? backendComputeDeviceClass(entry?.backend || '')
    : (label.toLowerCase().includes('gpu') ? 'gpu' : label.toLowerCase().includes('cpu') ? 'cpu' : '');
  return `<span class="bench-device ${escHtml(cls)}">${escHtml(label)}</span>`;
}

function perfHistoryAvatarHtml(entry) {
  const meta = perfHistoryVoiceMeta(entry);
  const title = meta.label || meta.id || 'Voice';
  if (meta.hasPicture) {
    return `<span class="perf-history-avatar" title="${escHtml(title)}"><img src="/api/voice/picture/${encodeURIComponent(meta.id)}" alt=""></span>`;
  }
  const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(meta.avatar, 24) : null;
  if (icon) return `<span class="perf-history-avatar" title="${escHtml(title)}">${icon.replace(/vp-avatar/g, 'perf-history-avatar-icon')}</span>`;
  const color = typeof avatarColor === 'function' ? avatarColor(meta.lang || meta.id || title) : '#6b7280';
  const init = (title || '?').trim()[0]?.toUpperCase() || '?';
  return `<span class="perf-history-avatar perf-history-avatar-init" style="background:${color}" title="${escHtml(title)}">${escHtml(init)}</span>`;
}


function perfHistorySortValue(entry, key) {
  switch (key) {
    case 'backend': return String(entry.backend || '').toLowerCase();
    case 'language': return String(perfHistoryVoiceMeta(entry).lang || '').toLowerCase();
    case 'gender': return String(perfHistoryGenderLabel(perfHistoryVoiceMeta(entry).gender) || '').toLowerCase();
    case 'voice': return String(entry.voice || '').toLowerCase();
    case 'device': return String(perfHistoryDeviceLabel(entry) || '').toLowerCase();
    case 'avgLatencyMs': return Number(entry.avgLatencyMs);
    case 'minLatencyMs': return Number(entry.minLatencyMs);
    case 'avgRtf': return Number(entry.avgRtf);
    case 'ts':
    default: return Number(entry.ts);
  }
}

function perfHistoryCompare(a, b) {
  const av = perfHistorySortValue(a, PERF_HISTORY_SORT.key);
  const bv = perfHistorySortValue(b, PERF_HISTORY_SORT.key);
  let result = 0;
  if (typeof av === 'string' || typeof bv === 'string') {
    result = String(av).localeCompare(String(bv), undefined, { numeric: true, sensitivity: 'base' });
  } else {
    const an = Number.isFinite(av) ? av : -Infinity;
    const bn = Number.isFinite(bv) ? bv : -Infinity;
    result = an === bn ? 0 : an - bn;
  }
  return PERF_HISTORY_SORT.dir === 'asc' ? result : -result;
}

function perfHistoryHeadButton(key, label) {
  const active = PERF_HISTORY_SORT.key === key;
  const icon = active
    ? (PERF_HISTORY_SORT.dir === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down')
    : 'mdi-swap-vertical';
  return `<button class="perf-history-sort${active ? ' active' : ''}" type="button" data-history-sort="${escHtml(key)}" aria-label="Sort by ${escHtml(label)}">
    <span>${escHtml(label)}</span><span class="mdi ${icon}" aria-hidden="true"></span>
  </button>`;
}

function renderPerfHistory() {
  const histList = $('perf-history-list');
  if (!histList) return;
  const filterEl  = $('perf-history-filter-current');
  const filterOn  = filterEl?.checked;
  const curBack   = $('perf-backend-select')?.value;
  const curVoice  = $('perf-voice-select')?.value;
  let entries = perfHistoryLoad().slice();
  if (filterOn && curBack) entries = entries.filter(e => e.backend === curBack && e.voice === curVoice);
  if (!entries.length) {
    histList.innerHTML = '<div class="perf-history-empty">' + (filterOn ? 'No history for this backend/voice yet.' : 'No benchmark history yet. Run a benchmark above to start tracking.') + '</div>';
    return;
  }
  entries.sort((a, b) => perfHistoryCompare(a, b) || (Number(b.ts) - Number(a.ts)));
  const head = `<div class="perf-history-row perf-history-head">
    <span>${perfHistoryHeadButton('ts', 'Date / Time')}</span>
    <span>${perfHistoryHeadButton('backend', 'Backend')}</span>
    <span>Profile</span>
    <span>${perfHistoryHeadButton('language', 'Language')}</span>
    <span>${perfHistoryHeadButton('gender', 'Gender')}</span>
    <span>${perfHistoryHeadButton('voice', 'Voice')}</span>
    <span>${perfHistoryHeadButton('device', 'CPU / GPU')}</span>
    <span>${perfHistoryHeadButton('avgLatencyMs', 'Avg latency')}</span>
    <span>${perfHistoryHeadButton('minLatencyMs', 'Min')}</span>
    <span>${perfHistoryHeadButton('avgRtf', 'Avg RTF')}</span>
    <span></span>
  </div>`;
  const rows = entries.map(e => {
    const dt     = new Date(e.ts).toLocaleString([], {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
    const rtfCls = e.avgRtf < 1 ? 'perf-good' : 'perf-slow';
    const meta   = perfHistoryVoiceMeta(e);
    return `<div class="perf-history-row">
      <span class="perf-history-ts">${escHtml(dt)}</span>
      <span>${escHtml(e.backend)}</span>
      <span>${perfHistoryAvatarHtml(e)}</span>
      <span>${escHtml(meta.lang || '-')}</span>
      <span>${escHtml(perfHistoryGenderLabel(meta.gender) || '-')}</span>
      <span>${escHtml(e.voice)}</span>
      <span>${perfHistoryDeviceHtml(e)}</span>
      <span>${Math.round(e.avgLatencyMs)} ms</span>
      <span>${Math.round(e.minLatencyMs)} ms</span>
      <span class="${rtfCls}">${e.avgRtf.toFixed(2)}</span>
      <span class="perf-history-del" data-ts="${e.ts}" title="Remove"><span class="mdi mdi-close"></span></span>
    </div>`;
  }).join('');
  histList.innerHTML = head + rows;
  histList.querySelectorAll('.perf-history-sort').forEach(btn => {
    btn.addEventListener('click', () => {
      const key = btn.dataset.historySort || 'ts';
      if (PERF_HISTORY_SORT.key === key) {
        PERF_HISTORY_SORT.dir = PERF_HISTORY_SORT.dir === 'asc' ? 'desc' : 'asc';
      } else {
        PERF_HISTORY_SORT.key = key;
        PERF_HISTORY_SORT.dir = key === 'backend' || key === 'voice' ? 'asc' : 'desc';
      }
      renderPerfHistory();
    });
  });
  histList.querySelectorAll('.perf-history-del').forEach(btn => {
    btn.addEventListener('click', () => {
      const ts = Number(btn.dataset.ts);
      perfHistorySave(perfHistoryLoad().filter(e => e.ts !== ts));
      renderPerfHistory();
    });
  });
}

(function initPerfBenchmark() {
  const perfBackendSel  = $('perf-backend-select');
  const perfVoiceSel    = $('perf-voice-select');
  const perfFetchBtn    = $('perf-fetch-voices-btn');
  const perfRunBtn      = $('perf-run-btn');
  const perfClearBtn    = $('perf-clear-btn');
  const perfProgress    = $('perf-progress');
  const perfResultsCard = $('perf-results-card');
  const perfSummary     = $('perf-summary');
  const perfTbody       = $('perf-tbody');
  const perfText        = $('perf-text');
  const perfRunsSel     = $('perf-runs');
  if (!perfRunBtn) return;

  let perfRows = [];

  function populatePerfBackends() {
    if (!perfBackendSel) return;
    const cur = perfBackendSel.value;
    perfBackendSel.innerHTML = availableTtsBackends().map(b =>
      `<option value="${escHtml(b.id)}"${b.id===cur?' selected':''}>${escHtml(b.label)}</option>`
    ).join('') || '<option value="">No backends available</option>';
  }
  populatePerfBackends();
  if (window.BenchmarkVoicePicker) BenchmarkVoicePicker.upgrade('perf-voice-select', { placeholder: '-- select after fetch --', empty: 'No voices' });

  perfFetchBtn.addEventListener('click', async () => {
    const backend = perfBackendSel.value;
    if (!backend) { toast('Select a backend first', 'error'); return; }
    perfFetchBtn.disabled = true;
    try {
      const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
      const cur = perfVoiceSel.value;
      const items = rawVoices.map(v => {
        const id = backendVoiceId(v);
        return { id, label: id, meta: (window._voices || []).find(x => x && x.id === id) || (typeof v === 'object' ? v : null) };
      }).filter(v => v.id);
      if (window.BenchmarkVoicePicker) {
        BenchmarkVoicePicker.populate('perf-voice-select', items, { placeholder: '-- select after fetch --', empty: 'No voices' });
        if (cur && items.some(v => v.id === cur)) BenchmarkVoicePicker.set('perf-voice-select', cur);
      } else {
        perfVoiceSel.innerHTML = items.map(v => `<option value="${escHtml(v.id)}"${v.id===cur?' selected':''}>${escHtml(v.id)}</option>`).join('') || '<option value="">No voices</option>';
      }
      window.dispatchEvent(new CustomEvent('benchmark:tts-voices-fetched', { detail: { backend, voices: items } }));
    } catch(e) { toast('Fetch voices failed: '+e.message, 'error'); }
    finally { perfFetchBtn.disabled = false; }
  });

  function updateTrendDisplay(backend, voice, currentAvgRtf) {
    const trendRow  = $('perf-trend-row');
    const trendBadge = $('perf-trend-badge');
    const sparkWrap  = $('perf-sparkline-wrap') || trendRow?.querySelector('.perf-sparkline-wrap');
    if (!trendRow) return;
    const history = perfHistoryLoad().filter(e => e.backend === backend && e.voice === voice && typeof e.avgRtf === 'number');
    if (history.length === 0) { trendRow.style.display = 'none'; return; }
    const prevRtf = history[history.length - 1].avgRtf;
    const delta   = currentAvgRtf - prevRtf;
    const pct     = Math.abs(delta / Math.max(prevRtf, 0.01)) * 100;
    let cls, label;
    if (pct < 5)         { cls = 'perf-trend-stable'; label = '<span class="mdi mdi-minus"></span> Stable'; }
    else if (delta < 0)  { cls = 'perf-trend-better'; label = `<span class="mdi mdi-arrow-down"></span> ${pct.toFixed(0)}% faster`; }
    else                 { cls = 'perf-trend-worse';  label = `<span class="mdi mdi-arrow-up"></span> ${pct.toFixed(0)}% slower`; }
    trendBadge.className = 'perf-trend-badge ' + cls;
    trendBadge.innerHTML = label;
    const rtfValues = [...history.slice(-9).map(e => e.avgRtf), currentAvgRtf];
    if (sparkWrap) sparkWrap.innerHTML = perfSparklineSvg(rtfValues);
    trendRow.style.display = '';
  }

  function renderPerfTable(sessionDone = false) {
    if (!perfRows.length) { perfResultsCard.style.display='none'; return; }
    perfResultsCard.style.display = '';
    perfTbody.innerHTML = perfRows.map((r, i) => {
      const rtf = r.audioDuration > 0 ? (r.latencyMs / 1000 / r.audioDuration).toFixed(2) : '—';
      const ok  = r.ok;
      return `<tr class="${ok?'':'perf-row-error'}">
        <td>${i+1}</td>
        <td>${escHtml(r.backend)}</td>
        <td><span class="bench-device ${typeof backendComputeDeviceClass === 'function' ? backendComputeDeviceClass(r.backend) : ''}" title="Inferred from the selected TTS backend metadata">${escHtml(typeof backendComputeDevice === 'function' ? backendComputeDevice(r.backend) : 'Unknown')}</span></td>
        <td>${escHtml(r.voice)}</td>
        <td>${ok ? r.latencyMs : '—'}</td>
        <td>${ok && r.audioDuration > 0 ? r.audioDuration.toFixed(2) : '—'}</td>
        <td>${ok ? rtf : '—'}</td>
        <td>${ok ? '<span class="perf-ok">OK</span>' : `<span class="perf-err">${escHtml(r.error||'Error')}</span>`}</td>
      </tr>`;
    }).join('');
    const ok = perfRows.filter(r => r.ok);
    if (ok.length) {
      const avg    = ok.reduce((s,r) => s + r.latencyMs, 0) / ok.length;
      const minL   = Math.min(...ok.map(r => r.latencyMs));
      const maxL   = Math.max(...ok.map(r => r.latencyMs));
      const rtfArr = ok.filter(r=>r.audioDuration>0).map(r=>r.latencyMs/1000/r.audioDuration);
      const avgRtf = rtfArr.length ? rtfArr.reduce((s,v)=>s+v,0)/rtfArr.length : 0;
      const labelEl = $('perf-results-label');
      if (labelEl) labelEl.textContent = `${ok.length} run${ok.length>1?'s':''} — ${perfBackendSel.value} / ${perfVoiceSel.value}`;
      perfSummary.innerHTML = `
        <span class="perf-stat"><strong>${Math.round(avg)} ms</strong> avg latency</span>
        <span class="perf-stat"><strong>${minL} ms</strong> best</span>
        <span class="perf-stat"><strong>${maxL} ms</strong> worst</span>
        <span class="perf-stat"><strong>${avgRtf.toFixed(2)}</strong> avg RTF</span>
        <span class="perf-stat ${avgRtf < 1 ? 'perf-good' : 'perf-slow'}">${avgRtf < 1 ? '<span class="mdi mdi-check-circle-outline"></span> Real-time capable' : '<span class="mdi mdi-alert-outline"></span> Slower than real-time'}</span>
      `;
      if (sessionDone && rtfArr.length) {
        updateTrendDisplay(perfBackendSel.value, perfVoiceSel.value, avgRtf);
        perfHistoryAdd({
          ts: Date.now(),
          backend: perfBackendSel.value,
          voice: perfVoiceSel.value,
          ...perfHistoryExtraFields(perfVoiceSel.value, perfBackendSel.value),
          textLen: perfText.value.trim().length,
          avgLatencyMs: avg,
          minLatencyMs: minL,
          maxLatencyMs: maxL,
          avgRtf,
          runCount: ok.length,
          allOk: ok.length === perfRows.length,
        });
        renderPerfHistory();
      }
    } else { perfSummary.innerHTML = '<span class="perf-err">All runs failed</span>'; }
  }

  perfClearBtn.addEventListener('click', () => {
    perfRows = [];
    renderPerfTable();
    if ($('perf-trend-row')) $('perf-trend-row').style.display = 'none';
    perfProgress.style.display = 'none';
  });

  perfRunBtn.addEventListener('click', async () => {
    const backend = perfBackendSel.value;
    const voice   = perfVoiceSel.value;
    const text    = perfText.value.trim();
    const runs    = parseInt(perfRunsSel.value) || 3;
    if (!backend) { toast('Select a backend first', 'error'); return; }
    if (!voice)   { toast('Fetch and select a voice first', 'error'); return; }
    if (!text)    { toast('Enter sample text', 'error'); return; }
    perfRows = [];
    perfRunBtn.disabled = true;
    perfProgress.style.display = '';
    for (let i = 0; i < runs; i++) {
      perfProgress.textContent = `Run ${i+1} / ${runs}…`;
      const row = { backend, voice, ok: false, latencyMs: 0, audioDuration: 0, error: '' };
      try {
        const t0 = performance.now();
        const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
        row.latencyMs = Math.round(performance.now() - t0);
        row.ok = true;
        try {
          const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
          const buf = await audioCtx.decodeAudioData(await blob.arrayBuffer());
          row.audioDuration = buf.duration;
          audioCtx.close();
        } catch(_) {}
      } catch(e) { row.error = e.message; }
      perfRows.push(row);
      renderPerfTable(false);
    }
    perfProgress.textContent = `Done — ${runs} run${runs>1?'s':''} completed.`;
    renderPerfTable(true);
    perfRunBtn.disabled = false;
  });

  // History filter toggle
  $('perf-history-filter-current')?.addEventListener('change', renderPerfHistory);
  $('perf-history-clear-btn')?.addEventListener('click', () => {
    perfHistorySave([]);
    renderPerfHistory();
    toast('Benchmark history cleared', 'success');
  });

  renderPerfHistory();
})();

