// ── Utility ───────────────────────────────────────────────────────────────

const $ = id => document.getElementById(id);

// ── Voice avatar colors ───────────────────────────────────────────────────
const _AVATAR_COLORS = ['#E57373','#F06292','#BA68C8','#9575CD','#7986CB',
  '#64B5F6','#4DD0E1','#4DB6AC','#81C784','#FFB74D','#FF8A65','#A1887F'];
function avatarColor(id) {
  let h = 0;
  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xFFFFFF;
  return _AVATAR_COLORS[Math.abs(h) % _AVATAR_COLORS.length];
}

let _toastTimer;
function toast(msg, type = '', ms = 6500) {
  const el = $('toast');
  if (!el) return;
  el.textContent = msg;
  el.setAttribute('role', type === 'error' ? 'alert' : 'status');
  el.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite');
  el.className = 'show ' + type;
  clearTimeout(_toastTimer);
  _toastTimer = setTimeout(() => el.className = '', ms);
}
function status(msg) { $('status-bar').textContent = msg; }
function escHtml(s) {
  return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
// Log a non-fatal error instead of silently swallowing it (was `.catch(()=>{})`).
// Use in best-effort paths so failures are visible in the console for debugging.
function logErr(context, err) { try { console.warn(`[${context}]`, err && (err.message || err)); } catch (_) {} }
// Run an async worker over items with bounded concurrency (default 4). `onProgress(done,total)`
// fires after each item. Replaces slow serial `for…await` loops over network calls.
async function runPool(items, worker, concurrency = 4, onProgress) {
  const arr = [...items]; let i = 0, done = 0; const total = arr.length;
  async function run() {
    while (i < arr.length) {
      const idx = i++;
      try { await worker(arr[idx], idx); } catch (e) { logErr('runPool', e); }
      done++; if (onProgress) { try { onProgress(done, total); } catch (_) {} }
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, arr.length) }, run));
}
async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
  } catch(e) {
    const ta = document.createElement('textarea');
    ta.value = text;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    ta.remove();
  }
}
function fmtDuration(sec) {
  if (sec == null || Number.isNaN(Number(sec))) return '-';
  const total = Math.max(0, Math.round(Number(sec)));
  const m = Math.floor(total / 60), s = total % 60;
  return `${m}:${String(s).padStart(2,'0')}`;
}

function loadingMarkup(title, detail = '', rows = 4) {
  const skeleton = Array.from({length: rows}, () => '<div class="skeleton-row"></div>').join('');
  return `
    <div class="loading-panel" role="status" aria-live="polite">
      <div class="loading-box">
        <div class="loading-head"><span class="spinner" aria-hidden="true"></span><span>${escHtml(title)}</span></div>
        ${detail ? `<div class="loading-sub">${escHtml(detail)}</div>` : ''}
        <div class="skeleton-stack">${skeleton}</div>
      </div>
    </div>`;
}

