// ---------- Speech Quality (WER) — shared card for every custom dashboard ----------
// Drop-in for Astro, Angkasa and any future custom analytics view:
//
//   const sq = useSpeechQuality();
//   <SpeechQualityCard logs={scopedLogs} sq={sq} t={t} lang={lang} Info={AstroInfo}
//     drill={drill} onDrill={toggleDrill} />
//   …and in the view's drill filter:
//   if (drill?.type === "sq") return speechQualityMatches(l, drill, sq);
//
// Scores come from src/services/speechQuality.js, which works out each
// assistant's key terms from its own script, so the card is tailored per
// company with no setup. Only calls in `logs` (the view's own scope) count.

Object.assign(window.STRINGS.en, {
  "sq.title": "Speech accuracy (WER)",
  "sq.subtitle": "{{scored}} of {{total}} recorded calls scored · tailored to each assistant's script",
  "sq.info": "Word Error Rate — the industry-standard accuracy measure for voice AI: wrong + missed + extra words ÷ words actually said. Lower is better. On phone lines ≤10% is excellent, ≤20% good, ≤30% fair, above 30% poor. Each assistant's company, product and customer names are taken from its own script, so brand names count as key terms rather than errors.",
  "sq.agent": "AI speech WER",
  "sq.agentInfo": "What the caller actually heard: the AI's side of the recording is transcribed and compared with what the AI meant to say. High values mean mispronounced names or numbers, clipped audio or cut-off sentences. Click to see calls above 20%.",
  "sq.agentMeta": "{{words}} words checked",
  "sq.cutMeta": "{{pct}} of the AI's speech was cut off — click",
  "sq.cutInfo": "Sentences the caller never heard to the end because the call ended, the caller spoke over the AI, or audio dropped. Counted separately so it doesn't look like bad pronunciation.",
  "sq.drill.cutOff": "AI speech cut off",
  "sq.caller": "Caller ASR WER",
  "sq.callerInfo": "How well the AI understood the caller: what it heard compared with a reference transcript of what the caller really said. References are typed by an admin (✎ below), or estimated by a second speech engine when enabled. Click to see the scored calls.",
  "sq.callerMeta": "Calls with a reference: {{n}}",
  "sq.callerNone": "Needs reference transcripts",
  "sq.terms": "Key-term accuracy",
  "sq.termsInfo": "Share of the script's key terms (company, product and customer names) that came through correctly in the AI's speech. Click to see calls where a key term was misheard.",
  "sq.termsMeta": "{{hits}} of {{total}} key terms",
  "sq.byAssistant": "AI speech WER by assistant",
  "sq.byAssistantInfo": "AI speech WER pooled per assistant (voice + script). Click a bar to see that assistant's scored calls.",
  "sq.topErrors": "Most frequent mishearings",
  "sq.topErrorsInfo": "Words that most often came out differently from what was meant (intended → heard), across AI speech and caller recognition. Click one to see the calls it happened in.",
  "sq.worst": "Worst calls",
  "sq.worstInfo": "Scored calls with the highest AI speech WER. Click a call to open it; admins can add what the caller really said with ✎.",
  "sq.pending": "Scoring runs in the background a few minutes after each recorded call ends. Nothing scored in this range yet.",
  "sq.loading": "Loading speech scores…",
  "sq.addRef": "✎ Caller reference",
  "sq.editRef": "✎ Edit reference",
  "sq.refTitle": "What did the caller really say?",
  "sq.refHelp": "Listen to the recording and type exactly what the CALLER said (not the AI), in order. This becomes the reference for Caller ASR WER.",
  "sq.refHeard": "What the speech recognizer heard",
  "sq.refPlaceholder": "Type the caller's words…",
  "sq.save": "Save",
  "sq.cancel": "Cancel",
  "sq.saving": "Saving…",
  "sq.saveFailed": "Could not save: {{error}}",
  "sq.drill.agentPoor": "AI speech WER above 20%",
  "sq.drill.caller": "Calls with a caller reference",
  "sq.drill.termMiss": "Key term misheard",
  "sq.drill.error": "Mishearing: {{pair}}",
  "sq.drill.call": "Call {{id}}",
  "sq.missed": "(missed)",
  "sq.extra": "(extra)",
  "sq.ins.title": "Insight",
  "sq.ins.info": "Plain-language reading of the numbers on this card for this client's calls, with what to do next. Worked out from the scores — nothing is assumed beyond them.",
  "sq.ins.clear.excellent": "The AI speaks clearly: {{wer}} WER over {{words}} words is excellent for phone audio.",
  "sq.ins.clear.good": "The AI is mostly clear: {{wer}} WER is good for phone audio. The mishearings below show where it slips.",
  "sq.ins.clear.fair": "Some of the AI's speech isn't coming through cleanly ({{wer}} WER, fair). Check the mishearings below — names and numbers first.",
  "sq.ins.clear.poor": "Callers often can't make out the AI ({{wer}} WER, poor). Listen to the worst calls: the voice, its speed or the line quality likely needs changing.",
  "sq.ins.cutHigh": "{{pct}} of what the AI planned to say was never heard — calls ending during the greeting or disclosure (no answer, voicemail, the customer hanging up) or the AI being cut off. That's a bigger problem than pronunciation: see the Unreachable tab and early hang-ups.",
  "sq.ins.cutSome": "{{pct}} of the AI's speech was cut off, mostly by calls ending early.",
  "sq.ins.terms": "Key terms come through {{pct}} of the time. Most misses are names ({{examples}}) — usually the transcriber spelling an uncommon name, not the AI saying it wrong. If a name sounds wrong when you listen, add a phonetic spelling to the campaign file.",
  "sq.ins.repeat": "\"{{ref}}\" was heard as \"{{hyp}}\" in {{n}} calls. A miss that repeats on the same word is worth one listen — if the voice blurs it, add a pronunciation fix for that word.",
  "sq.ins.placeholder": "Some calls read a blank-name placeholder aloud (\"null\"). That was fixed on 28 Oct 2026; these are older calls.",
  "sq.ins.shortWorst": "The worst-scoring calls are very short (little more than a greeting), so one or two wrong words make 50%. Judge accuracy by the overall figure, not those calls.",
  "sq.ins.callerNone": "How well the AI understands callers isn't measured yet. Add what the caller really said on about 20 calls (✎ below) to get a Caller ASR WER.",
  "sq.ins.callerPoor": "The AI mishears callers ({{wer}} WER). Expect wrong answers and \"boleh ulang?\" repeats — check the caller mishearings and add phrase hints for common answers.",
  "sq.ins.callerOk": "The AI hears callers well ({{wer}} WER on {{n}} checked calls).",
  "sq.ins.assistants": "{{best}} is the clearest ({{bestWer}}); {{worst}} is the weakest ({{worstWer}}) — compare their voice settings.",
  "sq.ins.sample": "Based on {{n}} scored calls — an early signal that firms up after about 50.",
});

