/* Brolly QA — standalone console. No Radar imports; everything self-contained. */
const { useState, useEffect } = React;

async function jget(url) {
  const r = await fetch(url, { credentials: 'include' });
  if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || `${r.status}`); }
  return r.json();
}
function useDataFetch(url, deps) {
  const [state, setState] = useState({ loading: true, data: null, error: null });
  useEffect(() => {
    let cancelled = false;
    setState((s) => ({ ...s, loading: true, error: null }));
    jget(url)
      .then((d) => { if (!cancelled) setState({ loading: false, data: d, error: null }); })
      .catch((e) => { if (!cancelled) setState({ loading: false, data: null, error: e.message }); });
    return () => { cancelled = true; };
  }, deps); // eslint-disable-line
  return state;
}
function MrSkel({ rows = 4, height }) {
  return <div className="gw-rows">{Array.from({ length: rows }).map((_, i) => <div key={i} className="mr-skel" style={height ? { height } : null} />)}</div>;
}

/* ---------- Login gate ---------- */
function Login({ onAuthed }) {
  const [u, setU] = useState(''); const [p, setP] = useState('');
  const [busy, setBusy] = useState(false); const [err, setErr] = useState('');
  const submit = async (e) => {
    e.preventDefault(); setBusy(true); setErr('');
    try {
      const r = await fetch('/login', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: u, password: p }) });
      if (!r.ok) { const j = await r.json().catch(() => ({})); throw new Error(j.error || 'Login failed'); }
      onAuthed();
    } catch (e2) { setErr(e2.message); setBusy(false); }
  };
  return (
    <div className="qa-login-wrap">
      <form className="qa-login" onSubmit={submit}>
        <div className="brand"><span className="logo">🧪</span> Brolly QA</div>
        <div className="sub">Standalone QA console — sign in to continue.</div>
        <label>Username<input autoFocus value={u} onChange={(e) => setU(e.target.value)} autoComplete="username" /></label>
        <label>Password<input type="password" value={p} onChange={(e) => setP(e.target.value)} autoComplete="current-password" /></label>
        <button type="submit" disabled={busy || !u || !p}>{busy ? 'Signing in…' : 'Sign in'}</button>
        <div className="err">{err}</div>
      </form>
    </div>
  );
}