function setBusyButton(id, busy) {
  const el = $(id);
  if (el) el.disabled = !!busy;
}
async function clientAutoTrimBounds(fid) {
  const resp = await fetch('/api/audio/' + encodeURIComponent(fid));
  if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
  const audioData = await resp.arrayBuffer();
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
  const buffer = await ctx.decodeAudioData(audioData.slice(0));
  try { ctx.close(); } catch (_) {}   // iOS Safari hard-limits live AudioContexts — free it now
  const samples = buffer.getChannelData(0);
  const sr = buffer.sampleRate;
  const dur = buffer.duration;
  if (dur <= 20) return {start:0, end:dur, duration:dur, reason:'Audio is already short enough.'};

  const chunkSec = 0.25, chunkSize = Math.max(1, Math.floor(sr * chunkSec));
  const chunks = [];
  for (let i = 0; i < samples.length; i += chunkSize) {
    let sum = 0, peak = 0, n = Math.min(chunkSize, samples.length - i);
    for (let j = 0; j < n; j++) {
      const v = samples[i + j];
      sum += v * v;
      peak = Math.max(peak, Math.abs(v));
    }
    const rms = Math.sqrt(sum / Math.max(1, n));
    const db = rms > 0 ? 20 * Math.log10(rms) : -80;
    chunks.push({db, speech:false, clipped:peak > 0.96});
  }
  const avgDb = chunks.reduce((a,c)=>a+c.db,0) / chunks.length;
  const floor = Math.max(avgDb - 18, -45);
  chunks.forEach(c => c.speech = c.db >= floor);

  function scoreWindow(startSec, lengthSec) {
    const first = Math.floor(startSec / chunkSec);
    const last = Math.min(chunks.length, Math.ceil((startSec + lengthSec) / chunkSec));
    const win = chunks.slice(first, last);
    if (!win.length) return {score:-9999};
    const speech = win.filter(c => c.speech);
    const speechRatio = speech.length / win.length;
    const silenceRatio = 1 - speechRatio;
    const clipRatio = win.filter(c => c.clipped).length / win.length;
    const speechAvg = speech.length ? speech.reduce((a,c)=>a+c.db,0) / speech.length : -80;
    const variance = speech.length ? speech.reduce((a,c)=>a+Math.pow(c.db-speechAvg,2),0) / speech.length : 100;
    const score = speechRatio * 100 - silenceRatio * 55 - clipRatio * 85 - Math.abs(speechAvg + 20) * 1.7 - Math.min(18, Math.sqrt(variance) * 1.4) - Math.abs(lengthSec - 12) * 0.9;
    return {score, speechRatio, silenceRatio, speechAvg};
  }

  let best = null;
  [8,10,12,15,18].forEach(length => {
    if (length > dur) return;
    for (let start = 0; start <= dur - length; start += 0.5) {
      const s = scoreWindow(start, length);
      if (!best || s.score > best.score) best = {start, end:start + length, duration:length, ...s};
    }
  });
  if (!best) return {start:0, end:Math.min(12,dur), duration:Math.min(12,dur), reason:'Using the beginning because no stable speech window was found.'};
  return {
    start:Number(best.start.toFixed(2)),
    end:Number(best.end.toFixed(2)),
    duration:Number(best.duration.toFixed(2)),
    reason:`Selected ${best.duration.toFixed(1)}s with ${Math.round(best.speechRatio*100)}% speech, ${Math.round(best.silenceRatio*100)}% silence, avg ${best.speechAvg.toFixed(1)} dBFS.`
  };
}


// ── Accessibility helpers ────────────────────────────────────────────────
function _a11yText(el) {
  return String(el?.textContent || '').replace(/\s+/g, ' ').trim();
}

function _a11yLabel(el) {
  return el?.getAttribute('aria-label') || el?.getAttribute('title') || _a11yText(el) || '';
}

function enhanceAccessibility(root = document) {
  const scope = root && root.querySelectorAll ? root : document;
  const statusBar = $('status-bar');
  if (statusBar) {
    statusBar.setAttribute('role', 'status');
    statusBar.setAttribute('aria-live', 'polite');
    statusBar.setAttribute('aria-atomic', 'true');
  }
  const toastEl = $('toast');
  if (toastEl) {
    toastEl.setAttribute('aria-live', 'polite');
    toastEl.setAttribute('aria-atomic', 'true');
  }

  scope.querySelectorAll('.nav-item, .nav-tree-item, .tab, [onclick]').forEach(el => {
    if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/i.test(el.tagName)) return;
    if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
    if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
    const label = _a11yLabel(el);
    if (label && !el.getAttribute('aria-label')) el.setAttribute('aria-label', label);
    if (label && !el.getAttribute('title')) el.setAttribute('title', label);
  });

  scope.querySelectorAll('button, [role=button], a, input, select, textarea').forEach(el => {
    const label = _a11yLabel(el);
    if (label && !el.getAttribute('title') && el.matches('button, [role=button]')) el.setAttribute('title', label);
    if (label && !el.getAttribute('aria-label') && !_a11yText(el) && el.matches('button, [role=button]')) el.setAttribute('aria-label', label);
  });

  scope.querySelectorAll('button .mdi, [role=button] .mdi, .nav-icon .mdi').forEach(icon => {
    icon.setAttribute('aria-hidden', 'true');
  });

  document.querySelectorAll('.nav-item, .nav-tree-item').forEach(el => {
    const active = el.classList.contains('active') || el.classList.contains('is-active');
    if (active && el.getAttribute('aria-current') !== 'page') el.setAttribute('aria-current', 'page');
    else if (!active && el.hasAttribute('aria-current')) el.removeAttribute('aria-current');
  });
}