Object.assign(window.STRINGS.ms, {
  "sq.ins.title": "Pandangan",
  "sq.ins.info": "Penerangan mudah tentang angka pada kad ini bagi panggilan klien ini, serta langkah seterusnya. Dikira daripada skor — tiada andaian lain.",
  "sq.ins.clear.excellent": "AI bercakap dengan jelas: WER {{wer}} bagi {{words}} perkataan adalah cemerlang untuk audio telefon.",
  "sq.ins.clear.good": "AI kebanyakannya jelas: WER {{wer}} adalah baik untuk audio telefon. Salah dengar di bawah menunjukkan di mana ia tergelincir.",
  "sq.ins.clear.fair": "Sebahagian pertuturan AI tidak sampai dengan jelas (WER {{wer}}, sederhana). Semak salah dengar di bawah — nama dan nombor dahulu.",
  "sq.ins.clear.poor": "Pemanggil sering tidak dapat menangkap apa yang AI sebut (WER {{wer}}, lemah). Dengar panggilan terburuk: suara, kelajuan atau kualiti talian mungkin perlu diubah.",
  "sq.ins.cutHigh": "{{pct}} daripada apa yang AI mahu sebut tidak pernah didengar — panggilan tamat semasa salam atau pendedahan (tiada jawapan, peti suara, pelanggan letak telefon) atau AI terputus. Ini masalah lebih besar daripada sebutan: lihat tab Tidak dapat dihubungi dan panggilan yang tamat awal.",
  "sq.ins.cutSome": "{{pct}} pertuturan AI terputus, kebanyakannya kerana panggilan tamat awal.",
  "sq.ins.terms": "Istilah utama disebut dengan betul {{pct}} masa. Kebanyakan yang salah ialah nama ({{examples}}) — biasanya pengecam pertuturan mengeja nama yang jarang, bukan AI tersalah sebut. Jika nama kedengaran salah semasa didengar, tambah ejaan fonetik dalam fail kempen.",
  "sq.ins.repeat": "\"{{ref}}\" didengar sebagai \"{{hyp}}\" dalam {{n}} panggilan. Kesilapan berulang pada perkataan yang sama patut didengar sekali — jika suara mengaburkannya, tambah pembetulan sebutan untuk perkataan itu.",
  "sq.ins.placeholder": "Sebahagian panggilan menyebut pemegang tempat nama kosong (\"null\"). Ini telah dibaiki pada 28 Okt 2026; ini panggilan lama.",
  "sq.ins.shortWorst": "Panggilan dengan skor terburuk sangat pendek (hampir salam sahaja), jadi satu dua perkataan salah menjadikannya 50%. Nilai ketepatan berdasarkan angka keseluruhan, bukan panggilan itu.",
  "sq.ins.callerNone": "Sejauh mana AI memahami pemanggil belum diukur. Tambah apa yang pemanggil benar-benar sebut pada kira-kira 20 panggilan (✎ di bawah) untuk mendapat WER ASR pemanggil.",
  "sq.ins.callerPoor": "AI tersalah dengar pemanggil (WER {{wer}}). Jangkakan jawapan salah dan ulangan \"boleh ulang?\" — semak salah dengar pemanggil dan tambah petunjuk frasa untuk jawapan biasa.",
  "sq.ins.callerOk": "AI mendengar pemanggil dengan baik (WER {{wer}} bagi {{n}} panggilan disemak).",
  "sq.ins.assistants": "{{best}} paling jelas ({{bestWer}}); {{worst}} paling lemah ({{worstWer}}) — bandingkan tetapan suara mereka.",
  "sq.ins.sample": "Berdasarkan {{n}} panggilan dinilai — petunjuk awal yang lebih tepat selepas kira-kira 50.",
});