/* ---------- Reports ---------- */
function CQAReports() {
  const { loading, data, error } = useDataFetch('/api/qa/docs', []);
  const [open, setOpen] = useState(null);
  const [html, setHtml] = useState(null);
  useEffect(() => {
    if (!open) { setHtml(null); return; }
    let cancelled = false;
    fetch(`/api/qa/docs/${encodeURIComponent(open.slug)}`, { credentials: 'include' })
      .then((r) => (r.ok ? r.text() : Promise.reject(new Error(`${r.status}`))))
      .then((t) => { if (!cancelled) setHtml(t); })
      .catch(() => { if (!cancelled) setHtml('<p style="font-family:sans-serif;padding:20px">Could not load this report.</p>'); });
    return () => { cancelled = true; };
  }, [open]);
  const openInTab = () => { if (!html) return; const url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); window.open(url, '_blank'); setTimeout(() => URL.revokeObjectURL(url), 60000); };
  const downloadPdf = async (doc) => {
    try {
      const r = await fetch(`/api/qa/docs/${encodeURIComponent(doc.slug)}/pdf`, { credentials: 'include' });
      if (!r.ok) throw new Error(`${r.status}`);
      const url = URL.createObjectURL(await r.blob());
      const a = Object.assign(document.createElement('a'), { href: url, download: doc.pdfName || `${doc.slug}.pdf` });
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 60000);
    } catch { /* noop */ }
  };
  const printDoc = () => { if (!html) return; const url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); const w = window.open(url, '_blank'); if (w) w.addEventListener('load', () => { try { w.focus(); w.print(); } catch {} }, { once: true }); setTimeout(() => URL.revokeObjectURL(url), 60000); };
  return (
    <div>
      <div className="gw-empty-b" style={{ padding: '0 0 12px' }}>Product QA bug reports — environment comparisons with real screenshots, version-controlled with the codebase, newest first.</div>
      {error ? <div className="gw-error">Couldn't load QA reports: {error}</div> : null}
      {loading ? <MrSkel rows={5} /> : (
        <div className="mr-doc-grid">
          {(data?.docs || []).map((d) => (
            <div key={d.slug} className="mr-doc-card" role="button" tabIndex={0} onClick={() => setOpen(d)} onKeyDown={(e) => { if (e.key === 'Enter') setOpen(d); }}>
              <div className="mr-doc-title">{d.title}</div>
              <div className="mr-doc-meta">{new Date(`${d.date}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' })} · {d.sizeKb} KB</div>
              <div className="mr-doc-blurb">{d.blurb}</div>
              <div className="mr-doc-actions">
                <button className="mr-btn" onClick={(e) => { e.stopPropagation(); setOpen(d); }}>Read</button>
                {d.hasPdf ? <button className="mr-btn" onClick={(e) => { e.stopPropagation(); downloadPdf(d); }}>⤓ PDF</button> : null}
              </div>
            </div>
          ))}
          {!loading && !(data?.docs || []).length ? <div className="gw-empty-b">No QA reports yet.</div> : null}
        </div>
      )}
      {open ? (
        <div className="mr-doc-overlay" onClick={() => setOpen(null)}>
          <div className="mr-doc-frame" onClick={(e) => e.stopPropagation()}>
            <div className="mr-doc-bar">
              <span className="mr-doc-bar-title">{open.title}</span>
              <span style={{ flex: 1 }} />
              {open.hasPdf ? <button className="mr-btn" onClick={() => downloadPdf(open)}>⤓ PDF</button> : null}
              <button className="mr-btn" onClick={printDoc} disabled={!html}>🖨 Print / PDF</button>
              <button className="mr-btn" onClick={openInTab} disabled={!html}>⧉ Open in tab</button>
              <button className="mr-btn" onClick={() => setOpen(null)}>✕ Close</button>
            </div>
            {html ? <iframe className="mr-doc-iframe" title={open.title} srcDoc={html} sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" /> : <MrSkel rows={10} />}
          </div>
        </div>
      ) : null}
    </div>
  );
}

/* ---------- Accounts (direct login per account) ---------- */
function CQAAccountsAdmin() {
  const [st, setSt] = useState({ loading: true, accounts: [] });
  const [form, setForm] = useState(null);
  const load = () => jget('/api/qa/accounts').then((d) => setSt({ loading: false, accounts: d.accounts || [] })).catch(() => setSt({ loading: false, accounts: [] }));
  useEffect(() => { load(); }, []);
  // editing: password starts blank (placeholder shows it's kept unless retyped)
  const open = (a) => setForm(a ? { ...a, password: '' } : { name: '', org: '', env: 'beta', username: '', password: '', scenario: '', suites: ['post', 'comment'] });
  const save = () => {
    const body = { ...form };
    if (form.id && !form.password) delete body.password; // keep existing password on edit
    return fetch('/api/qa/accounts', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(() => { setForm(null); load(); });
  };
  const del = (id) => fetch(`/api/qa/accounts/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' }).then(load);
  const toggleSuite = (s) => setForm((f) => ({ ...f, suites: f.suites.includes(s) ? f.suites.filter((x) => x !== s) : [...f.suites, s] }));
  const envBadge = (e) => { const c = { design: ['#dcfce7', '#15803d'], beta: ['#fef3c7', '#b45309'], prod: ['#fee2e2', '#b91c1c'] }[e] || ['#f1f5f9', '#475569']; return <span style={{ background: c[0], color: c[1], padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 700 }}>{e}</span>; };
  const inp = { width: '100%', border: '1px solid #e7ebf0', borderRadius: 8, padding: '8px 10px', fontSize: 13, fontFamily: 'inherit', marginTop: 4 };
  const lbl = { fontSize: 12, fontWeight: 600, color: '#0f172a', display: 'block' };
  // valid to save: name + username always; password required for a NEW account
  const canSave = form && form.name && form.username && (form.id || form.password);
  return (
    <div style={{ marginTop: 14 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
        <div style={{ fontWeight: 700, color: '#0f172a' }}>Test accounts <span style={{ fontWeight: 400, color: '#94a3b8', fontSize: 12.5 }}>· the agent logs in directly as each user, then tests that account's records</span></div>
        <button className="mr-btn" onClick={() => open(null)} style={{ whiteSpace: 'nowrap' }}>+ Add account</button>
      </div>
      {form ? (
        <div style={{ background: '#fff', border: '1px solid #e7ebf0', borderRadius: 12, padding: 16, marginBottom: 14, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
          <label style={lbl}>Account / org name<input style={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="South Australia Police" /></label>
          <label style={lbl}>Org ID <span style={{ fontWeight: 400, color: '#94a3b8' }}>(optional)</span><input style={inp} value={form.org} onChange={(e) => setForm({ ...form, org: e.target.value })} placeholder="197" /></label>
          <label style={lbl}>Username<input style={inp} value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} placeholder="user@agency.gov.au" autoComplete="off" /></label>
          <label style={lbl}>Password<input style={inp} type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} placeholder={form.id ? '•••••• (leave blank to keep)' : 'account password'} autoComplete="new-password" /></label>
          <label style={lbl}>Environment<select style={inp} value={form.env} onChange={(e) => setForm({ ...form, env: e.target.value })}><option value="design">design</option><option value="beta">beta</option><option value="prod">prod</option></select></label>
          <label style={lbl}>Scenario <span style={{ fontWeight: 400, color: '#94a3b8' }}>(optional)</span><input style={inp} value={form.scenario} onChange={(e) => setForm({ ...form, scenario: e.target.value })} placeholder="moderated thread / deleted comments" /></label>
          <div style={{ gridColumn: '1 / -1' }}><span style={lbl}>Suites</span><div style={{ display: 'flex', gap: 14, marginTop: 5 }}>{['post', 'comment', 'feed'].map((s) => <label key={s} style={{ display: 'flex', gap: 5, alignItems: 'center', fontSize: 13 }}><input type="checkbox" checked={form.suites.includes(s)} onChange={() => toggleSuite(s)} />{s}</label>)}</div></div>
          <div style={{ gridColumn: '1 / -1', fontSize: 11.5, color: '#94a3b8' }}>The password is stored server-side and never shown again. {form.id ? 'Leave it blank to keep the current one.' : ''}</div>
          <div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
            <button className="mr-btn" onClick={() => setForm(null)}>Cancel</button>
            <button className="mr-btn" style={{ background: '#6d28d9', color: '#fff', borderColor: '#6d28d9' }} onClick={save} disabled={!canSave}>Save account</button>
          </div>
        </div>
      ) : null}
      {st.loading ? <MrSkel rows={3} /> : (st.accounts.length ? st.accounts.map((a) => (
        <div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 12, background: '#fff', border: '1px solid #e7ebf0', borderRadius: 11, padding: '12px 14px', marginBottom: 10 }}>
          <div style={{ flex: 1 }}>
            <div style={{ fontWeight: 700, color: '#0f172a' }}>{a.name} {a.org ? <span style={{ fontSize: 11.5, color: '#64748b' }}>· org {a.org}</span> : null} {envBadge(a.env)}</div>
            <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 3 }}>{a.username || 'no username'} · {(a.suites || []).join(', ') || 'no suites'}{a.scenario ? ` · ${a.scenario}` : ''}</div>
          </div>
          <span title={a.credConfigured ? 'Password is stored' : 'Edit to set this account’s password'} style={{ background: a.credConfigured ? '#dcfce7' : '#fef3c7', color: a.credConfigured ? '#15803d' : '#b45309', padding: '2px 9px', borderRadius: 999, fontSize: 11, fontWeight: 700 }}>{a.credConfigured ? 'password set ✓' : 'set password'}</span>
          <button className="mr-btn" onClick={() => open(a)}>Edit</button>
          <button className="mr-btn" onClick={() => del(a.id)} title="Remove">✕</button>
        </div>
      )) : <div className="gw-empty-b" style={{ padding: '26px 0' }}>No accounts yet — add one (org name + username + password) to test against it.</div>)}
    </div>
  );
}

/* ---------- Dedicated full-screen shell ---------- */
const QA_CSS = `
.qa-root{position:fixed;inset:0;display:grid;grid-template-columns:222px 1fr;background:#f1f4f8;color:#1e293b}
.qa-side{background:#0f172a;color:#cbd5e1;padding:20px 14px;display:flex;flex-direction:column;gap:3px;overflow:auto}
.qa-brand{display:flex;align-items:center;gap:9px;padding:4px 8px 16px;font-weight:700;color:#fff;font-size:15px}
.qa-logo{width:26px;height:26px;border-radius:7px;background:linear-gradient(135deg,#7c3aed,#db2777);display:flex;align-items:center;justify-content:center;font-size:15px}
.qa-navsec{font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:#64748b;margin:14px 8px 4px}
.qa-nav{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer;color:#cbd5e1;font-weight:500;font-size:13.5px;border:none;background:none;width:100%;text-align:left}
.qa-nav:hover{background:rgba(255,255,255,.06)}
.qa-nav.on{background:#7c3aed;color:#fff}
.qa-nav .ic{width:16px;text-align:center}
.qa-nav .ct{margin-left:auto;background:rgba(255,255,255,.12);border-radius:999px;font-size:11px;padding:0 7px;font-weight:600}
.qa-back{margin-top:10px;color:#94a3b8}
.qa-hermes{margin-top:auto;background:rgba(124,58,237,.14);border:1px solid rgba(124,58,237,.35);border-radius:10px;padding:10px 11px;font-size:11.5px;color:#ddd6fe;line-height:1.5}
.qa-hermes b{color:#fff}
.qa-pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:#22c55e;margin-right:6px}
.qa-main{overflow:auto;padding:24px 30px}
.qa-top{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
.qa-h1{font-size:25px;font-weight:700;color:#0f172a;letter-spacing:-.3px}
.qa-sub{color:#64748b;font-size:13px;margin:4px 0 20px}
.qa-runbtn{border:none;background:#6d28d9;color:#fff;border-radius:9px;padding:9px 14px;font-weight:600;font-size:13px;cursor:pointer;white-space:nowrap}
.qa-tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px}
.qa-tile{background:#fff;border:1px solid #e7ebf0;border-radius:12px;padding:15px 16px}
.qa-tile .l{font-size:12px;color:#64748b;font-weight:500}
.qa-tile .v{font-size:26px;font-weight:700;color:#0f172a;margin-top:4px;letter-spacing:-.5px}
.qa-tile .d{font-size:11.5px;color:#94a3b8;margin-top:3px}
.qa-card{background:#fff;border:1px solid #e7ebf0;border-radius:12px;padding:18px 20px}
.qa-card h2{font-size:15px;color:#0f172a;margin-bottom:12px}
.qa-ev{display:flex;gap:11px;padding:9px 0;border-bottom:1px solid #f0f3f6;font-size:13px}
.qa-ev:last-child{border:none}
.qa-av{width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:linear-gradient(135deg,#7c3aed,#db2777);color:#fff;font-weight:700}
.qa-note{background:#faf5ff;border:1px dashed #c4b5fd;border-radius:10px;padding:11px 13px;font-size:12.5px;color:#5b21b6;margin-top:14px}
.qa-row{display:flex;align-items:center;gap:14px;background:#fff;border:1px solid #e7ebf0;border-radius:11px;padding:13px 14px;margin-bottom:10px}
.qa-pill{padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
.qa-tbl{width:100%;border-collapse:collapse;font-size:13px;background:#fff;border:1px solid #e7ebf0;border-radius:12px;overflow:hidden}
.qa-tbl th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.3px;color:#64748b;padding:9px 12px;border-bottom:2px solid #e7ebf0;background:#fff}
.qa-tbl td{padding:9px 12px;border-bottom:1px solid #f0f3f6}
.qa-tbl tbody tr:hover{background:#faf5ff}
.qa-itbl tbody tr.qa-grp:hover{background:#eef2f6}
.qa-counter{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
.qa-cc{font-size:12px;color:#475569;background:#fff;border:1px solid #e7ebf0;border-radius:999px;padding:4px 11px;cursor:pointer;user-select:none}
.qa-cc b{font-weight:800}
.qa-grp{cursor:pointer;background:#f8fafc}
.qa-grp td{font-weight:700;color:#0f172a;font-size:12.5px}
.qa-gpill{padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700}
.qa-grp-n{color:#94a3b8;font-weight:600;margin-left:4px}
.qa-modal-wrap{position:fixed;inset:0;background:rgba(15,23,42,.55);display:flex;align-items:center;justify-content:center;z-index:90;padding:28px}
.qa-modal{background:#fff;border-radius:14px;width:min(680px,96vw);max-height:90vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.4)}
.qa-modal-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;padding:16px 20px;border-bottom:1px solid #e7ebf0}
.qa-modal-title{font-weight:700;font-size:16px;color:#0f172a}
.qa-modal-sub{font-size:12px;color:#64748b;margin-top:3px}
.qa-modal-body{padding:18px 20px;overflow:auto}
.qa-ms{margin-bottom:16px}
.qa-ms-h{font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:#94a3b8;font-weight:700;margin-bottom:5px}
.qa-ms div,.qa-ms a{font-size:13.5px;color:#1e293b;line-height:1.5;overflow-wrap:anywhere}
.qa-steps{margin:0;padding-left:20px;font-size:13.5px;line-height:1.7;color:#1e293b}
.qa-chiprow{display:flex;gap:7px;align-items:center;flex-wrap:wrap;margin-top:8px}
.qa-sev{font-size:10.5px;font-weight:800;padding:3px 9px;border-radius:999px;text-transform:uppercase;letter-spacing:.3px}
.qa-sev-high{background:#fee2e2;color:#b91c1c}.qa-sev-medium{background:#fef3c7;color:#b45309}.qa-sev-low{background:#f1f5f9;color:#475569}
.qa-jchip{font-size:11.5px;font-weight:700;color:#6d28d9;background:#f5f3ff;padding:3px 9px;border-radius:7px;text-decoration:none}
.qa-tag{font-size:10.5px;font-weight:700;color:#4338ca;background:#eef2ff;padding:3px 9px;border-radius:999px}
.qa-tag-reg{color:#b91c1c;background:#fee2e2}
.qa-repro{font-size:12px;color:#64748b;margin-bottom:12px;word-break:break-all;background:#f8fafc;border:1px solid #eef0f2;border-radius:8px;padding:8px 11px}
.qa-repro a{color:#6d28d9}
.qa-shot-lg{width:100%;border:1px solid #e7ebf0;border-radius:10px;display:block;margin-bottom:14px}
.qa-verdict{font-size:13px;border-radius:8px;padding:9px 12px;margin-bottom:14px;background:#f1f5f9;color:#334155;line-height:1.5}
.qa-verdict.bad{background:#fef2f2;color:#991b1b;border-left:3px solid #ef4444}
.qa-verdict.good{background:#f0fdf4;color:#166534;border-left:3px solid #22c55e}
.qa-empty{padding:30px;text-align:center;color:#94a3b8;font-style:italic}
.qa-chip{font-size:11px;padding:2px 8px;border-radius:7px;border:1px solid #e7ebf0;color:#64748b;background:#fafbfc}
.qa-chat{display:flex;flex-direction:column;height:calc(100vh - 132px)}
.qa-chat-thread{flex:1;overflow:auto;padding:4px 2px 10px;display:flex;flex-direction:column;gap:14px}
.qa-msg{display:flex;gap:10px;max-width:820px}
.qa-msg.user{align-self:flex-end;flex-direction:row-reverse}
.qa-msg-av{width:28px;height:28px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff}
.qa-msg.assistant .qa-msg-av{background:linear-gradient(135deg,#7c3aed,#db2777)}
.qa-msg.user .qa-msg-av{background:#0f172a}
.qa-msg-body{background:#fff;border:1px solid #e7ebf0;border-radius:12px;padding:10px 14px;font-size:13.5px;line-height:1.55;color:#1e293b;overflow-wrap:anywhere}
.qa-msg.user .qa-msg-body{background:#7c3aed;color:#fff;border-color:#7c3aed}
.qa-msg.user .qa-msg-body a{color:#fff;text-decoration:underline}
.qa-msg-body a{color:#6d28d9}
.qa-msg-body code{background:rgba(0,0,0,.06);padding:1px 5px;border-radius:5px;font-size:12px}
.qa-cimg{max-width:100%;border-radius:8px;margin:6px 0;display:block;border:1px solid #e7ebf0}
.qa-typing{color:#94a3b8;font-style:italic}
.qa-chat-input{border-top:1px solid #e7ebf0;padding-top:10px}
.qa-attach{font-size:12px;color:#64748b;margin-bottom:6px}
.qa-attach button{border:none;background:none;cursor:pointer;color:#b91c1c;font-weight:700}
.qa-chat-row{display:flex;align-items:flex-end;gap:8px;background:#fff;border:1px solid #e7ebf0;border-radius:12px;padding:7px 8px}
.qa-attach-btn{cursor:pointer;font-size:18px;padding:4px 6px;user-select:none}
.qa-chat-ta{flex:1;border:none;outline:none;resize:none;font-family:inherit;font-size:13.5px;line-height:1.5;max-height:140px;background:transparent;color:#1e293b}
.qa-send{border:none;background:#6d28d9;color:#fff;width:34px;height:34px;border-radius:9px;cursor:pointer;font-size:15px;flex-shrink:0}
.qa-send:disabled{opacity:.4;cursor:default}
@media(max-width:780px){.qa-root{grid-template-columns:1fr}.qa-side{position:fixed;left:-999px}.qa-tiles{grid-template-columns:1fr 1fr}}
`;
// Minimal, escape-first markdown → HTML (images, links, bold, code, breaks).
function mdToHtml(s) {
  let h = String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
  h = h.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (m, alt, url) => `<img class="qa-cimg" alt="${alt}" src="${url}">`);
  h = h.replace(/(^|\s)(\/chat-artifacts\/\S+|https?:\/\/\S+\.(?:png|jpe?g|gif|webp))(?=\s|$)/gi, (m, pre, url) => `${pre}<img class="qa-cimg" src="${url}">`);
  h = h.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
  h = h.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
  h = h.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
  h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
  return h.replace(/\n/g, '<br>');
}

// Issues — review findings (checker fails + manual), confirm, push to Jira.
function CFindings() {
  const [st, setSt] = useState({ loading: true, findings: [] });
  const [jira, setJira] = useState({});
  const [form, setForm] = useState(null);
  const [busy, setBusy] = useState(null);
  const [msg, setMsg] = useState(null);
  const [collapsed, setCollapsed] = useState({});
  const [detail, setDetail] = useState(null);
  const load = () => jget('/api/qa/findings').then((d) => setSt({ loading: false, findings: d.findings || [] })).catch(() => setSt({ loading: false, findings: [] }));
  const loadJira = () => jget('/api/qa/findings/jira-status').then((d) => setJira(d.statuses || {})).catch(() => {});
  useEffect(() => { load(); loadJira(); }, []);
  const sync = () => fetch('/api/qa/findings/sync', { method: 'POST', credentials: 'include' }).then((r) => r.json()).then((d) => { setMsg(`Synced ${d.added || 0} new finding(s) from the latest checks.`); load(); });
  const importJira = () => { setMsg('Importing from Jira…'); fetch('/api/qa/findings/import-jira', { method: 'POST', credentials: 'include' }).then((r) => r.json()).then((d) => { setMsg(d.error ? `⚠ ${d.error}` : `Imported ${d.added || 0} of ${d.total || 0} ticket(s) under the QA Audit epic.`); load(); loadJira(); }); };
  const open = (f) => setForm(f ? { ...f } : { kind: 'bug', title: '', area: '', env: 'beta', detail: '', reproduceUrl: '', workingRefUrl: '' });
  const save = () => fetch('/api/qa/findings', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(form) }).then(() => { setForm(null); load(); });
  const setKind = (f, kind) => fetch('/api/qa/findings', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...f, kind }) }).then(load);
  const dismiss = (id) => fetch(`/api/qa/findings/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' }).then(load);
  const push = (id) => { setBusy(id); setMsg(null); fetch(`/api/qa/findings/${encodeURIComponent(id)}/push`, { method: 'POST', credentials: 'include' }).then((r) => r.json()).then((d) => { setMsg(d.error ? `⚠ Push failed: ${d.error}` : `✓ Created ${d.key}${d.warning ? ` (${d.warning})` : ''}`); }).finally(() => { setBusy(null); load(); loadJira(); }); };
  const inp = { width: '100%', border: '1px solid #e7ebf0', borderRadius: 8, padding: '8px 10px', fontSize: 13, fontFamily: 'inherit', marginTop: 4 };
  const lbl = { fontSize: 12, fontWeight: 600, color: '#0f172a', display: 'block' };
  const kindBadge = (k) => <span style={{ background: k === 'feature' ? '#eef2ff' : '#fee2e2', color: k === 'feature' ? '#4338ca' : '#b91c1c', padding: '2px 7px', borderRadius: 999, fontSize: 10.5, fontWeight: 700 }}>{k}</span>;
  const active = st.findings.filter((f) => f.status !== 'dismissed');
  // group by status: unpushed → "To review"; pushed → live Jira status
  const statusOf = (f) => (f.jiraKey ? (jira[f.id]?.status || f.jiraStatus || 'Unknown') : 'To review');
  const catOf = (f) => (f.jiraKey ? (jira[f.id]?.category || f.jiraStatusCategory || '') : 'review');
  const groups = {}; active.forEach((f) => { (groups[statusOf(f)] ||= []).push(f); });
  const ORDER = ['To review', 'To Do', 'Todo', 'Open', 'Backlog', 'Selected for Development', 'In Progress', 'In Review', 'In QA', 'Done', 'Closed', 'WONT DO', 'Unknown'];
  const groupNames = Object.keys(groups).sort((a, b) => { const ia = ORDER.indexOf(a), ib = ORDER.indexOf(b); return (ia < 0 ? 50 : ia) - (ib < 0 ? 50 : ib); });
  const catColor = (cat) => cat === 'done' ? ['#dcfce7', '#15803d'] : cat === 'indeterminate' ? ['#fef3c7', '#b45309'] : cat === 'review' ? ['#f5f3ff', '#6d28d9'] : ['#f1f5f9', '#475569'];
  const toggle = (g) => setCollapsed((c) => ({ ...c, [g]: !c[g] }));
  return (
    <div style={{ marginTop: 14 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
        <div style={{ fontWeight: 700, color: '#0f172a' }}>Issues to review <span style={{ fontWeight: 400, color: '#94a3b8', fontSize: 12.5 }}>· confirm each before it becomes a Jira ticket</span></div>
        <div style={{ display: 'flex', gap: 8 }}>
          <button className="mr-btn" onClick={importJira}>⤓ Import from Jira</button>
          <button className="mr-btn" onClick={sync}>↻ Sync from latest checks</button>
          <button className="mr-btn" onClick={() => open(null)}>+ Add finding</button>
        </div>
      </div>
      {msg ? <div style={{ marginBottom: 12, padding: '8px 12px', borderRadius: 8, fontSize: 13, background: msg.startsWith('⚠') ? '#fee2e2' : '#dcfce7', color: msg.startsWith('⚠') ? '#b91c1c' : '#15803d' }}>{msg}</div> : null}
      {form ? (
        <div style={{ background: '#fff', border: '1px solid #e7ebf0', borderRadius: 12, padding: 16, marginBottom: 14, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
          <label style={lbl}>Type<select style={inp} value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}><option value="bug">bug</option><option value="feature">feature / change</option></select></label>
          <label style={lbl}>Environment<select style={inp} value={form.env} onChange={(e) => setForm({ ...form, env: e.target.value })}><option value="design">design</option><option value="beta">beta</option><option value="prod">prod</option></select></label>
          <label style={{ ...lbl, gridColumn: '1 / -1' }}>Title<input style={inp} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} placeholder="[Comment Inspector] Version status labels missing" /></label>
          <label style={lbl}>Area<input style={inp} value={form.area} onChange={(e) => setForm({ ...form, area: e.target.value })} placeholder="Comments tab" /></label>
          <label style={lbl} />
          <label style={{ ...lbl, gridColumn: '1 / -1' }}>Detail<textarea style={{ ...inp, minHeight: 60 }} value={form.detail} onChange={(e) => setForm({ ...form, detail: e.target.value })} /></label>
          <label style={lbl}>Reproduce URL<input style={inp} value={form.reproduceUrl} onChange={(e) => setForm({ ...form, reproduceUrl: e.target.value })} placeholder="https://beta.staging.brolly.com.au/archive?..." /></label>
          <label style={lbl}>Working reference URL<input style={inp} value={form.workingRefUrl} onChange={(e) => setForm({ ...form, workingRefUrl: e.target.value })} placeholder="https://design.brolly.com.au/archive?..." /></label>
          <div style={{ gridColumn: '1 / -1', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
            <button className="mr-btn" onClick={() => setForm(null)}>Cancel</button>
            <button className="mr-btn" style={{ background: '#6d28d9', color: '#fff', borderColor: '#6d28d9' }} onClick={save} disabled={!form.title}>Save finding</button>
          </div>
        </div>
      ) : null}
      {/* counter bar */}
      {active.length ? (
        <div className="qa-counter">
          <span className="qa-cc"><b>{active.length}</b> total</span>
          {groupNames.map((g) => { const [bg, fg] = catColor(catOf(groups[g][0])); return <span key={g} className="qa-cc" style={{ background: bg, color: fg }} onClick={() => toggle(g)} title="Toggle group"><b>{groups[g].length}</b> {g}</span>; })}
        </div>
      ) : null}
      {st.loading ? <MrSkel rows={3} /> : (active.length ? (
        <table className="qa-tbl qa-itbl">
          <thead><tr><th style={{ width: 90 }}>Issue</th><th style={{ width: 70 }}>Type</th><th>Title</th><th style={{ width: 110 }}>Env / area</th><th style={{ width: 130 }}>Assignee</th><th style={{ width: 200 }}>Actions</th></tr></thead>
          {groupNames.map((g) => {
            const [bg, fg] = catColor(catOf(groups[g][0]));
            const isOpen = !collapsed[g];
            return (
              <tbody key={g}>
                <tr className="qa-grp" onClick={() => toggle(g)}><td colSpan={6}><span style={{ marginRight: 6 }}>{isOpen ? '▾' : '▸'}</span><span className="qa-gpill" style={{ background: bg, color: fg }}>{g}</span> <span className="qa-grp-n">{groups[g].length}</span></td></tr>
                {isOpen && groups[g].map((f) => (
                  <tr key={f.id}>
                    <td>{f.jiraKey ? <a href={f.jiraUrl} target="_blank" rel="noopener" style={{ fontWeight: 700, color: '#6d28d9' }}>{f.jiraKey} ↗</a> : <span style={{ color: '#cbd5e1' }}>—</span>}</td>
                    <td>{kindBadge(f.kind)}</td>
                    <td style={{ fontWeight: 600, color: '#0f172a', cursor: 'pointer' }} onClick={() => setDetail(f)}>{f.title} <span style={{ color: '#94a3b8', fontWeight: 400 }}>›</span>{f.detail ? <div style={{ fontSize: 11.5, color: '#94a3b8', fontWeight: 400 }}>{f.detail.slice(0, 100)}</div> : null}</td>
                    <td style={{ fontSize: 12, color: '#64748b' }}>{[f.env, f.area].filter(Boolean).join(' · ') || '—'}</td>
                    <td style={{ fontSize: 12.5, color: f.assignee ? '#0f172a' : '#cbd5e1' }}>{f.assignee || 'Unassigned'}</td>
                    <td>{f.jiraKey ? (
                      <a href={f.jiraUrl} target="_blank" rel="noopener" className="mr-btn" style={{ textDecoration: 'none' }}>Open in Jira</a>
                    ) : (
                      <span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
                        <select value={f.kind} onChange={(e) => setKind(f, e.target.value)} style={{ ...inp, width: 'auto', marginTop: 0, padding: '4px 6px' }} title="Correct the type before pushing"><option value="bug">bug</option><option value="feature">feature</option></select>
                        <button className="mr-btn" onClick={() => push(f.id)} disabled={busy === f.id} style={{ background: '#6d28d9', color: '#fff', borderColor: '#6d28d9' }}>{busy === f.id ? '…' : 'Push'}</button>
                        <button className="mr-btn" onClick={() => open(f)}>Edit</button>
                        <button className="mr-btn" onClick={() => dismiss(f.id)} title="Dismiss">✕</button>
                      </span>
                    )}</td>
                  </tr>
                ))}
              </tbody>
            );
          })}
        </table>
      ) : <div className="gw-empty-b" style={{ padding: '26px 0' }}>No issues yet. Click "Import from Jira" to pull existing tickets, run QA then "Sync from latest checks", or add one manually.</div>)}
      {detail ? <CCaseModal c={detail} onClose={() => setDetail(null)} /> : null}
    </div>
  );
}

// Settings — connect to Jira (config + API token + test). Token is write-only.
function CSettings() {
  const [cfg, setCfg] = useState(null);
  const [saving, setSaving] = useState(false);
  const [test, setTest] = useState(null); // { busy, ok, msg }
  const [types, setTypes] = useState(null); // available issue types from the project
  const inp = { width: '100%', border: '1px solid #e7ebf0', borderRadius: 8, padding: '9px 11px', fontSize: 13.5, fontFamily: 'inherit', marginTop: 5 };
  const lbl = { fontSize: 12, fontWeight: 600, color: '#0f172a', display: 'block', marginBottom: 2 };
  useEffect(() => { jget('/api/qa/jira/config').then((d) => setCfg({ ...d, token: '' })).catch(() => setCfg({ domain: '', email: '', projectKey: '', parentKey: '', issueType: 'Bug', tokenSet: false, token: '' })); }, []);
  if (!cfg) return <MrSkel rows={4} />;
  const set = (k, v) => setCfg({ ...cfg, [k]: v });
  const save = async () => {
    setSaving(true); setTest(null);
    const body = { ...cfg }; if (!body.token) delete body.token;
    try { const r = await fetch('/api/qa/jira/config', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const d = await r.json(); setCfg({ ...d, token: '' }); } catch (e) { setTest({ ok: false, msg: e.message }); } finally { setSaving(false); }
  };
  const doTest = async () => {
    setTest({ busy: true }); if (cfg.token) await save();
    try { const r = await fetch('/api/qa/jira/test', { method: 'POST', credentials: 'include' }); const d = await r.json(); setTest(d.ok ? { ok: true, msg: `Connected as ${d.user?.name || d.user?.email}${d.project?.key ? ` · project ${d.project.key} (${d.project.name})` : ''}${d.project?.error ? ` · ⚠ project: ${d.project.error}` : ''}` } : { ok: false, msg: d.error }); } catch (e) { setTest({ ok: false, msg: e.message }); }
  };
  const loadTypes = async () => {
    setTypes({ busy: true });
    if (cfg.token) await save();
    try { const d = await jget('/api/qa/jira/issuetypes'); setTypes({ list: d.types || [] }); } catch (e) { setTypes({ error: e.message }); }
  };
  return (
    <div style={{ marginTop: 14, maxWidth: 620 }}>
      <div style={{ background: '#fff', border: '1px solid #e7ebf0', borderRadius: 12, padding: '20px 22px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 4 }}>
          <div style={{ fontWeight: 700, fontSize: 15, color: '#0f172a' }}>Jira connection</div>
          <span style={{ background: cfg.tokenSet ? '#dcfce7' : '#fef3c7', color: cfg.tokenSet ? '#15803d' : '#b45309', padding: '2px 9px', borderRadius: 999, fontSize: 11, fontWeight: 700 }}>{cfg.tokenSet ? 'token set' : 'not connected'}</span>
        </div>
        <div style={{ fontSize: 12.5, color: '#64748b', marginBottom: 14 }}>Connect your Jira so confirmed issues can be pushed as tickets and retested when fixed. The API token is stored server-side and never shown again.</div>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
          <label style={lbl}>Jira domain<input style={inp} value={cfg.domain} onChange={(e) => set('domain', e.target.value)} placeholder="yourco.atlassian.net" /></label>
          <label style={lbl}>Account email<input style={inp} value={cfg.email} onChange={(e) => set('email', e.target.value)} placeholder="you@brolly.com.au" /></label>
          <label style={{ ...lbl, gridColumn: '1 / -1' }}>API token <span style={{ fontWeight: 400, color: '#94a3b8' }}>(id.atlassian.com → Security → API tokens)</span><input style={inp} type="password" value={cfg.token} onChange={(e) => set('token', e.target.value)} placeholder={cfg.tokenSet ? '•••••• (leave blank to keep)' : 'paste API token'} autoComplete="new-password" /></label>
          <label style={lbl}>Project key<input style={inp} value={cfg.projectKey} onChange={(e) => set('projectKey', e.target.value)} placeholder="BRO" /></label>
          <label style={lbl}>Parent (QA Audit epic)<input style={inp} value={cfg.parentKey} onChange={(e) => set('parentKey', e.target.value)} placeholder="BRO-3316" /></label>
          <label style={lbl}>Bug issue type<input style={inp} list="jira-types" value={cfg.bugIssueType} onChange={(e) => set('bugIssueType', e.target.value)} placeholder="Bug" /></label>
          <label style={lbl}>Feature / change issue type<input style={inp} list="jira-types" value={cfg.featureIssueType} onChange={(e) => set('featureIssueType', e.target.value)} placeholder="Story" /></label>
        </div>
        <datalist id="jira-types">{(types?.list || []).map((t) => <option key={t} value={t} />)}</datalist>
        <div style={{ marginTop: 8, fontSize: 12, color: '#64748b', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <button className="mr-btn" onClick={loadTypes} disabled={types?.busy || !cfg.projectKey}>{types?.busy ? 'Loading…' : 'Load types from project'}</button>
          {types?.list ? <span>Available: {types.list.join(', ') || '(none)'}</span> : null}
          {types?.error ? <span style={{ color: '#b91c1c' }}>⚠ {types.error}</span> : null}
          <span style={{ color: '#94a3b8' }}>Bugs push as the bug type; feature/change findings push as the feature type.</span>
        </div>
        {test ? <div style={{ marginTop: 14, padding: '9px 12px', borderRadius: 8, fontSize: 13, background: test.busy ? '#f1f5f9' : test.ok ? '#dcfce7' : '#fee2e2', color: test.busy ? '#475569' : test.ok ? '#15803d' : '#b91c1c' }}>{test.busy ? 'Testing connection…' : (test.ok ? '✓ ' : '⚠ ') + test.msg}</div> : null}
        <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
          <button className="mr-btn" onClick={doTest}>Test connection</button>
          <button className="mr-btn" style={{ background: '#6d28d9', color: '#fff', borderColor: '#6d28d9' }} onClick={save} disabled={saving || !cfg.domain || !cfg.email}>{saving ? 'Saving…' : 'Save'}</button>
        </div>
      </div>
      <div style={{ fontSize: 12, color: '#94a3b8', marginTop: 12 }}>Next: confirmed checker findings get pushed here as Bugs under the QA Audit parent, then retested automatically when the ticket is marked done.</div>
    </div>
  );
}

// Chat with Hermes (the QA coordinator). Text + image in/out, session continuity.
function CHermesChat() {
  const [msgs, setMsgs] = useState([{ role: 'assistant', text: "Hi — I'm **Hermes**, the QA coordinator. Describe a test to validate, paste a URL, or attach a screenshot. I know the design→beta→prod setup and the prod = data-only rule." }]);
  const [input, setInput] = useState('');
  const [img, setImg] = useState(null); // { base64, name, preview }
  const [busy, setBusy] = useState(false);
  const sidRef = React.useRef(null);
  if (!sidRef.current) { try { let s = localStorage.getItem('qa-hermes-session'); if (!s) { s = Math.random().toString(36).slice(2, 10); localStorage.setItem('qa-hermes-session', s); } sidRef.current = s; } catch { sidRef.current = 'sess'; } }
  const endRef = React.useRef(null);
  useEffect(() => { if (endRef.current) endRef.current.scrollIntoView({ behavior: 'smooth' }); }, [msgs, busy]);
  const fileToImg = (file) => { const rd = new FileReader(); rd.onload = () => { const res = rd.result; setImg({ base64: String(res).split(',')[1], name: file.name || 'image.png', preview: res }); }; rd.readAsDataURL(file); };
  const onPaste = (e) => { const it = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith('image/')); if (it) { const f = it.getAsFile(); if (f) { e.preventDefault(); fileToImg(f); } } };
  const send = async () => {
    if ((!input.trim() && !img) || busy) return;
    const userMsg = { role: 'user', text: input.trim(), image: img?.preview };
    const payloadImg = img;
    setMsgs((m) => [...m, userMsg]); setInput(''); setImg(null); setBusy(true);
    try {
      const r = await fetch('/api/qa/hermes/chat', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: userMsg.text, sessionId: sidRef.current, imageBase64: payloadImg?.base64, imageName: payloadImg?.name }) });
      const j = await r.json().catch(() => ({}));
      setMsgs((m) => [...m, { role: 'assistant', text: r.ok ? (j.reply || '(no reply)') : `⚠ ${j.error || r.status}` }]);
    } catch (e) { setMsgs((m) => [...m, { role: 'assistant', text: `⚠ ${e.message}` }]); }
    finally { setBusy(false); }
  };
  return (
    <div className="qa-chat">
      <div className="qa-chat-thread">
        {msgs.map((m, i) => (
          <div key={i} className={`qa-msg ${m.role}`}>
            <div className="qa-msg-av">{m.role === 'user' ? 'You' : 'H'}</div>
            <div className="qa-msg-body">
              {m.image ? <img className="qa-cimg" src={m.image} alt="attachment" /> : null}
              {m.text ? <div dangerouslySetInnerHTML={{ __html: mdToHtml(m.text) }} /> : null}
            </div>
          </div>
        ))}
        {busy ? <div className="qa-msg assistant"><div className="qa-msg-av">H</div><div className="qa-msg-body qa-typing">Hermes is thinking…</div></div> : null}
        <div ref={endRef} />
      </div>
      <div className="qa-chat-input">
        {img ? <div className="qa-attach">📎 {img.name} <button onClick={() => setImg(null)}>✕</button></div> : null}
        <div className="qa-chat-row">
          <label className="qa-attach-btn" title="Attach image">📎<input type="file" accept="image/*" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; if (f) fileToImg(f); e.target.value = ''; }} /></label>
          <textarea className="qa-chat-ta" rows={1} placeholder="Message Hermes — describe a test, paste a URL, or attach a screenshot…" value={input} onChange={(e) => setInput(e.target.value)} onPaste={onPaste} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }} />
          <button className="qa-send" onClick={send} disabled={busy || (!input.trim() && !img)}>➤</button>
        </div>
      </div>
    </div>
  );
}

function CQAApp({ user, onLogout }) {
  const [sec, setSec] = useState('overview');
  const { loading, data } = useDataFetch('/api/qa/results', []);
  const r = data || {}; const s = r.summary || {};
  const [run, setRun] = useState({ status: 'idle' });
  useEffect(() => {
    let stop = false;
    const tick = () => jget('/api/qa/runs/status').then((d) => { if (!stop) setRun(d); }).catch(() => {});
    tick(); const iv = setInterval(tick, 7000);
    return () => { stop = true; clearInterval(iv); };
  }, []);
  const requestRun = () => fetch('/api/qa/runs/request', { method: 'POST', credentials: 'include' }).then(() => jget('/api/qa/runs/status')).then(setRun).catch(() => {});
  const runBusy = run.status === 'pending' || run.status === 'running';
  const runLabel = run.status === 'pending' ? '⏳ Requested…' : run.status === 'running' ? '● Running…' : '▶ Run QA';
  const [caseModal, setCaseModal] = useState(null);
  const statusPill = (st) => { const m = { pass: ['#dcfce7', '#15803d'], fail: ['#fee2e2', '#b91c1c'], skip: ['#f1f5f9', '#64748b'], candidate: ['#dcfce7', '#16a34a'], 'needs-review': ['#fef3c7', '#b45309'], rejected: ['#fee2e2', '#b91c1c'] }[st] || ['#f1f5f9', '#475569']; return <span className="qa-pill" style={{ background: m[0], color: m[1] }}>{st}</span>; };
  const nav = (id, ic, label, ct) => <button className={`qa-nav ${sec === id ? 'on' : ''}`} onClick={() => setSec(id)}><span className="ic">{ic}</span>{label}{ct != null ? <span className="ct">{ct}</span> : null}</button>;
  return (
    <div className="qa-root">
      <style>{QA_CSS}</style>
      <aside className="qa-side">
        <div className="qa-brand"><span className="qa-logo">🧪</span> Brolly QA</div>
        <div className="qa-navsec">Agent</div>
        {nav('overview', '◧', 'Overview')}
        {nav('areas', '▦', 'App areas', s.areas || 0)}
        {nav('accounts', '👥', 'Accounts', s.accounts || 0)}
        {nav('cases', '✓', 'Checks', s.cases || 0)}
        {nav('issues', '🐞', 'Issues → Jira')}
        {nav('reports', '↻', 'Runs & reports')}
        {nav('chat', '💬', 'Hermes chat')}
        {nav('settings', '⚙', 'Settings')}
        <button className="qa-nav qa-back" onClick={onLogout}>⎋ Sign out{user ? ` (${user})` : ''}</button>
        <div className="qa-hermes"><span className="qa-pulse" /><b>Hermes</b> · coordinator<br /><span style={{ color: '#a5b4cb' }}>Runs are triggered by the nightly cron + post-deploy hook.</span></div>
      </aside>
      <main className="qa-main">
        <div className="qa-top">
          <div>
            <div className="qa-h1">{({ overview: 'QA Overview', areas: 'App areas', accounts: 'Accounts', cases: 'Checks', issues: 'Issues → Jira', reports: 'Runs & reports', chat: 'Hermes chat', settings: 'Settings' })[sec]}</div>
            <div className="qa-sub">{sec === 'chat' ? 'Chat with Hermes, the QA coordinator — share tests, images and URLs to validate' : sec === 'settings' ? 'Integrations and connections' : `Coordinated by Hermes · ${s.accounts || 0} accounts · ${s.cases || 0} test cases · ${s.runs || 0} runs`}</div>
          </div>
          {sec !== 'chat' && sec !== 'settings' ? <button className="qa-runbtn" title="Enqueue a QA run — the local worker (watch mode) picks it up and captures + checks all accounts" onClick={requestRun} disabled={runBusy}>{runLabel}</button> : null}
        </div>
        {loading && sec !== 'reports' && sec !== 'accounts' && sec !== 'chat' && sec !== 'settings' && sec !== 'issues' ? <MrSkel rows={4} /> : null}

        {sec === 'overview' && !loading ? (
          <div>
            <div className="qa-tiles">
              <div className="qa-tile"><div className="l">Accounts</div><div className="v">{s.accounts || 0}</div></div>
              <div className="qa-tile"><div className="l">App areas</div><div className="v">{s.areas || 0}</div></div>
              <div className="qa-tile"><div className="l">Checks</div><div className="v">{s.cases || 0}</div><div className="d"><span style={{ color: '#15803d' }}>{s.passed || 0} pass</span> · <span style={{ color: (s.failed ? '#b91c1c' : '#94a3b8') }}>{s.failed || 0} fail</span></div></div>
              <div className="qa-tile"><div className="l">Runs</div><div className="v">{s.runs || 0}</div><div className="d">captured</div></div>
            </div>
            <div className="qa-card">
              <h2>Recent runs</h2>
              {(r.runs || []).length ? (r.runs || []).slice(0, 8).map((run) => (
                <div className="qa-ev" key={run.id}><div className="qa-av">H</div><div style={{ flex: 1 }}><div><b>{run.area}</b> · {run.env} — {run.captured}/6 tabs</div><div style={{ color: '#94a3b8', fontSize: 11.5 }}>{run.ts} · {run.errors ? `${run.errors} warn` : 'clean'}</div></div></div>
              )) : <div className="qa-empty">No runs yet — the agent populates this once it runs.</div>}
              <div className="qa-note">Pass/fail, coverage and the trend chart appear once the <b>Phase 2 checker</b> turns candidate cases into verdicts. Until then these are grounded candidates + captured runs.</div>
            </div>
          </div>
        ) : null}

        {sec === 'areas' && !loading ? (
          (r.areas || []).length ? (r.areas || []).map((a) => (
            <div className="qa-row" key={a.name}><div style={{ flex: 1 }}><div style={{ fontWeight: 700, color: '#0f172a' }}>{a.name}</div><div style={{ marginTop: 6, display: 'flex', gap: 5, flexWrap: 'wrap' }}>{(a.tabs || []).map((t) => <span className="qa-chip" key={t}>{t}</span>)}</div></div><div style={{ textAlign: 'right', fontSize: 12.5, color: '#475569' }}>{a.cases} cases<br /><span style={{ color: a.needsReview ? '#b45309' : '#94a3b8' }}>{a.needsReview} need review</span></div></div>
          )) : <div className="qa-empty">No areas captured yet.</div>
        ) : null}

        {sec === 'accounts' ? <CQAAccountsAdmin /> : null}

        {sec === 'cases' && !loading ? (
          (r.cases || []).length ? (
            <table className="qa-tbl"><thead><tr><th>Area</th><th>Env</th><th>Test case</th><th>Status</th></tr></thead>
              <tbody>{(r.cases || []).map((c) => <tr key={c.id} style={{ cursor: 'pointer' }} onClick={() => setCaseModal(c)}><td>{c.area}</td><td>{c.env}{c.kind ? ` · ${c.kind}` : ''}</td><td style={{ fontWeight: 600, color: '#0f172a' }}>{c.title} <span style={{ color: '#94a3b8', fontWeight: 400 }}>›</span></td><td>{statusPill(c.status)}</td></tr>)}</tbody>
            </table>
          ) : <div className="qa-empty">No test cases yet — run QA to generate them.</div>
        ) : null}

        {sec === 'issues' ? <CFindings /> : null}
        {sec === 'reports' ? <CQAReports /> : null}
        {sec === 'chat' ? <CHermesChat /> : null}
        {sec === 'settings' ? <CSettings /> : null}
      </main>
      {caseModal ? <CCaseModal c={caseModal} onClose={() => setCaseModal(null)} statusPill={statusPill} /> : null}
    </div>
  );
}

// Detail modal for a test case OR a finding — laid out like the bug report:
// title + severity/Jira chips, Reproduce link, prominent screenshot, then story/steps/expected.
function CCaseModal({ c, onClose, statusPill }) {
  const shot = c.shot || c.screenshot;
  const repro = c.reproduceUrl || c.recordUrl;
  const reproLink = repro && /https?:\/\//.test(repro) ? repro : null;
  const sev = c.severity ? String(c.severity) : null;
  return (
    <div className="qa-modal-wrap" onClick={onClose}>
      <div className="qa-modal" onClick={(e) => e.stopPropagation()}>
        <div className="qa-modal-head">
          <div style={{ flex: 1 }}>
            <div className="qa-modal-sub" style={{ marginBottom: 4 }}>{c.area}{c.tab && !String(c.area || '').includes('·') ? ` · ${c.tab}` : ''}{c.env ? ` · ${c.env}` : ''}{c.kind ? ` · ${c.kind}` : ''}</div>
            <div className="qa-modal-title">{c.title}</div>
            <div className="qa-chiprow">
              {sev ? <span className={`qa-sev qa-sev-${sev.toLowerCase()}`}>{sev}</span> : null}
              {statusPill && ['pass', 'fail', 'skip'].includes(c.status) ? statusPill(c.status) : null}
              {c.jiraKey ? <a className="qa-jchip" href={c.jiraUrl || '#'} target="_blank" rel="noopener">{c.jiraKey}{c.jiraStatus ? ` · ${c.jiraStatus}` : ''} ↗</a> : null}
              {c.klass === 'regression' ? <span className="qa-tag qa-tag-reg">regression</span> : c.klass === 'enhancement' ? <span className="qa-tag">enhancement</span> : null}
              {c.change ? <span className="qa-tag">{c.change}</span> : null}
            </div>
          </div>
          <button className="mr-btn" onClick={onClose}>✕</button>
        </div>
        <div className="qa-modal-body">
          {reproLink ? <div className="qa-repro">Reproduce → <a href={reproLink} target="_blank" rel="noopener">{reproLink}</a></div> : null}
          {shot ? <a href={shot} target="_blank" rel="noopener"><img className="qa-shot-lg" src={shot} alt="issue screenshot" /></a> : null}
          {c.detail ? <div className={`qa-verdict ${c.status === 'fail' ? 'bad' : c.status === 'pass' ? 'good' : ''}`}><b>{['pass', 'fail', 'skip'].includes(c.status) ? (c.status === 'pass' ? '✓ Actual' : c.status === 'fail' ? '✗ Actual' : 'Detail') : 'Detail'}:</b> {c.detail}</div> : null}
          {c.story ? <div className="qa-ms"><div className="qa-ms-h">User story</div><div>{c.story}</div></div> : null}
          {c.steps?.length ? <div className="qa-ms"><div className="qa-ms-h">Steps the agent runs</div><ol className="qa-steps">{c.steps.map((s, i) => <li key={i}>{s}</li>)}</ol></div> : null}
          {c.expect ? <div className="qa-ms"><div className="qa-ms-h">Expected</div><div>{c.expect}</div></div> : null}
          {c.assignee || c.account ? <div className="qa-ms"><div className="qa-ms-h">{c.assignee ? 'Assignee' : 'Account'}</div><div>{c.assignee || c.account}</div></div> : null}
          {c.workingRefUrl ? <div className="qa-ms"><div className="qa-ms-h">Working reference</div><a href={c.workingRefUrl} target="_blank" rel="noopener">{c.workingRefUrl} ↗</a></div> : null}
        </div>
      </div>
    </div>
  );
}

/* ---------- bootstrap: gate on /api/me ---------- */
function Root() {
  const [auth, setAuth] = useState({ loading: true, user: null });
  const check = () => jget('/api/me').then((d) => setAuth({ loading: false, user: d.user })).catch(() => setAuth({ loading: false, user: null }));
  useEffect(() => { check(); }, []);
  const logout = () => fetch('/logout', { method: 'POST', credentials: 'include' }).then(() => setAuth({ loading: false, user: null }));
  if (auth.loading) return <div style={{ position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#64748b' }}>Loading…</div>;
  if (!auth.user) return <Login onAuthed={check} />;
  return <CQAApp user={auth.user} onLogout={logout} />;
}

ReactDOM.createRoot(document.getElementById('root')).render(<Root />);