function initAccessibilityEnhancements() {
  if (window.__ttsvcA11yReady) { enhanceAccessibility(document); return; }
  window.__ttsvcA11yReady = true;
  enhanceAccessibility(document);
  document.addEventListener('keydown', (e) => {
    if (e.key !== 'Enter' && e.key !== ' ') return;
    const el = e.target?.closest?.('[role=button]');
    if (!el || /^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/i.test(el.tagName)) return;
    e.preventDefault();
    el.click();
  });
  const obs = new MutationObserver((mutations) => {
    for (const m of mutations) {
      if (m.type === 'childList') {
        m.addedNodes.forEach(n => { if (n.nodeType === 1) enhanceAccessibility(n); });
      } else if (m.type === 'attributes') {
        enhanceAccessibility(document);
      }
    }
  });
  obs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
}

// ── Light / dark theme ────────────────────────────────────────────────────

function applyTheme(t) {
  document.documentElement.dataset.theme = t;
  const btn = $('theme-btn');
  if (btn) { btn.innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>'; btn.title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; }
  const sel = $('s-theme-select');
  if (sel) sel.value = t;
  localStorage.setItem('vcf-theme', t);
}
$('theme-btn')?.addEventListener('click', () =>
  applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
);
applyTheme(localStorage.getItem('vcf-theme') || 'dark');

// ── Language helpers ──────────────────────────────────────────────────────

function cc2flag(cc) {
  if (!cc || cc.length !== 2) return '<span class="mdi mdi-earth"></span>';
  return cc.toUpperCase().replace(/./g, c => String.fromCodePoint(c.charCodeAt(0) + 127397));
}

// Display label for a country code (3-letter where conventional)
const CC_DISPLAY = {
  AU:'AUS', NZ:'NZL', GB:'GB', US:'US', CA:'CA', IE:'IRE', ZA:'ZAF', IN:'IND', SG:'SGP', PH:'PHL', NG:'NGA', KE:'KEN', GH:'GHA', JM:'JAM', TT:'TTO', MT:'MLT',
  CH:'CH', AT:'AT', BE:'BE', CN:'CN', TW:'TWN', HK:'HKG', MO:'MAC', JP:'JP', KR:'KR', VN:'VNM', TH:'THA', ID:'IDN', MY:'MYS', PK:'PAK', BD:'BGD', LK:'LKA', NP:'NPL', IR:'IRN', IL:'ISR',
  MX:'MEX', AR:'ARG', CO:'COL', CL:'CHL', PE:'PER', VE:'VEN', UY:'URY', EC:'ECU', BO:'BOL', CR:'CRI', CU:'CUB', DO:'DOM',
  BR:'BRA', SA:'SAU', EG:'EGY', AE:'UAE', MA:'MAR', QA:'QAT', KW:'KWT', OM:'OMN', JO:'JOR', LB:'LBN', IQ:'IRQ',
  DK:'DNK', NO:'NOR', SE:'SE', FI:'FIN', IS:'ISL', NL:'NL', LU:'LUX', LI:'LIE', FR:'FR', DE:'DE', ES:'ES', PT:'PT', IT:'IT', GR:'GRC', CY:'CYP', TR:'TR', PL:'PL', CZ:'CZE', SK:'SVK', HU:'HUN', RO:'ROU', BG:'BGR', HR:'HRV', SI:'SVN', RS:'SRB', BA:'BIH', ME:'MNE', MK:'MKD', AL:'ALB', EE:'EST', LV:'LVA', LT:'LTU', UA:'UKR', RU:'RU', BY:'BLR', MD:'MDA'
};
function ccDisplay(cc) { return CC_DISPLAY[cc] || cc; }

const EUROPE_FLAGS = [
  ['GB','GB - British'], ['IE','IRE - Irish'], ['DE','DE - German'], ['AT','AT - Austrian'], ['CH','CH - Swiss'], ['FR','FR - French'], ['BE','BE - Belgian'], ['NL','NL - Dutch'], ['LU','LUX - Luxembourgish'], ['LI','LIE - Liechtenstein'],
  ['ES','ES - Spanish'], ['PT','PT - Portuguese'], ['IT','IT - Italian'], ['MT','MLT - Maltese'], ['GR','GRC - Greek'], ['CY','CYP - Cypriot'],
  ['DK','DNK - Danish'], ['NO','NOR - Norwegian'], ['SE','SE - Swedish'], ['FI','FIN - Finnish'], ['IS','ISL - Icelandic'],
  ['PL','PL - Polish'], ['CZ','CZE - Czech'], ['SK','SVK - Slovak'], ['HU','HUN - Hungarian'], ['RO','ROU - Romanian'], ['BG','BGR - Bulgarian'], ['HR','HRV - Croatian'], ['SI','SVN - Slovenian'], ['RS','SRB - Serbian'], ['BA','BIH - Bosnian'], ['ME','MNE - Montenegrin'], ['MK','MKD - Macedonian'], ['AL','ALB - Albanian'],
  ['EE','EST - Estonian'], ['LV','LVA - Latvian'], ['LT','LTU - Lithuanian'], ['UA','UKR - Ukrainian'], ['RU','RU - Russian'], ['BY','BLR - Belarusian'], ['MD','MDA - Moldovan'], ['TR','TR - Turkish'],
];
const ASIA_FLAGS = [
  ['CN','CN - Mainland Chinese'], ['TW','TWN - Taiwanese'], ['HK','HKG - Hong Kong'], ['MO','MAC - Macau'], ['SG','SGP - Singapore'], ['JP','JP - Japanese'], ['KR','KR - Korean'],
  ['VN','VNM - Vietnamese'], ['TH','THA - Thai'], ['ID','IDN - Indonesian'], ['MY','MYS - Malaysian'], ['PH','PHL - Filipino'], ['IN','IND - Indian'], ['PK','PAK - Pakistani'], ['BD','BGD - Bangladeshi'], ['LK','LKA - Sri Lankan'], ['NP','NPL - Nepali'],
  ['SA','SAU - Saudi'], ['AE','UAE - Emirati'], ['QA','QAT - Qatari'], ['KW','KWT - Kuwaiti'], ['OM','OMN - Omani'], ['JO','JOR - Jordanian'], ['LB','LBN - Lebanese'], ['IQ','IRQ - Iraqi'], ['IR','IRN - Iranian'], ['IL','ISR - Israeli'],
];
const ENGLISH_FLAGS = [
  ['GB','GB - British'], ['US','US - American'], ['CA','CA - Canadian'], ['AU','AUS - Australian'], ['NZ','NZL - New Zealand'], ['IE','IRE - Irish'], ['ZA','ZAF - South African'], ['IN','IND - Indian English'], ['SG','SGP - Singapore English'], ['PH','PHL - Filipino English'], ['NG','NGA - Nigerian English'], ['KE','KEN - Kenyan English'], ['GH','GHA - Ghanaian English'], ['JM','JAM - Jamaican English'], ['TT','TTO - Trinidad and Tobago English'], ['MT','MLT - Maltese English'],
];
const LATAM_FLAGS = [
  ['MX','MEX - Mexican'], ['AR','ARG - Argentine'], ['CO','COL - Colombian'], ['CL','CHL - Chilean'], ['PE','PER - Peruvian'], ['VE','VEN - Venezuelan'], ['UY','URY - Uruguayan'], ['EC','ECU - Ecuadorian'], ['BO','BOL - Bolivian'], ['CR','CRI - Costa Rican'], ['CU','CUB - Cuban'], ['DO','DOM - Dominican'], ['BR','BRA - Brazilian'],
];
function uniqueFlagOptions(groups) {
  const seen = new Set(), out = [];
  groups.flat().forEach(item => { if (item && !seen.has(item[0])) { seen.add(item[0]); out.push(item); } });
  return out;
}

const FLAG_OPTIONS = {
  EN: ENGLISH_FLAGS,
  DE: uniqueFlagOptions([[['DE','DE - German'],['AT','AT - Austrian'],['CH','CH - Swiss (DE)']], EUROPE_FLAGS]),
  FR: uniqueFlagOptions([[['FR','FR - French'],['BE','BE - Belgian'],['CH','CH - Swiss (FR)'],['CA','CA - Canadian']], EUROPE_FLAGS]),
  ZH: uniqueFlagOptions([[['CN','CN - Mainland'],['TW','TWN - Taiwanese'],['HK','HKG - Hong Kong'],['SG','SGP - Singaporean']], ASIA_FLAGS]),
  ES: uniqueFlagOptions([[['ES','ES - Spain']], LATAM_FLAGS, EUROPE_FLAGS]),
  PT: uniqueFlagOptions([[['PT','PT - Portuguese'],['BR','BRA - Brazilian']], EUROPE_FLAGS, LATAM_FLAGS]),
  AR: uniqueFlagOptions([[['SA','SAU - Saudi'],['EG','EGY - Egyptian'],['AE','UAE - Emirati'],['MA','MAR - Moroccan']], ASIA_FLAGS]),
  NL: uniqueFlagOptions([[['NL','NL - Dutch'],['BE','BE - Belgian']], EUROPE_FLAGS]),
  JA: uniqueFlagOptions([[['JP','JP - Japanese']], ASIA_FLAGS]),
  KO: uniqueFlagOptions([[['KR','KR - Korean']], ASIA_FLAGS]),
  IT: uniqueFlagOptions([[['IT','IT - Italian']], EUROPE_FLAGS]),
  RU: uniqueFlagOptions([[['RU','RU - Russian']], EUROPE_FLAGS, ASIA_FLAGS]),
  PL: uniqueFlagOptions([[['PL','PL - Polish']], EUROPE_FLAGS]),
  SV: uniqueFlagOptions([[['SE','SE - Swedish']], EUROPE_FLAGS]),
  TR: uniqueFlagOptions([[['TR','TR - Turkish']], EUROPE_FLAGS, ASIA_FLAGS]),
  HI: uniqueFlagOptions([[['IN','IND - Indian']], ASIA_FLAGS]),
};

const LANG_FLAG_DEFAULT = {
  EN:'GB', DE:'DE', ZH:'CN', FR:'FR', ES:'ES', JA:'JP', KO:'KR',
  IT:'IT', PT:'BR', RU:'RU', AR:'SA', PL:'PL', NL:'NL', SV:'SE', TR:'TR', HI:'IN',
};

const ALL_FLAGS = uniqueFlagOptions([ENGLISH_FLAGS, EUROPE_FLAGS, ASIA_FLAGS, LATAM_FLAGS, [
  ['EG','EGY - Egyptian'], ['MA','MAR - Moroccan'], ['ZA','ZAF - South African'], ['NG','NGA - Nigerian'], ['KE','KEN - Kenyan'], ['GH','GHA - Ghanaian'],
]]);

// ── Preview sample texts per language ────────────────────────────────────
const VL_SAMPLE_TEXTS = {
  EN: 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.',
  DE: 'Hallo, wie geht es Ihnen heute? Bitte lesen Sie diesen Text deutlich vor — für einen fairen Stimmvergleich.',
  FR: 'Bonjour, comment allez-vous aujourd\'hui ? Veuillez lire ce texte clairement pour un test vocal équitable.',
  ES: '¡Hola! ¿Cómo estás hoy? Por favor, lee este texto con claridad para una evaluación justa de la voz.',
  PT: 'Olá, como vai você hoje? Por favor, leia este texto claramente para uma avaliação justa da voz.',
  IT: 'Ciao, come stai oggi? Per favore leggi questo testo in modo chiaro per una valutazione equa della voce.',
  NL: 'Hallo, hoe gaat het vandaag? Lees dit voorbeeld alstublieft duidelijk voor een eerlijke stembeoordeling.',
  PL: 'Cześć, jak się dziś masz? Przeczytaj proszę ten tekst wyraźnie, aby dokonać rzetelnej oceny głosu.',
  SV: 'Hej, hur mår du idag? Vänligen läs detta exempel tydligt för en rättvis röstbedömning.',
  DA: 'Hej, hvordan har du det i dag? Læs venligst dette eksempel tydeligt for en retfærdig stemmevurdering.',
  NB: 'Hei, hvordan har du det i dag? Vennligst les dette eksempelet tydelig for en rettferdig stemmevurdering.',
  FI: 'Hei, kuinka voit tänään? Lue tämä esimerkki selkeästi reilua ääniarviointia varten.',
  HU: 'Szia, hogy vagy ma? Kérlek, olvasd fel ezt a szöveget érthetően az igazságos hangértékelés érdekében.',
  CS: 'Dobrý den, jak se máte? Přečtěte prosím tento text zřetelně pro spravedlivé hodnocení hlasu.',
  RO: 'Bună ziua, cum vă simțiți astăzi? Vă rugăm citiți acest text clar pentru o evaluare corectă a vocii.',
  UK: 'Привіт, як ви сьогодні? Будь ласка, прочитайте цей текст чітко для справедливого тестування голосу.',
  RU: 'Здравствуйте, как вы сегодня? Пожалуйста, прочитайте этот текст чётко для справедливого тестирования голоса.',
  TR: 'Merhaba, bugün nasılsınız? Adil bir ses değerlendirmesi için lütfen bu örneği açıkça okuyun.',
  AR: 'مرحباً، كيف حالك اليوم؟ يرجى قراءة هذا النص بوضوح لإجراء اختبار صوت عادل.',
  HI: 'नमस्ते, आप आज कैसे हैं? कृपया इस नमूने को स्पष्ट रूप से पढ़ें ताकि एक उचित आवाज़ मूल्यांकन हो सके।',
  ZH: '你好，你今天怎么样？请清晰地朗读这段示例，以便进行公正的语音评测。',
  JA: 'こんにちは、今日はいかがですか？公平な音声評価のために、このサンプルをはっきりと読んでください。',
  KO: '안녕하세요, 오늘 어떠세요? 공정한 음성 벤치마크를 위해 이 샘플을 명확하게 읽어주세요.',
};

const _VL_LANG_FLAG = Object.assign({ DA:'DK', NB:'NO', FI:'FI', HU:'HU', CS:'CZ', RO:'RO', UK:'UA' }, LANG_FLAG_DEFAULT);

function setPreviewLang(lang) {
  const ta = $('vl-preview-sample');
  const hidden = $('benchmark-sample-text');
  if (!ta) return;
  const text = VL_SAMPLE_TEXTS[lang] || VL_SAMPLE_TEXTS.EN;
  ta.value = text;
  ta.dispatchEvent(new Event('input'));
  if (hidden) { hidden.value = text; hidden.dispatchEvent(new Event('input')); }
  try { localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); } catch {}
  const btn = $('vl-preview-lang-btn');
  if (btn) {
    const cc = (_VL_LANG_FLAG[lang] || 'gb').toLowerCase();
    btn.innerHTML = `<span class="fi fi-${cc}"></span>`;
    btn.title = (LANGUAGE_LABELS[lang] || lang) + ' — click to change';
    btn.dataset.lang = lang;
  }
}