Object.assign(window.STRINGS.ms, {
  "sq.title": "Ketepatan pertuturan (WER)",
  "sq.subtitle": "{{scored}} daripada {{total}} panggilan berakaman dinilai · disesuaikan dengan skrip setiap pembantu",
  "sq.info": "Kadar Ralat Perkataan (WER) — ukuran ketepatan standard industri untuk AI suara: perkataan salah + tertinggal + lebihan ÷ perkataan sebenar. Lebih rendah lebih baik. Untuk talian telefon ≤10% cemerlang, ≤20% baik, ≤30% sederhana, melebihi 30% lemah. Nama syarikat, produk dan pelanggan diambil daripada skrip setiap pembantu, jadi nama jenama dikira sebagai istilah utama, bukan ralat.",
  "sq.agent": "WER pertuturan AI",
  "sq.agentInfo": "Apa yang sebenarnya didengar pemanggil: bahagian AI dalam rakaman ditranskrip dan dibandingkan dengan apa yang AI mahu sebut. Nilai tinggi bermaksud nama atau nombor salah sebut, audio terpotong atau ayat terputus. Klik untuk lihat panggilan melebihi 20%.",
  "sq.agentMeta": "{{words}} perkataan disemak",
  "sq.cutMeta": "{{pct}} pertuturan AI terputus — klik",
  "sq.cutInfo": "Ayat yang tidak didengar sepenuhnya oleh pemanggil kerana panggilan tamat, pemanggil bercakap serentak dengan AI, atau audio terputus. Dikira berasingan supaya tidak kelihatan seperti sebutan yang salah.",
  "sq.drill.cutOff": "Pertuturan AI terputus",
  "sq.caller": "WER ASR pemanggil",
  "sq.callerInfo": "Sejauh mana AI memahami pemanggil: apa yang didengar dibandingkan dengan transkrip rujukan apa yang pemanggil benar-benar sebut. Rujukan ditaip oleh admin (✎ di bawah), atau dianggarkan oleh enjin pertuturan kedua jika diaktifkan. Klik untuk lihat panggilan yang dinilai.",
  "sq.callerMeta": "Panggilan dengan rujukan: {{n}}",
  "sq.callerNone": "Perlukan transkrip rujukan",
  "sq.terms": "Ketepatan istilah utama",
  "sq.termsInfo": "Peratus istilah utama skrip (nama syarikat, produk dan pelanggan) yang disebut dengan betul dalam pertuturan AI. Klik untuk lihat panggilan di mana istilah utama tersalah dengar.",
  "sq.termsMeta": "{{hits}} daripada {{total}} istilah utama",
  "sq.byAssistant": "WER pertuturan AI mengikut pembantu",
  "sq.byAssistantInfo": "WER pertuturan AI bagi setiap pembantu (suara + skrip). Klik bar untuk lihat panggilan pembantu itu.",
  "sq.topErrors": "Salah dengar paling kerap",
  "sq.topErrorsInfo": "Perkataan yang paling kerap keluar berbeza daripada yang dimaksudkan (dimaksudkan → didengar), merangkumi pertuturan AI dan pengecaman pemanggil. Klik untuk lihat panggilan berkenaan.",
  "sq.worst": "Panggilan terburuk",
  "sq.worstInfo": "Panggilan yang dinilai dengan WER pertuturan AI tertinggi. Klik panggilan untuk membukanya; admin boleh tambah apa yang pemanggil benar-benar sebut dengan ✎.",
  "sq.pending": "Penilaian berjalan di latar belakang beberapa minit selepas setiap panggilan berakaman tamat. Belum ada yang dinilai dalam julat ini.",
  "sq.loading": "Memuatkan skor pertuturan…",
  "sq.addRef": "✎ Rujukan pemanggil",
  "sq.editRef": "✎ Sunting rujukan",
  "sq.refTitle": "Apa yang pemanggil benar-benar sebut?",
  "sq.refHelp": "Dengar rakaman dan taip tepat apa yang PEMANGGIL sebut (bukan AI), mengikut urutan. Ini menjadi rujukan untuk WER ASR pemanggil.",
  "sq.refHeard": "Apa yang didengar oleh pengecam pertuturan",
  "sq.refPlaceholder": "Taip perkataan pemanggil…",
  "sq.save": "Simpan",
  "sq.cancel": "Batal",
  "sq.saving": "Menyimpan…",
  "sq.saveFailed": "Gagal menyimpan: {{error}}",
  "sq.drill.agentPoor": "WER pertuturan AI melebihi 20%",
  "sq.drill.caller": "Panggilan dengan rujukan pemanggil",
  "sq.drill.termMiss": "Istilah utama tersalah dengar",
  "sq.drill.error": "Salah dengar: {{pair}}",
  "sq.drill.call": "Panggilan {{id}}",
  "sq.missed": "(tertinggal)",
  "sq.extra": "(lebihan)",
});

const SQ_POOR_WER = 0.2;

function sqColor(wer) {
  if (wer == null) return undefined;
  if (wer <= 0.1) return "var(--ok)";
  if (wer <= 0.2) return "var(--info)";
  if (wer <= 0.3) return "var(--warn)";
  return "var(--err)";
}
const sqPct = (x) => (x == null || Number.isNaN(x) ? "—" : `${(x * 100).toFixed(1)}%`);

function sqPool(sides) {
  const t = { refWords: 0, errors: 0, termTotal: 0, termHits: 0, calls: 0, cutWords: 0 };
  sides.forEach(s => {
    if (!s || s.wer == null) return; // too few heard words to score
    t.calls += 1;
    t.cutWords += s.cutWords || 0;
    t.refWords += s.refWords;
    t.errors += s.substitutions + s.deletions + s.insertions;
    t.termTotal += s.termTotal || 0;
    t.termHits += s.termHits || 0;
  });
  return { ...t, wer: t.refWords ? t.errors / t.refWords : null };
}

// Cut-off share counts every scored call, including ones too short to score.
function sqCutShare(sides) {
  let cut = 0;
  let total = 0;
  sides.forEach(s => {
    if (!s) return;
    cut += s.cutWords || 0;
    total += (s.refWords || 0) + (s.cutWords || 0);
  });
  return { cut, total };
}