function openPreviewLangPicker(anchor) {
  const items = Object.keys(VL_SAMPLE_TEXTS).map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
  createSearchablePicker(anchor, items, setPreviewLang, {
    placeholder: 'Sample language…',
    renderItem: (l) => {
      const cc = (_VL_LANG_FLAG[l] || 'gb').toLowerCase();
      return `<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span>`
           + `<span class="ipi-code">${escHtml(l)}</span>`
           + `<span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`;
    },
  });
}

// ── Searchable picker dropdown ────────────────────────────────────────────
function createSearchablePicker(anchor, items, onSelect, { placeholder = 'Search…', renderItem } = {}) {
  document.querySelector('.insp-picker')?.remove();
  const panel = document.createElement('div');
  panel.className = 'insp-picker';

  const search = document.createElement('input');
  search.type = 'search'; search.className = 'insp-picker-search';
  search.placeholder = placeholder; search.autocomplete = 'off';

  const list = document.createElement('div');
  list.className = 'insp-picker-list';

  const renderList = (q) => {
    const f = q.trim().toLowerCase();
    const filtered = f
      ? items.filter(([code, name]) => name.toLowerCase().includes(f) || code.toLowerCase().includes(f))
      : items;
    list.innerHTML = '';
    if (!filtered.length) { list.innerHTML = '<div class="insp-picker-empty">No matches</div>'; return; }
    filtered.forEach(([code, name]) => {
      const btn = document.createElement('button');
      btn.className = 'insp-picker-item'; btn.type = 'button';
      btn.innerHTML = renderItem ? renderItem(code, name) : `<span class="ipi-code">${escHtml(code)}</span><span>${escHtml(name)}</span>`;
      btn.addEventListener('mousedown', (e) => { e.preventDefault(); onSelect(code); panel.remove(); cleanup(); });
      list.appendChild(btn);
    });
  };
  search.addEventListener('input', () => renderList(search.value));
  renderList('');

  panel.appendChild(search); panel.appendChild(list);
  document.body.appendChild(panel);

  const rect = anchor.getBoundingClientRect();
  const pw = 244;
  let left = rect.left;
  if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
  panel.style.top  = (rect.bottom + 4) + 'px';
  panel.style.left = Math.max(8, left) + 'px';

  const cleanup = () => { document.removeEventListener('mousedown', onOut); document.removeEventListener('keydown', onKey); };
  const onOut = (e) => { if (!panel.contains(e.target)) { panel.remove(); cleanup(); } };
  const onKey = (e) => { if (e.key === 'Escape') { panel.remove(); cleanup(); } };
  setTimeout(() => { document.addEventListener('mousedown', onOut); document.addEventListener('keydown', onKey); }, 0);
  search.focus();
}