// Plain-language findings for the insight panel: only statements the scores
// support, each with what to do next. Ordered most important first.
function sqInsights(stats, scoredCalls, t) {
  const out = [];
  const { agent, caller, cutShare, assistants, topErrors, worst } = stats;
  if (agent.wer != null) {
    const band = agent.wer <= 0.1 ? "excellent" : agent.wer <= 0.2 ? "good" : agent.wer <= 0.3 ? "fair" : "poor";
    out.push({ tone: band === "excellent" || band === "good" ? "ok" : band === "fair" ? "warn" : "err",
      text: t(`sq.ins.clear.${band}`, { wer: sqPct(agent.wer), words: agent.refWords.toLocaleString() }) });
  }
  const cut = cutShare.total ? cutShare.cut / cutShare.total : 0;
  if (cut >= 0.25) out.push({ tone: "err", text: t("sq.ins.cutHigh", { pct: sqPct(cut) }), drill: "cutOff" });
  else if (cut >= 0.1) out.push({ tone: "warn", text: t("sq.ins.cutSome", { pct: sqPct(cut) }), drill: "cutOff" });

  const termPairs = new Map();
  scoredCalls.forEach(r => (r.agent?.termErrors || []).forEach(p => termPairs.set(p, (termPairs.get(p) || 0) + 1)));
  if (agent.termTotal && agent.termHits / agent.termTotal < 0.9) {
    const examples = [...termPairs.keys()].filter(p => !p.startsWith("null ")).slice(0, 3).map(p => p.split(" → ")[0]).join(", ");
    out.push({ tone: "warn", text: t("sq.ins.terms", { pct: `${Math.round((agent.termHits / agent.termTotal) * 100)}%`, examples: examples || "—" }), drill: "termMiss" });
  }
  // The same word missed in 2+ calls — a real pronunciation suspect.
  const repeat = topErrors.find(([pair, n]) => n >= 2 && !pair.startsWith("null ") && !pair.includes("(extra)") && !pair.includes("(missed)")
    && (termPairs.has(pair) || pair.split(" → ")[0].length >= 5));
  if (repeat) {
    const [ref, hyp] = repeat[0].split(" → ");
    out.push({ tone: "info", text: t("sq.ins.repeat", { ref, hyp, n: repeat[1] }), drill: `error:${repeat[0]}` });
  }
  const placeholder = topErrors.find(([pair]) => pair.startsWith("null "));
  if (placeholder) out.push({ tone: "info", text: t("sq.ins.placeholder"), drill: `error:${placeholder[0]}` });
  if (worst.length && worst.filter(l => (scoredCalls.find(r => r.id === l.id)?.agent?.refWords || 99) < 15).length >= Math.ceil(worst.length / 2)) {
    out.push({ tone: "info", text: t("sq.ins.shortWorst") });
  }
  if (caller.calls === 0) out.push({ tone: "info", text: t("sq.ins.callerNone") });
  else if (caller.wer > 0.2) out.push({ tone: "err", text: t("sq.ins.callerPoor", { wer: sqPct(caller.wer) }), drill: "caller" });
  else out.push({ tone: "ok", text: t("sq.ins.callerOk", { wer: sqPct(caller.wer), n: caller.calls }), drill: "caller" });
  const ranked = assistants.filter(a => a.calls >= 3 && a.wer != null).sort((a, b) => a.wer - b.wer);
  if (ranked.length >= 2 && ranked[ranked.length - 1].wer - ranked[0].wer >= 0.05) {
    const best = ranked[0], worstA = ranked[ranked.length - 1];
    out.push({ tone: "info", text: t("sq.ins.assistants", { best: best.name, bestWer: sqPct(best.wer), worst: worstA.name, worstWer: sqPct(worstA.wer) }), drill: `assistant:${worstA.id}` });
  }
  if (stats.scored < 30) out.push({ tone: "info", text: t("sq.ins.sample", { n: stats.scored }) });
  return out;
}

function SpeechInsights({ items, t, Info, onDrill }) {
  const InfoIcon = Info || (() => null);
  const color = { ok: "var(--ok)", warn: "var(--warn)", err: "var(--err)", info: "var(--info)" };
  if (!items.length) return null;
  return (
    <div style={{ padding: 12, borderRadius: 8, background: "rgba(141,139,255,0.07)", border: "1px solid rgba(141,139,255,0.25)" }}>
      <div className="stat-label mb-2">✦ {t("sq.ins.title")}<InfoIcon text={t("sq.ins.info")} /></div>
      <ul style={{ margin: 0, paddingLeft: 18 }} className="col gap-1">
        {items.map((it, i) => (
          <li key={i} className="text-12" style={{ lineHeight: 1.5, cursor: it.drill ? "pointer" : undefined, listStyle: "none", marginLeft: -18, display: "flex", gap: 8 }}
            onClick={() => it.drill && onDrill(it.drill)}>
            <span style={{ color: color[it.tone], fontWeight: 700 }}>●</span>
            <span>{it.text}{it.drill ? <span className="muted"> →</span> : null}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

function useSpeechQuality() {
  const [data, setData] = React.useState(null);
  const load = React.useCallback(() => voxApi("/speech-quality")
    .then(r => setData({ calls: r.calls || {}, canEditReferences: Boolean(r.canEditReferences) }))
    .catch(() => setData(d => d || { calls: {}, canEditReferences: false, error: true })), []);
  React.useEffect(() => {
    load();
    const id = setInterval(load, 5 * 60 * 1000);
    return () => clearInterval(id);
  }, [load]);
  const setCall = React.useCallback((id, summary) => setData(d => ({ ...(d || {}), calls: { ...(d?.calls || {}), [id]: summary } })), []);
  return data ? { ...data, reload: load, setCall } : null;
}

// Drill filter shared by every view: drill = { type: "sq", value }.
function speechQualityMatches(l, drill, sq) {
  const r = sq?.calls?.[l.id];
  if (!r) return false;
  const [kind, ...rest] = String(drill?.value || "").split(":");
  const arg = rest.join(":");
  switch (kind) {
    case "scored": return true;
    case "agentPoor": return (r.agent?.wer ?? 0) > SQ_POOR_WER;
    case "caller": return Boolean(r.caller);
    case "termMiss": return Boolean(r.agent && r.agent.termTotal > r.agent.termHits);
    case "cutOff": return (r.agent?.cutWords || 0) > 0;
    case "assistant": return (l.assistantId || "") === arg && r.agent?.wer != null;
    case "error": return [...(r.agent?.errors || []), ...(r.caller?.errors || [])].includes(arg);
    case "call": return l.id === arg;
    default: return true;
  }
}

function SpeechQualityCard({ logs = [], sq, t, lang = "en", Info, drill, onDrill }) {
  const InfoIcon = Info || (() => null);
  const [editing, setEditing] = React.useState(null);
  const active = (value) => drill?.type === "sq" && drill.value === value;
  const errorLabel = (pair) => pair.replace("(missed)", t("sq.missed")).replace("(extra)", t("sq.extra"));

  const stats = React.useMemo(() => {
    const recorded = logs.filter(l => l.recordingPath);
    const scored = recorded.filter(l => sq?.calls?.[l.id]);
    const agent = sqPool(scored.map(l => sq.calls[l.id].agent));
    const cutShare = sqCutShare(scored.map(l => sq.calls[l.id].agent));
    const caller = sqPool(scored.map(l => sq.calls[l.id].caller));
    const byAssistant = new Map();
    const errors = new Map();
    scored.forEach(l => {
      const r = sq.calls[l.id];
      if (r.agent) {
        const key = l.assistantId || "";
        const row = byAssistant.get(key) || { id: key, name: l.assistantName || key || "—", sides: [] };
        row.sides.push(r.agent);
        byAssistant.set(key, row);
      }
      [...(r.agent?.errors || []), ...(r.caller?.errors || [])].forEach(p => errors.set(p, (errors.get(p) || 0) + 1));
    });
    const assistants = [...byAssistant.values()].map(a => ({ ...a, ...sqPool(a.sides) })).sort((a, b) => b.refWords - a.refWords);
    const topErrors = [...errors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
    const worst = scored.filter(l => sq.calls[l.id].agent?.wer != null).sort((a, b) => sq.calls[b.id].agent.wer - sq.calls[a.id].agent.wer).slice(0, 5);
    const result = { recorded: recorded.length, scored: scored.length, agent, cutShare, caller, assistants: assistants.filter(a => a.calls > 0), topErrors, worst };
    result.insights = scored.length ? sqInsights(result, scored.map(l => ({ ...sq.calls[l.id], id: l.id })), t) : [];
    return result;
  }, [logs, sq, t]);
  const drillInsight = (value) => {
    const [kind, ...rest] = value.split(":");
    const arg = rest.join(":");
    const label = kind === "error" ? t("sq.drill.error", { pair: errorLabel(arg) })
      : kind === "assistant" ? `${t("sq.agent")}: ${stats.assistants.find(a => a.id === arg)?.name || arg}`
      : t(`sq.drill.${kind}`);
    onDrill("sq", value, label);
  };

  const title = <>{t("sq.title")}<InfoIcon text={t("sq.info")} /></>;
  if (!sq) return <Card title={title}><div className="muted text-12">{t("sq.loading")}</div></Card>;

  const tile = (labelKey, infoKey, value, color, meta, drillValue, clickable) => (
    <div className="stat" style={{ cursor: clickable ? "pointer" : undefined, background: active(drillValue) ? "rgba(141,139,255,0.09)" : undefined }}
      onClick={() => clickable && onDrill("sq", drillValue, t(`sq.drill.${drillValue}`))}>
      <div className="stat-label">{t(labelKey)}<InfoIcon text={t(infoKey)} /></div>
      <div className="stat-value" style={{ color }}>{value}</div>
      <div className="muted text-11 mt-2">{meta}</div>
    </div>
  );

  return (
    <Card title={title} subtitle={t("sq.subtitle", { scored: stats.scored.toLocaleString(), total: stats.recorded.toLocaleString() })}>
      {!stats.scored ? (
        <div className="muted text-12">{t("sq.pending")}</div>
      ) : (
        <div className="col gap-3">
          <SpeechInsights items={stats.insights} t={t} Info={Info} onDrill={drillInsight} />
          <div className="g-3">
            {tile("sq.agent", "sq.agentInfo", sqPct(stats.agent.wer), sqColor(stats.agent.wer),
              t("sq.agentMeta", { words: stats.agent.refWords.toLocaleString() }), "agentPoor", stats.agent.calls > 0)}
            {stats.cutShare.cut > 0 && (
              <div className="muted text-11" style={{ gridColumn: "1 / -1", cursor: "pointer", marginTop: -6,
                background: active("cutOff") ? "rgba(141,139,255,0.09)" : undefined, borderRadius: 6, padding: "2px 6px" }}
                onClick={() => onDrill("sq", "cutOff", t("sq.drill.cutOff"))}>
                ✂ {t("sq.cutMeta", { pct: sqPct(stats.cutShare.cut / stats.cutShare.total) })}
                <InfoIcon text={t("sq.cutInfo")} />
              </div>
            )}
            {tile("sq.caller", "sq.callerInfo", sqPct(stats.caller.wer), sqColor(stats.caller.wer),
              stats.caller.calls ? t("sq.callerMeta", { n: stats.caller.calls }) : t("sq.callerNone"), "caller", stats.caller.calls > 0)}
            {tile("sq.terms", "sq.termsInfo", stats.agent.termTotal ? `${Math.round((stats.agent.termHits / stats.agent.termTotal) * 100)}%` : "—",
              stats.agent.termTotal && stats.agent.termHits / stats.agent.termTotal < 0.9 ? "var(--warn)" : undefined,
              t("sq.termsMeta", { hits: stats.agent.termHits, total: stats.agent.termTotal }), "termMiss", stats.agent.termTotal > stats.agent.termHits)}
          </div>

          {stats.assistants.length > 0 && (
            <div>
              <div className="stat-label mb-2">{t("sq.byAssistant")}<InfoIcon text={t("sq.byAssistantInfo")} /></div>
              <div className="col gap-2">
                {stats.assistants.map(a => (
                  <AngkasaBarRow key={a.id || "none"} label={a.name} value={sqPct(a.wer)} count={Math.round((a.wer || 0) * 1000)}
                    max={Math.max(1, ...stats.assistants.map(x => Math.round((x.wer || 0) * 1000)))} color={sqColor(a.wer)}
                    active={active(`assistant:${a.id}`)} onClick={() => onDrill("sq", `assistant:${a.id}`, `${t("sq.agent")}: ${a.name}`)} />
                ))}
              </div>
            </div>
          )}

          {stats.topErrors.length > 0 && (
            <div>
              <div className="stat-label mb-2">{t("sq.topErrors")}<InfoIcon text={t("sq.topErrorsInfo")} /></div>
              <div className="col gap-1">
                {stats.topErrors.map(([pair, count]) => (
                  <div key={pair} className="row" onClick={() => onDrill("sq", `error:${pair}`, t("sq.drill.error", { pair: errorLabel(pair) }))}
                    style={{ justifyContent: "space-between", fontSize: 12, cursor: "pointer", padding: "3px 6px", margin: "0 -6px", borderRadius: 6,
                      background: active(`error:${pair}`) ? "rgba(141,139,255,0.09)" : "transparent" }}>
                    <span className="mono">{errorLabel(pair)}</span>
                    <span className="muted">×{count}</span>
                  </div>
                ))}
              </div>
            </div>
          )}

          <div>
            <div className="stat-label mb-2">{t("sq.worst")}<InfoIcon text={t("sq.worstInfo")} /></div>
            <div className="col gap-1">
              {stats.worst.map(l => {
                const r = sq.calls[l.id];
                return (
                  <div key={l.id} className="row" style={{ justifyContent: "space-between", gap: 8, fontSize: 12, padding: "3px 6px", margin: "0 -6px", borderRadius: 6,
                    background: active(`call:${l.id}`) ? "rgba(141,139,255,0.09)" : "transparent" }}>
                    <span style={{ cursor: "pointer", flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
                      onClick={() => onDrill("sq", `call:${l.id}`, t("sq.drill.call", { id: l.customerName || l.id }))}>
                      {l.customerName || l.title || l.id}
                      <span className="muted"> · {new Date(l.startedAt || l.updatedAt || 0).toLocaleString(lang === "ms" ? "ms-MY" : "en-MY", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}</span>
                    </span>
                    <span style={{ color: sqColor(r.agent.wer), fontWeight: 600 }}>{sqPct(r.agent.wer)}</span>
                    {sq.canEditReferences && (
                      <Btn size="sm" variant="ghost" onClick={() => setEditing(l)}>{r.hasReference ? t("sq.editRef") : t("sq.addRef")}</Btn>
                    )}
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      )}
      {editing && (
        <SpeechReferenceEditor log={editing} t={t} lang={lang} onClose={() => setEditing(null)}
          onSaved={(summary) => { if (summary) sq.setCall(editing.id, summary); setEditing(null); }} />
      )}
    </Card>
  );
}

function SpeechReferenceEditor({ log, t, lang, onClose, onSaved }) {
  const [text, setText] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState("");
  React.useEffect(() => {
    let alive = true;
    voxApi(`/speech-quality/${encodeURIComponent(log.id)}/reference`).then(r => alive && setText(r.text || "")).catch(() => {});
    const onKey = e => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } };
    window.addEventListener("keydown", onKey, true);
    return () => { alive = false; window.removeEventListener("keydown", onKey, true); };
  }, [log.id]);
  const heard = (log.turns || []).map(turn => String(turn.user || "").trim()).filter(Boolean).join(" · ");
  async function save() {
    setBusy(true);
    setError("");
    try {
      const r = await voxApi(`/speech-quality/${encodeURIComponent(log.id)}/reference`, { method: "PUT", body: JSON.stringify({ text }) });
      onSaved(r.summary);
    } catch (e) {
      setError(t("sq.saveFailed", { error: e.message }));
      setBusy(false);
    }
  }
  return (
    <div onClick={onClose} style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(10,12,20,0.55)", display: "flex", alignItems: "center", justifyContent: "center", padding: 16 }}>
      <div className="card" onClick={e => e.stopPropagation()} style={{ width: "min(640px, 100%)", maxHeight: "90vh", overflow: "auto", padding: 18 }}>
        <div className="card-title mb-2">{t("sq.refTitle")}</div>
        <div className="muted text-12 mb-3">{log.customerName || log.title || log.id} — {t("sq.refHelp")}</div>
        {log.recordingPath && typeof RecordingPlayer === "function" && <div className="mb-3"><RecordingPlayer logId={log.id} lang={lang} /></div>}
        <div className="stat-label mb-1">{t("sq.refHeard")}</div>
        <div className="text-12 mb-3" style={{ padding: 8, borderRadius: 6, background: "var(--c-bg-2, rgba(0,0,0,0.04))" }}>{heard || "—"}</div>
        <textarea value={text} onChange={e => setText(e.target.value)} placeholder={t("sq.refPlaceholder")} rows={6}
          style={{ width: "100%", boxSizing: "border-box", fontFamily: "inherit", fontSize: 13, padding: 8, borderRadius: 6, border: "1px solid var(--c-line-2)", background: "transparent", color: "inherit" }} />
        {error && <div className="text-12 mt-2" style={{ color: "var(--err)" }}>{error}</div>}
        <div className="row gap-2 mt-3" style={{ justifyContent: "flex-end" }}>
          <Btn size="sm" variant="ghost" onClick={onClose}>{t("sq.cancel")}</Btn>
          <Btn size="sm" variant="primary" onClick={save} disabled={busy}>{busy ? t("sq.saving") : t("sq.save")}</Btn>
        </div>
      </div>
    </div>
  );
}