// ── Tag reuse (localStorage) ──────────────────────────────────────────────
const _TAG_KEY = 'ttsvc-voice-tags';
function getStoredTags() {
  try { return JSON.parse(localStorage.getItem(_TAG_KEY) || '[]'); } catch { return []; }
}
function addStoredTag(tag) {
  const t = tag.trim(); if (!t) return;
  const tags = getStoredTags().filter(x => x !== t);
  tags.unshift(t);
  try { localStorage.setItem(_TAG_KEY, JSON.stringify(tags.slice(0, 60))); } catch {}
}

// ── Tabs ──────────────────────────────────────────────────────────────────

function disabledBackendTabMessage(tab) {
  const required = tab?.dataset.backendRequired || '';
  if (required === 'any_tts') return 'No Qwen3-TTS backend is running or configured. Check Settings.';
  const backend = (_ttsBackends || []).find(b => b.id === required);
  const label = backend?.label || required.replace(/_/g, ' ');
  const url = backend?.url ? ` (${backend.url})` : '';
  return `${label}${url} is not running or not configured in Settings.`;
}

function switchTab(name) {
  const targetTab = document.querySelector(`.tab[data-tab="${name}"]`);
  if (targetTab?.classList.contains('backend-unavailable')) {
    toast(disabledBackendTabMessage(targetTab), 'error');
    return false;
  }
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
  document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
  if (name === 'library') loadVoiceLibrary();
  if (name === 'integrations') {
    if (!_voices.length) loadVoiceLibrary();
    renderIntegrationSnippets();
  }
  if (name === 'routing') loadRoutingTab();
  if (name === 'getvoices') loadGetVoices();
  return true;
}
// ── Language detection ────────────────────────────────────────────────────────
// Lightweight, dependency-free detector. First by script (CJK / Cyrillic /
// Hangul), then by stop-word frequency for Latin-script languages. Returns a
// full English language name ("German", "English", …) matching the design-language
// <select>, or '' when there's too little signal.
function detectLang(text) {
  const t = String(text || '').slice(0, 4000);
  if (!t.trim()) return '';
  if (/[가-힣]/.test(t)) return 'Korean';
  if (/[぀-ヿ]/.test(t)) return 'Japanese';
  if (/[一-鿿]/.test(t)) return 'Chinese';
  if (/[Ѐ-ӿ]/.test(t)) return 'Russian';
  const STOP = {
    English:    ['the','and','of','to','a','in','that','is','was','he','for','it','with','as','his','on','be','at','by','she','you','not'],
    German:     ['der','die','und','ich','das','nicht','ein','ist','sie','mit','dem','den','war','auf','für','des','eine','auch','als','er','wir','aber','noch'],
    French:     ['le','la','les','et','de','un','une','des','est','que','pour','dans','qui','pas','plus','vous','avec','sur','son','ne','je','il'],
    Spanish:    ['el','la','los','las','de','que','y','un','una','en','es','por','con','no','se','su','para','lo','como','más','pero','sus'],
    Italian:    ['il','la','di','e','che','un','una','è','per','non','con','sono','del','le','si','lo','ma','come','più','anche','della','gli'],
    Portuguese: ['de','que','e','o','a','do','da','em','um','uma','não','os','para','com','por','como','mas','se','dos','das','ao','seu'],
    Dutch:      ['de','het','een','en','van','ik','te','dat','die','in','is','niet','met','zijn','op','aan','voor','er','maar','om','ook','als'],
  };
  const words = (t.toLowerCase().match(/[a-zà-ÿ]+/g) || []);
  if (words.length < 8) return '';
  const counts = {}, sets = {};
  Object.keys(STOP).forEach(l => { counts[l] = 0; sets[l] = new Set(STOP[l]); });
  for (const w of words) for (const l in sets) if (sets[l].has(w)) counts[l]++;
  let best = '', bestN = 0;
  for (const l in counts) if (counts[l] > bestN) { bestN = counts[l]; best = l; }
  return bestN >= 3 ? best : 'English';
}
window.detectLang = detectLang;

document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab)));
document.addEventListener('click', e => {
  const btn = e.target.closest('.backend-jump');
  if (!btn) return;
  switchTab('generation');
  const backend = btn.dataset.backend;
  const sel = $('tts-backend-select');
  if (sel && [...sel.options].some(o => o.value === backend)) {
    sel.value = backend;
    sel.dispatchEvent(new Event('change'));
  }
});


