// dashboard/js/qa.js — QA tab UI.
//
// Originally W-mpeiwz6k0005bf34-d shipped this as a three-section tab with
// inline forms for "Start QA Session" and "+ New runbook". W-mpxp7a3e000g0a49
// (this rewrite) demoted the page to passive monitoring: session and runbook
// CREATION now flow through Command Center; this page only renders what is
// happening and exposes per-row operational controls.
//
// Sections (top → bottom):
//  1. Active QA Sessions — non-terminal sessions only, with a CC-pitch card
//     at the top and a collapsed disclosure for terminal sessions (last 10).
//     Per-card controls: approve / edit / cancel / dismiss / kill.
//  2. Live Targets — slim dedup'd list of /api/managed-processes +
//     /api/keep-processes with a health badge and a link to /engine
//     (W-mpdad3mq000m53bb — do NOT mirror the full inventory here).
//  3. Recent Runs — list from GET /api/qa/runs?limit=20, polled every 5s
//     while the QA page is active. Each row links to the live agent stream
//     and renders inline artifact previews via /api/qa/artifacts/<runId>/<file>
//     (screenshots as <img>, videos as <video>, logs as 40-line text preview
//     with a "View full" link). No direct filesystem paths are exposed —
//     artifact URLs always go through /api/qa/artifacts/.
//  4. Validation Runbooks — collapsed disclosure with a small CC-pitch and
//     the runbook list. Each row has a Run + Delete button.
//
// The polling interval is cleared on page navigation via the PAGE_LEAVE_HOOKS
// in state.js (W-mpgb0xa7000d90d4). closeManagedLog is invoked from the same
// hook so a modal opened from /engine doesn't keep streaming after the user
// navigates.

let _qaRunsPollInterval = null;
let _qaTargetsCache = [];
let _qaRunbooksCache = [];

function _stopQaRunsPoll() {
  if (_qaRunsPollInterval) {
    clearInterval(_qaRunsPollInterval);
    _qaRunsPollInterval = null;
  }
}

function _startQaRunsPoll() {
  _stopQaRunsPoll();
  // Initial fetch + 5s poll (per acceptance criteria). The poll is bounded
  // to the QA page via switchPage cleanup below.
  loadQaRuns();
  _qaRunsPollInterval = setInterval(loadQaRuns, 5000);
}

// W-mpxp7a3e000g0a49 — open Command Center drawer + focus the composer.
// toggleCommandCenter() flips _ccOpen, so re-invoking when already open
// would close it; guard against that and just focus the input instead.
function qaFocusCommandCenter() {
  try {
    const alreadyOpen = (typeof _ccOpen !== 'undefined') ? !!_ccOpen
      : ((typeof window !== 'undefined') && !!window._ccOpen);
    if (!alreadyOpen && typeof toggleCommandCenter === 'function') {
      toggleCommandCenter();
      return; // toggleCommandCenter focuses #cc-input on open
    }
    const input = document.getElementById('cc-input');
    if (input) input.focus();
  } catch (_e) { /* never let a focus helper crash the page */ }
}

// ── Section 1: Targets ─────────────────────────────────────────────────────
async function loadQaTargets() {
  const root = document.getElementById('qa-targets-content');
  if (!root) return;
  let managed = [];
  let keep = [];
  let err = null;
  try {
    const [mRes, kRes] = await Promise.all([
      fetch('/api/managed-processes').then(r => r.ok ? r.json() : { items: [] }).catch(() => ({ items: [] })),
      fetch('/api/keep-processes').then(r => r.ok ? r.json() : { items: [] }).catch(() => ({ items: [] })),
    ]);
    managed = Array.isArray(mRes && mRes.items) ? mRes.items : [];
    keep = Array.isArray(kRes && kRes.items) ? kRes.items : [];
  } catch (e) { err = e; }

  if (err) {
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: err.message)
    const frag = document.createRange().createContextualFragment(
      '<p class="empty" style="color:var(--red)">Failed to load targets: ' + escHtml(err.message || String(err)) + '</p>'
    );
    root.replaceChildren(frag);
    _qaTargetsCache = [];
    _qaPopulateTargetDropdown();
    return;
  }

  // Dedup by name+project: a managed spec and a keep_processes entry can
  // declare the same name (managed is canonical because the engine owns the
  // lifecycle). Build a Set keyed by `<project>::<name>` and skip keep entries
  // whose name is already represented in managed.
  const seen = new Set();
  const targets = [];
  for (const m of managed) {
    if (!m || !m.name) continue;
    const key = (m.owner_project || '') + '::' + m.name;
    if (seen.has(key)) continue;
    seen.add(key);
    targets.push({
      name: m.name,
      project: m.owner_project || '',
      source: 'managed',
      healthy: !!m.healthy,
      alive: !!m.alive,
      ports: Array.isArray(m.ports) ? m.ports.slice() : [],
    });
  }
  for (const k of keep) {
    if (!k || !k.valid) continue;
    // Keep-processes entries are keyed by agent — use the agent's purpose
    // as a friendly name fallback when no explicit name is present.
    const name = k.name || k.purpose || k.agentId || '';
    if (!name) continue;
    const project = k.project || '';
    const key = project + '::' + name;
    if (seen.has(key)) continue;
    seen.add(key);
    const anyAlive = Array.isArray(k.pids) && k.pids.some(p => p && p.alive);
    targets.push({
      name,
      project,
      source: 'keep',
      healthy: anyAlive,
      alive: anyAlive,
      ports: Array.isArray(k.ports) ? k.ports.slice() : [],
    });
  }

  _qaTargetsCache = targets;
  _qaPopulateTargetDropdown();

  if (!targets.length) {
    const frag = document.createRange().createContextualFragment(
      '<p class="empty">No live targets. Spawn a service via <code>meta.managed_spawn</code> or <code>meta.keep_processes</code> to populate this list. Full inventory on <a href="/engine">/engine</a>.</p>'
    );
    root.replaceChildren(frag);
    return;
  }

  const rows = targets.map(function (t) {
    const badge = t.healthy
      ? '<span class="qa-health-dot qa-health-ok">●</span> healthy'
      : (t.alive ? '<span class="qa-health-dot qa-health-warn">●</span> starting' : '<span class="qa-health-dot qa-health-down">○</span> down');
    const portsTxt = (t.ports && t.ports.length) ? ' ports: ' + t.ports.join(',') : '';
    const projTxt = t.project ? ' <span class="qa-target-project">(' + escHtml(t.project) + ')</span>' : '';
    const srcTxt = '<span class="qa-target-source">' + escHtml(t.source) + '</span>';
    return '<div class="qa-target-row">' +
      '<div class="qa-target-main"><span class="qa-target-name">' + escHtml(t.name) + '</span>' + projTxt + ' ' + srcTxt + '</div>' +
      '<div class="qa-target-meta">' + badge + escHtml(portsTxt) + '</div>' +
      '<a href="/engine" class="qa-target-link">View on /engine</a>' +
      '</div>';
  }).join('');
  // Use createContextualFragment + replaceChildren to keep this file out of
  // the dynamic-innerHTML regression gate (cf. test/unit.test.js
  // DYNAMIC_INNERHTML_BASELINE — all interpolated fields above are wrapped
  // in escHtml()).
  // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: target name, project, source, ports)
  const frag = document.createRange().createContextualFragment(rows);
  root.replaceChildren(frag);
}

function _qaPopulateTargetDropdown() {
  const sel = document.getElementById('qa-runbook-target');
  if (!sel) return;
  const current = sel.value;
  // Build option nodes via DOM API — no innerHTML.
  while (sel.firstChild) sel.removeChild(sel.firstChild);
  const placeholder = document.createElement('option');
  placeholder.value = '';
  placeholder.textContent = '— select a target —';
  sel.appendChild(placeholder);
  for (const t of _qaTargetsCache) {
    const opt = document.createElement('option');
    opt.value = t.name;
    const projLabel = t.project ? ' (' + t.project + ')' : '';
    opt.textContent = t.name + projLabel + ' [' + t.source + ']';
    sel.appendChild(opt);
  }
  if (current) sel.value = current;
}

// ── Section 2: Runbooks ────────────────────────────────────────────────────
async function loadQaRunbooks() {
  const root = document.getElementById('qa-runbooks-content');
  if (!root) return;
  let items = [];
  let err = null;
  try {
    const res = await fetch('/api/qa/runbooks');
    if (res.ok) {
      const data = await res.json();
      items = Array.isArray(data && data.items) ? data.items : (Array.isArray(data) ? data : []);
    } else if (res.status !== 404) {
      err = new Error('HTTP ' + res.status);
    }
  } catch (e) { err = e; }
  _qaRunbooksCache = items;
  _qaUpdateRunbooksDisclosureCount(items.length);

  if (err) {
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: err.message)
    const frag = document.createRange().createContextualFragment(
      '<p class="empty" style="color:var(--red)">Failed to load runbooks: ' + escHtml(err.message || String(err)) + '</p>'
    );
    root.replaceChildren(frag);
    return;
  }
  if (!items.length) {
    const frag = document.createRange().createContextualFragment(
      '<p class="empty">No runbooks saved yet.</p>'
    );
    root.replaceChildren(frag);
    return;
  }
  const rows = items.map(function (rb) {
    const id = rb.id || rb.name || '';
    const stepsCount = Array.isArray(rb.steps) ? rb.steps.length : (rb.steps ? String(rb.steps).split(/\r?\n/).filter(Boolean).length : 0);
    const artifactsCount = Array.isArray(rb.expectedArtifacts) ? rb.expectedArtifacts.length : 0;
    return '<div class="qa-runbook-row">' +
      '<div class="qa-runbook-main">' +
        '<div class="qa-runbook-name">' + escHtml(rb.name || id) + '</div>' +
        '<div class="qa-runbook-meta">target: <code>' + escHtml(rb.target || '?') + '</code> · ' +
          stepsCount + ' step' + (stepsCount === 1 ? '' : 's') + ' · ' +
          artifactsCount + ' expected artifact' + (artifactsCount === 1 ? '' : 's') +
        '</div>' +
      '</div>' +
      '<div class="qa-runbook-actions" style="display:flex;gap:var(--space-2)">' +
        '<button class="btn-primary-solid qa-runbook-run-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaRunRunbook(this.dataset.runbookId)">Run</button>' +
        '<button class="btn-ghost qa-btn-danger qa-runbook-delete-btn" data-runbook-id="' + escHtml(id) + '" onclick="qaDeleteRunbook(this.dataset.runbookId)">Delete</button>' +
      '</div>' +
      '</div>';
  }).join('');
  // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runbook id, name, target)
  const frag = document.createRange().createContextualFragment(rows);
  root.replaceChildren(frag);
}

// W-mpxp7a3e000g0a49 — keep the "Show saved runbooks (N)" disclosure summary
// in sync with the runbook count after every load.
function _qaUpdateRunbooksDisclosureCount(n) {
  const sum = document.getElementById('qa-runbooks-disclosure-summary');
  if (sum) sum.textContent = 'Show saved runbooks (' + n + ')';
}

async function qaDeleteRunbook(id) {
  if (!id) return;
  const rb = _qaRunbooksCache.find(function (r) { return (r.id || r.name) === id; });
  const label = rb ? (rb.name || id) : id;
  if (!confirm('Delete runbook "' + label + '"? This cannot be undone.')) return;
  try {
    const res = await fetch('/api/qa/runbooks/' + encodeURIComponent(id), { method: 'DELETE' });
    if (!res.ok) {
      const txt = await res.text().catch(() => '');
      throw new Error('HTTP ' + res.status + (txt ? ': ' + txt.slice(0, 200) : ''));
    }
    loadQaRunbooks();
  } catch (e) {
    alert('Failed to delete runbook "' + label + '": ' + (e.message || String(e)));
  }
}

async function qaRunRunbook(id) {
  if (!id) return;
  const rb = _qaRunbooksCache.find(function (r) { return (r.id || r.name) === id; });
  const label = rb ? (rb.name || id) : id;
  if (!confirm('Dispatch a validation agent for runbook "' + label + '"?')) return;
  try {
    const res = await fetch('/api/qa/runbooks/run', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id }),
    });
    if (!res.ok) {
      const txt = await res.text().catch(() => '');
      throw new Error('HTTP ' + res.status + (txt ? ': ' + txt.slice(0, 200) : ''));
    }
    // Reload runs immediately so the new dispatch shows up in section 3.
    loadQaRuns();
  } catch (e) {
    alert('Failed to dispatch runbook "' + label + '": ' + (e.message || String(e)));
  }
}

// ── Section 3: Runs ─────────────────────────────────────────────────────────
async function loadQaRuns() {
  const root = document.getElementById('qa-runs-content');
  if (!root) return;
  let items = [];
  let err = null;
  try {
    const res = await fetch('/api/qa/runs?limit=20');
    if (res.ok) {
      const data = await res.json();
      items = Array.isArray(data && data.items) ? data.items : (Array.isArray(data) ? data : []);
    } else if (res.status !== 404) {
      err = new Error('HTTP ' + res.status);
    }
  } catch (e) { err = e; }

  if (err) {
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: err.message)
    const frag = document.createRange().createContextualFragment(
      '<p class="empty" style="color:var(--red)">Failed to load runs: ' + escHtml(err.message || String(err)) + '</p>'
    );
    root.replaceChildren(frag);
    return;
  }
  if (!items.length) {
    const frag = document.createRange().createContextualFragment(
      '<p class="empty">No runs yet. Dispatch a runbook above to see results here.</p>'
    );
    root.replaceChildren(frag);
    return;
  }
  const rows = items.map(_qaRenderRunRow).join('');
  // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: run id, status, runbook, target, timestamp, workItemId, agentId, artifact file names)
  const frag = document.createRange().createContextualFragment(rows);
  root.replaceChildren(frag);
}

function _qaRenderRunRow(run) {
  const id = run.id || run.runId || '';
  const status = run.status || 'unknown';
  const statusClass = 'qa-status-' + status.toLowerCase().replace(/[^a-z0-9-]/g, '-');
  const runbook = run.runbookName || run.runbook || '';
  const target = run.target || '';
  const ts = run.startedAt || run.createdAt || '';
  const workItemId = run.workItemId || run.workItem || '';
  const agentId = run.agentId || '';
  // Live-stream link to /agent/<workItemId> per the acceptance contract;
  // routes through openAgentDetail when available so the existing dashboard
  // detail panel handles the live tail. Falls back to a plain anchor when
  // the page is loaded standalone (e.g. snapshot test).
  const linkLabel = workItemId ? 'View live agent →' : (agentId ? 'View live agent →' : '');
  let liveLink = '';
  if (workItemId) {
    liveLink = '<a href="/agent/' + encodeURIComponent(workItemId) + '" class="qa-run-link" ' +
      'onclick="event.preventDefault();qaOpenRunAgent(this.dataset.wi,this.dataset.agent);" ' +
      'data-wi="' + escHtml(workItemId) + '" data-agent="' + escHtml(agentId) + '">' + escHtml(linkLabel) + '</a>';
  } else if (agentId) {
    liveLink = '<a href="#" class="qa-run-link" onclick="event.preventDefault();qaOpenRunAgent(\'\',this.dataset.agent);" data-agent="' + escHtml(agentId) + '">' + escHtml(linkLabel) + '</a>';
  }
  // W-mpxp90xs000i5732 — per-row Delete button, terminal-only. The engine
  // also enforces terminal-only with a 409 (defense in depth); rendering
  // the button conditionally just keeps the UI honest about what's safe
  // to click.
  let deleteBtn = '';
  if (_qaIsTerminalStatus(status)) {
    deleteBtn = '<button class="btn-ghost qa-run-delete-btn" ' +
      'data-run-id="' + escHtml(id) + '" ' +
      'onclick="qaDeleteRun(this.dataset.runId)">Delete</button>';
  }
  const actionsHtml = (liveLink || deleteBtn)
    ? '<span class="qa-run-actions">' + liveLink + deleteBtn + '</span>'
    : '';
  const artifactsHtml = _qaRenderArtifactPreviews(id, run.artifacts || []);
  return '<div class="qa-run-row" data-run-id="' + escHtml(id) + '">' +
    '<div class="qa-run-head">' +
      '<span class="qa-run-status ' + escHtml(statusClass) + '">' + escHtml(status) + '</span>' +
      '<span class="qa-run-name">' + escHtml(runbook) + '</span>' +
      (target ? '<span class="qa-run-target">→ <code>' + escHtml(target) + '</code></span>' : '') +
      (ts ? '<span class="qa-run-ts">' + escHtml(String(ts).slice(0, 19).replace('T', ' ')) + '</span>' : '') +
      actionsHtml +
    '</div>' +
    (artifactsHtml ? '<div class="qa-run-artifacts">' + artifactsHtml + '</div>' : '') +
    '</div>';
}

// W-mpxp90xs000i5732 — terminal status set kept in sync with
// engine/qa-runs.js#TERMINAL_STATUSES. The Delete button only renders for
// runs in one of these states; the engine's deleteQaRun returns 409 for
// anything else (defense in depth).
function _qaIsTerminalStatus(status) {
  if (!status || typeof status !== 'string') return false;
  const s = status.toLowerCase();
  return s === 'passed' || s === 'failed' || s === 'errored';
}

// W-mpxp90xs000i5732 — per-row Delete handler. Confirms with the user,
// fires DELETE /api/qa/runs/<id>, optimistic-removes the row on 200, and
// surfaces errors via showToast(). Does NOT use alert() post-API per the
// WI's UX contract.
async function qaDeleteRun(id) {
  if (!id) return;
  if (!confirm('Delete QA run ' + id + ' and its artifacts? This cannot be undone.')) return;
  let res, json;
  try {
    res = await fetch('/api/qa/runs/' + encodeURIComponent(id), {
      method: 'DELETE',
    });
  } catch (e) {
    if (typeof showToast === 'function') {
      showToast('cmd-toast', 'Delete QA run failed: ' + (e && e.message || String(e)), false);
    }
    return;
  }
  try { json = await res.json(); } catch { json = {}; }
  if (res.ok && json && json.ok) {
    const sel = (window.CSS && CSS.escape ? CSS.escape(id) : id.replace(/"/g, '\\"'));
    const row = document.querySelector('.qa-run-row[data-run-id="' + sel + '"]');
    if (row && row.parentNode) row.parentNode.removeChild(row);
    if (typeof loadQaRuns === 'function') loadQaRuns();
    if (typeof showToast === 'function') {
      const msg = json.artifactsRemoved
        ? 'QA run deleted (artifacts removed).'
        : 'QA run deleted.';
      showToast('cmd-toast', msg, true);
    }
    return;
  }
  const errMsg = (json && (json.error + (json.currentStatus ? ' (status=' + json.currentStatus + ')' : '')))
    || ('HTTP ' + (res ? res.status : '?'));
  if (typeof showToast === 'function') {
    showToast('cmd-toast', 'Delete QA run failed: ' + errMsg, false);
  }
}

function _qaRenderArtifactPreviews(runId, artifacts) {
  if (!runId || !Array.isArray(artifacts) || !artifacts.length) return '';
  const parts = [];
  for (const a of artifacts) {
    // Accept either a string filename or { file, kind } shape. Strip any
    // path separators so a malicious entry can't escape the /api/qa/artifacts/
    // namespace — the server is canonical here, but client-side normalization
    // keeps the URL pattern observable in source-inspection tests.
    const fileRaw = (typeof a === 'string') ? a : (a && (a.file || a.name || a.path) || '');
    if (!fileRaw) continue;
    const file = String(fileRaw).split(/[\\/]/).pop();
    if (!file) continue;
    const kindHint = (typeof a === 'object' && a && a.kind) ? String(a.kind).toLowerCase() : '';
    const ext = (file.match(/\.([a-z0-9]+)$/i) || ['', ''])[1].toLowerCase();
    const url = '/api/qa/artifacts/' + encodeURIComponent(runId) + '/' + encodeURIComponent(file);
    if (kindHint === 'screenshot' || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
      parts.push(
        '<div class="qa-artifact qa-artifact-image">' +
          '<img src="' + escHtml(url) + '" alt="' + escHtml(file) + '" loading="lazy">' +
          '<div class="qa-artifact-caption">' + escHtml(file) + '</div>' +
        '</div>'
      );
    } else if (kindHint === 'video' || ['mp4', 'webm', 'ogg', 'mov'].includes(ext)) {
      parts.push(
        '<div class="qa-artifact qa-artifact-video">' +
          '<video controls src="' + escHtml(url) + '"></video>' +
          '<div class="qa-artifact-caption">' + escHtml(file) + '</div>' +
        '</div>'
      );
    } else {
      // Log / text — render an asynchronously-filled <pre>; first 40 lines
      // fetched lazily so we don't N+1-flood the network on every poll.
      const blockId = 'qa-log-' + runId + '-' + file.replace(/[^a-zA-Z0-9_-]/g, '_');
      parts.push(
        '<div class="qa-artifact qa-artifact-log">' +
          '<div class="qa-artifact-caption">' +
            escHtml(file) + ' ' +
            '<a href="' + escHtml(url) + '" target="_blank" rel="noopener" class="qa-artifact-fulllink">View full</a>' +
          '</div>' +
          '<pre id="' + escHtml(blockId) + '" class="qa-artifact-log-preview" data-qa-log-url="' + escHtml(url) + '">Loading…</pre>' +
        '</div>'
      );
    }
  }
  return parts.join('');
}

// Lazily fetch log previews after the runs DOM is in place. Runs every time
// loadQaRuns finishes; cheap because the `data-qa-log-url` lookup short-
// circuits on already-loaded blocks.
function _qaFillLogPreviews() {
  const blocks = document.querySelectorAll('#qa-runs-content .qa-artifact-log-preview[data-qa-log-url]');
  blocks.forEach(function (el) {
    const url = el.getAttribute('data-qa-log-url');
    if (!url || el.__qaLoaded) return;
    el.__qaLoaded = true;
    fetch(url)
      .then(function (r) { return r.ok ? r.text() : ''; })
      .then(function (txt) {
        const head = (txt || '').split(/\r?\n/).slice(0, 40).join('\n');
        el.textContent = head || '(empty)';
      })
      .catch(function () { el.textContent = '(failed to load)'; });
  });
}

function qaOpenRunAgent(workItemId, agentId) {
  // Prefer the agent live-detail panel if available; fall back to the
  // work-item detail; fall back to a hard navigation as a last resort.
  if (agentId && typeof openAgentDetail === 'function') { try { openAgentDetail(agentId); return; } catch {} }
  if (workItemId && typeof openWorkItemDetail === 'function') { try { openWorkItemDetail(workItemId); return; } catch {} }
  if (workItemId) location.href = '/agent/' + encodeURIComponent(workItemId);
}

// ── Page-navigation hooks ──────────────────────────────────────────────────
// W-mpgb0xa7000d90d4 — the per-tab switchPage monkey-patch this file used to
// install (__qaWrapped wrapper that called closeManagedLog + _stopQaRunsPoll
// on every navigation and kicked off loadQaTargets / loadQaRunbooks /
// _startQaRunsPoll on enter) has been generalized into one canonical
// lifecycle. See `PAGE_LAZY_LOADERS` + `PAGE_LEAVE_HOOKS` in state.js:
//   - QA enter hooks live in PAGE_LAZY_LOADERS.qa
//   - closeManagedLog + _stopQaRunsPoll live in PAGE_LEAVE_HOOKS (fire on
//     every page transition — both are no-ops when nothing is active)
// The loader / stop functions themselves stay co-located with the QA UI here
// and are resolved by name through window[name] at switchPage time, so this
// file no longer needs to wrap switchPage.

// Refresh log previews after every runs render — _qaFillLogPreviews is a
// no-op for already-loaded blocks, so polling repeatedly is safe.
(function () {
  const _origLoad = loadQaRuns;
  window.loadQaRuns = async function () {
    const ret = await _origLoad.apply(this, arguments);
    try { _qaFillLogPreviews(); } catch {}
    return ret;
  };
})();

// If the user landed directly on /qa (deep-link), the switchPage wrapper
// above runs after refresh.js calls switchPage(currentPage) — but only if
// qa.js was already evaluated. Guard with currentPage so we initialize
// targets/runbooks/runs immediately on first paint.
(function () {
  try {
    if (typeof currentPage !== 'undefined' && currentPage === 'qa') {
      loadQaTargets();
      loadQaRunbooks();
      loadQaRunners();
      loadQaProjectsSelect();
      loadQaSessions();
      _startQaSessionsPoll();
      _startQaRunsPoll();
    }
  } catch {}
})();

// ── P-h7e4f9b2: QA Sessions section ───────────────────────────────────────
//
// Three sub-UIs that mirror the Targets / Runbooks / Runs trio above:
//   1. "Start QA Session" form posts to POST /api/qa/session.
//   2. Sessions list (composite cards with phase chips) polled every 3s
//      while any session is non-terminal; clearInterval once all sessions
//      are terminal (mirrors _stopPlanPoll's "auto-stop on terminal" rule).
//   3. Runner dropdown auto-loaded from GET /api/qa/runners.
//
// All renderer HTML escapes user-supplied values with escHtml. The five
// session actions (approve / edit / cancel / dismiss / kill) call their
// matching /api/qa/sessions/<id>/<action> endpoint and refresh the card in
// place via a follow-up loadQaSessions().

const QA_SESSION_TERMINAL_STATES = new Set(['done', 'failed', 'killed']);

let _qaSessionsPollInterval = null;
let _qaSessionsCache = [];
let _qaRunnersCache = [];

function _stopQaSessionsPoll() {
  if (_qaSessionsPollInterval) {
    clearInterval(_qaSessionsPollInterval);
    _qaSessionsPollInterval = null;
  }
}

function _startQaSessionsPoll() {
  _stopQaSessionsPoll();
  // Initial fetch + 3s poll per acceptance criteria. The poll is auto-
  // stopped once every session in _qaSessionsCache is terminal (see
  // _qaAfterSessionsRender). The PAGE_LEAVE_HOOKS still call
  // _stopQaSessionsPoll on every switchPage, so leaving the page is a
  // separate stop trigger.
  loadQaSessions();
  _qaSessionsPollInterval = setInterval(loadQaSessions, 3000);
}

// W-mpxp7a3e000g0a49 — qaSessionTargetKindChanged removed; the inline
// "Start QA Session" form has moved to Command Center. loadQaRunners and
// loadQaProjectsSelect below are no-ops on this page (their target DOM
// elements were removed with the form) but remain exported so the
// PAGE_LAZY_LOADERS registry in state.js can still reference them by
// name without throwing.

async function loadQaRunners() {
  // Form removed (W-mpxp7a3e000g0a49) — dropdown no longer exists, so this
  // early-returns. Kept exported because PAGE_LAZY_LOADERS.qa references it.
  const sel = document.getElementById('qa-session-runner');
  if (!sel) return;
  try {
    const res = await fetch('/api/qa/runners');
    const json = res.ok ? await res.json() : { runners: [] };
    _qaRunnersCache = Array.isArray(json.runners) ? json.runners : [];
  } catch { _qaRunnersCache = []; }
  // Rebuild via DOM API so we don't touch innerHTML at all.
  while (sel.firstChild) sel.removeChild(sel.firstChild);
  const auto = document.createElement('option');
  auto.value = '';
  auto.textContent = 'Auto-detect';
  sel.appendChild(auto);
  for (const r of _qaRunnersCache) {
    if (!r || !r.name) continue;
    const opt = document.createElement('option');
    opt.value = r.name;
    opt.textContent = (r.label || r.name) + ' (priority ' + (r.priority != null ? r.priority : '?') + ')';
    sel.appendChild(opt);
  }
}

// W-mpq6xqzj000606d0 — Populate the multi-select projects dropdown on the
// QA Session form. Prefers the in-memory `cmdProjects` cache (populated by
// /api/status renders), falling back to a direct /api/status fetch when the
// QA page is opened in isolation before any other page has loaded.
async function loadQaProjectsSelect() {
  const sel = document.getElementById('qa-session-projects');
  if (!sel) return;
  let projects = [];
  try {
    if (typeof cmdProjects !== 'undefined' && Array.isArray(cmdProjects) && cmdProjects.length > 0) {
      projects = cmdProjects.slice();
    } else {
      const res = await fetch('/api/status');
      const json = res.ok ? await res.json() : {};
      if (Array.isArray(json && json.projects)) {
        projects = json.projects.map(p => ({ name: p.name, description: p.description || '' }));
      }
    }
  } catch { projects = []; }
  // Preserve current selection across re-renders.
  const previouslySelected = new Set(Array.from(sel.selectedOptions || []).map(o => o.value));
  while (sel.firstChild) sel.removeChild(sel.firstChild);
  for (const p of projects) {
    if (!p || !p.name) continue;
    const opt = document.createElement('option');
    opt.value = p.name;
    opt.textContent = p.name;
    if (previouslySelected.has(p.name)) opt.selected = true;
    sel.appendChild(opt);
  }
}

async function loadQaSessions() {
  const activeRoot = document.getElementById('qa-sessions-content');
  const recentRoot = document.getElementById('qa-sessions-recent-content');
  if (!activeRoot) return;
  let sessions = [];
  let err = null;
  try {
    const res = await fetch('/api/qa/sessions?limit=50');
    const json = res.ok ? await res.json() : { sessions: [] };
    sessions = Array.isArray(json.sessions) ? json.sessions : [];
  } catch (e) { err = e; }

  if (err) {
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: err.message)
    const frag = document.createRange().createContextualFragment(
      '<p class="empty" style="color:var(--red)">Failed to load sessions: ' + escHtml(err.message || String(err)) + '</p>'
    );
    activeRoot.replaceChildren(frag);
    return;
  }

  _qaSessionsCache = sessions;

  // W-mpxp7a3e000g0a49 — split into active (non-terminal) + recent (terminal,
  // last 10). Active list is the primary surface; terminal sessions live
  // behind a collapsed disclosure to keep the page scannable.
  const active = sessions.filter(s => s && !QA_SESSION_TERMINAL_STATES.has(s.state));
  const terminal = sessions.filter(s => s && QA_SESSION_TERMINAL_STATES.has(s.state)).slice(0, 10);

  if (!active.length) {
    const frag = document.createRange().createContextualFragment(
      '<p class="empty">No active QA sessions.</p>'
    );
    activeRoot.replaceChildren(frag);
  } else {
    let html = '';
    for (const s of active) html += _qaRenderSessionCard(s);
    // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
    const frag = document.createRange().createContextualFragment(html);
    activeRoot.replaceChildren(frag);
  }

  if (recentRoot) {
    if (!terminal.length) {
      const frag = document.createRange().createContextualFragment(
        '<p class="empty">No recent sessions.</p>'
      );
      recentRoot.replaceChildren(frag);
    } else {
      let html = '';
      for (const s of terminal) html += _qaRenderSessionCard(s);
      // eslint-disable-next-line no-unsanitized/method -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: session id, state, target sub-fields, mode, runner, error/summary/failureClass)
      const frag = document.createRange().createContextualFragment(html);
      recentRoot.replaceChildren(frag);
    }
  }

  _qaAfterSessionsRender();
}

// Auto-stop polling once every visible session has reached a terminal
// state. The form-submit + action handlers each call _startQaSessionsPoll
// to restart polling when the next non-terminal session appears.
function _qaAfterSessionsRender() {
  const allTerminal = _qaSessionsCache.length > 0
    && _qaSessionsCache.every(s => s && QA_SESSION_TERMINAL_STATES.has(s.state));
  if (allTerminal) _stopQaSessionsPoll();
}

function _qaRenderSessionCard(s) {
  const id = s && s.id || '';
  const state = (s && s.state) || 'unknown';
  const spec = (s && s.spec) || {};
  const target = spec.target || {};
  const targetSummary = _qaSummarizeTarget(target);
  const flowsRaw = spec.flowsRaw || '';
  const flowsShort = flowsRaw.length > 200 ? flowsRaw.slice(0, 200) + '…' : flowsRaw;
  const runner = spec.runner || '(auto-detect)';
  const mode = spec.mode || 'confirm';
  const failureClass = s && s.failureClass || '';
  const error = s && s.error || '';
  const summary = s && s.summary || '';
  const createdAt = s && s.createdAt ? String(s.createdAt).slice(0, 19).replace('T', ' ') : '';
  const isTerminal = QA_SESSION_TERMINAL_STATES.has(state);

  const phases = _qaRenderPhaseChips(state);

  // Action buttons depend on session state:
  //   - awaiting-approval → [REVIEW DRAFT] [APPROVE & RUN] [EDIT] [CANCEL]
  //     The Review draft button comes first so users read before approving.
  //   - non-terminal      → footer always shows [DISMISS] [KILL SPAWN]
  //   - terminal          → no actions
  let actions = '';
  if (state === 'awaiting-approval') {
    actions += '<button class="btn-ghost" onclick="qaSessionReviewDraft(\'' + escHtml(id) + '\')">Review draft</button>';
    actions += '<button class="btn-primary-solid" onclick="qaSessionApprove(\'' + escHtml(id) + '\')">Approve &amp; run</button>';
    actions += '<button class="btn-ghost" onclick="qaSessionEditPrompt(\'' + escHtml(id) + '\')">Edit</button>';
    actions += '<button class="btn-ghost" onclick="qaSessionCancel(\'' + escHtml(id) + '\')">Cancel</button>';
  }
  if (!isTerminal) {
    actions += '<button class="btn-ghost" onclick="qaSessionDismiss(\'' + escHtml(id) + '\')">Dismiss</button>';
    actions += '<button class="btn-ghost qa-btn-danger" onclick="qaSessionKill(\'' + escHtml(id) + '\')">Kill spawn</button>';
  }

  let meta = '';
  meta += '<span class="qa-session-meta-item">target: <code>' + escHtml(targetSummary) + '</code></span>';
  meta += '<span class="qa-session-meta-item">mode: <code>' + escHtml(mode) + '</code></span>';
  meta += '<span class="qa-session-meta-item">runner: <code>' + escHtml(runner) + '</code></span>';
  if (createdAt) meta += '<span class="qa-session-meta-item qa-session-meta-ts">' + escHtml(createdAt) + '</span>';

  let body = '';
  if (flowsShort) {
    body += '<div class="qa-session-flows"><strong>Flows:</strong> ' + escHtml(flowsShort) + '</div>';
  }
  if (state === 'failed' && (failureClass || error)) {
    body += '<div class="qa-session-error"><strong>' + escHtml(failureClass || 'failed') + ':</strong> ' + escHtml(error || summary || 'no details') + '</div>';
  } else if (summary) {
    body += '<div class="qa-session-summary">' + escHtml(summary) + '</div>';
  }

  const stateClass = 'qa-session-state-' + escHtml(state);
  return (
    '<div class="qa-session-card ' + stateClass + '" data-session-id="' + escHtml(id) + '">' +
      '<div class="qa-session-head">' +
        '<span class="qa-session-id"><code>' + escHtml(id) + '</code></span>' +
        '<span class="qa-session-state">' + escHtml(state) + '</span>' +
      '</div>' +
      '<div class="qa-session-phases">' + phases + '</div>' +
      '<div class="qa-session-meta">' + meta + '</div>' +
      body +
      (actions ? '<div class="qa-session-actions">' + actions + '</div>' : '') +
    '</div>'
  );
}

// Map session.state → the visual phase the user is currently in (1..4).
// Phases earlier than `current` render as --done; later phases render as
// muted; the current phase is --active. Terminal states render the final
// phase as --done with a status badge on the state row.
function _qaRenderPhaseChips(state) {
  const PHASES = [
    { key: 'setup',   icon: '🔧', label: 'setup' },
    { key: 'draft',   icon: '📝', label: 'draft' },
    { key: 'execute', icon: '▶',  label: 'execute' },
    { key: 'done',    icon: '✅', label: 'done' },
  ];
  let activeIdx;
  switch (state) {
    case 'pending':
    case 'spawning': activeIdx = 0; break;
    case 'drafting':
    case 'awaiting-approval': activeIdx = 1; break;
    case 'executing': activeIdx = 2; break;
    case 'done':
    case 'failed':
    case 'killed': activeIdx = 3; break;
    default: activeIdx = 0;
  }
  let html = '';
  for (let i = 0; i < PHASES.length; i++) {
    let cls = 'qa-phase-chip';
    if (i < activeIdx) cls += ' qa-phase-chip--done';
    else if (i === activeIdx) cls += ' qa-phase-chip--active';
    else cls += ' qa-phase-chip--pending';
    if (i === 3 && state === 'failed') cls += ' qa-phase-chip--failed';
    if (i === 3 && state === 'killed') cls += ' qa-phase-chip--killed';
    html += '<span class="' + escHtml(cls) + '">' + escHtml(PHASES[i].icon) + ' ' + escHtml(PHASES[i].label) + '</span>';
    if (i < PHASES.length - 1) html += '<span class="qa-phase-arrow">→</span>';
  }
  return html;
}

function _qaSummarizeTarget(target) {
  if (!target || typeof target !== 'object') return '(unknown)';
  switch (target.kind) {
    case 'pr': return 'PR#' + (target.prId || '?');
    case 'branch': return 'branch:' + (target.branch || '?');
    case 'commit': return 'commit:' + String(target.sha || '').slice(0, 8);
    case 'current': return 'current:' + (target.worktree || '(agent cwd)');
    default: return target.kind || '(unknown)';
  }
}

// W-mpxp7a3e000g0a49 — qaSubmitSessionForm removed; creating QA sessions
// now flows through Command Center (POST /api/qa/session is still wired
// for CC and external callers, but the inline form on this page is gone).

async function _qaSessionPost(url, label, body) {
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: body ? JSON.stringify(body) : '{}',
    });
    const json = await res.json().catch(() => ({}));
    if (!res.ok) {
      alert(label + ' failed (' + res.status + '): ' + (json.error || 'unknown'));
      return null;
    }
    _startQaSessionsPoll();
    return json;
  } catch (e) {
    alert(label + ' failed: ' + (e && e.message || String(e)));
    return null;
  }
}

function qaSessionApprove(id) {
  if (!id) return null;
  return _qaSessionPost('/api/qa/sessions/' + encodeURIComponent(id) + '/approve', 'approve');
}
function qaSessionCancel(id) {
  if (!id) return null;
  if (!confirm('Cancel session ' + id + '? The managed-spawn keeps running — use Kill to also tear it down.')) return null;
  return _qaSessionPost('/api/qa/sessions/' + encodeURIComponent(id) + '/cancel', 'cancel');
}
function qaSessionDismiss(id) {
  if (!id) return null;
  if (!confirm('Mark session ' + id + ' done without running EXECUTE?')) return null;
  return _qaSessionPost('/api/qa/sessions/' + encodeURIComponent(id) + '/dismiss', 'dismiss');
}
function qaSessionKill(id) {
  if (!id) return null;
  if (!confirm('Kill session ' + id + ' AND its managed-spawn?')) return null;
  return _qaSessionPost('/api/qa/sessions/' + encodeURIComponent(id) + '/kill', 'kill');
}
function qaSessionEdit(id, feedback) {
  if (!id) return null;
  if (!feedback || !feedback.trim()) return null;
  return _qaSessionPost('/api/qa/sessions/' + encodeURIComponent(id) + '/edit', 'edit', { feedback });
}
// Convenience wrapper used by the card UI — prompt for feedback then POST.
function qaSessionEditPrompt(id) {
  const fb = prompt('Reviewer feedback for the next DRAFT pass:');
  if (!fb || !fb.trim()) return null;
  return qaSessionEdit(id, fb);
}

// ── Review draft modal (W-mpxpvpwn000ra419) ──────────────────────────────
//
// Greenfield modal infrastructure. There is no reusable generic modal in
// dashboard/js/* today (modal-qa.js is doc-chat, not a modal shell), so the
// minimal one below is inlined here. Read-only by design — editing a draft
// continues to flow through the existing prompt()-based qaSessionEditPrompt
// path. The "Request edit" button in the modal footer just calls it.
//
// All DOM construction uses document.createElement + appendChild (and
// textContent for any caller-supplied content) so eslint-plugin-no-unsanitized
// stays happy. The only HTML-string interpolation is for static structural
// markup (icons + class names), which the lint rule explicitly allows.
//
// State invariants:
//  - Idempotent open: a re-open while a modal is already mounted no-ops if
//    the sessionId matches, or closes-then-reopens for a different session.
//  - Close on backdrop click + Esc; both paths route through _qaModalClose
//    so the keydown listener and focus restore happen exactly once.
//  - Focus restores to the originating button on close (a11y).

let _qaModalState = null; // { sessionId, root, originButton, keydownHandler }

function qaSessionReviewDraft(id) {
  if (!id) return null;
  // Idempotent: same id → no-op; different id → close + reopen.
  if (_qaModalState) {
    if (_qaModalState.sessionId === id) return null;
    _qaModalClose();
  }
  _qaInstallModalStyle();
  // Capture the focused button so we can restore focus on close (a11y).
  const originButton = (document.activeElement && document.activeElement.tagName === 'BUTTON')
    ? document.activeElement
    : null;
  // Mount the shell synchronously with a "Loading…" body so the modal feels
  // responsive even on slow fetches. We replace the body once the test file
  // comes back from /api/qa/sessions/<id>/test.
  const root = _qaBuildModalShell(id);
  document.body.appendChild(root);
  const keydownHandler = (e) => {
    if (e.key === 'Escape') {
      e.preventDefault();
      _qaModalClose();
    }
  };
  document.addEventListener('keydown', keydownHandler);
  _qaModalState = { sessionId: id, root, originButton, keydownHandler };
  // Fetch + render body. We don't block opening on the fetch — the spinner
  // is shown until the response lands.
  fetch('/api/qa/sessions/' + encodeURIComponent(id) + '/test')
    .then(res => res.json().catch(() => ({})).then(json => ({ res, json })))
    .then(({ res, json }) => {
      if (!_qaModalState || _qaModalState.sessionId !== id) return; // closed mid-flight
      if (!res.ok || !json || !json.ok) {
        _qaModalRenderError(json && json.error ? String(json.error) : 'Failed to load draft (' + res.status + ')');
        return;
      }
      _qaModalRenderBody(json);
    })
    .catch((e) => {
      if (!_qaModalState || _qaModalState.sessionId !== id) return;
      _qaModalRenderError(e && e.message || String(e));
    });
  return null;
}

function _qaModalClose() {
  if (!_qaModalState) return;
  const { root, originButton, keydownHandler } = _qaModalState;
  _qaModalState = null;
  if (keydownHandler) document.removeEventListener('keydown', keydownHandler);
  if (root && root.parentNode) root.parentNode.removeChild(root);
  // Restore focus to the originating button for keyboard navigation.
  if (originButton && typeof originButton.focus === 'function') {
    try { originButton.focus(); } catch (_) { /* noop */ }
  }
}

function _qaBuildModalShell(sessionId) {
  const backdrop = document.createElement('div');
  backdrop.className = 'qa-modal-backdrop';
  backdrop.setAttribute('role', 'dialog');
  backdrop.setAttribute('aria-modal', 'true');
  backdrop.setAttribute('aria-label', 'Review drafted QA test');
  backdrop.addEventListener('click', (e) => {
    if (e.target === backdrop) _qaModalClose();
  });

  const shell = document.createElement('div');
  shell.className = 'qa-modal-shell';
  backdrop.appendChild(shell);

  // Header
  const header = document.createElement('div');
  header.className = 'qa-modal-header';
  const title = document.createElement('div');
  title.className = 'qa-modal-title';
  title.textContent = 'Drafted test — (loading…)';
  header.appendChild(title);
  const closeBtn = document.createElement('button');
  closeBtn.type = 'button';
  closeBtn.className = 'btn-ghost qa-modal-close';
  closeBtn.setAttribute('aria-label', 'Close');
  closeBtn.textContent = '×';
  closeBtn.addEventListener('click', _qaModalClose);
  header.appendChild(closeBtn);
  shell.appendChild(header);

  // Meta strip
  const meta = document.createElement('div');
  meta.className = 'qa-modal-meta';
  meta.setAttribute('data-qa-modal-region', 'meta');
  const metaSession = document.createElement('span');
  metaSession.className = 'qa-modal-meta-item';
  metaSession.textContent = 'session: ' + sessionId;
  meta.appendChild(metaSession);
  shell.appendChild(meta);

  // Body
  const body = document.createElement('div');
  body.className = 'qa-modal-body';
  body.setAttribute('data-qa-modal-region', 'body');
  const loading = document.createElement('p');
  loading.className = 'qa-modal-loading';
  loading.textContent = 'Loading draft…';
  body.appendChild(loading);
  shell.appendChild(body);

  // Footer
  const footer = document.createElement('div');
  footer.className = 'qa-modal-footer';
  footer.setAttribute('data-qa-modal-region', 'footer');
  shell.appendChild(footer);

  return backdrop;
}

function _qaModalRenderError(message) {
  if (!_qaModalState) return;
  const body = _qaModalState.root.querySelector('[data-qa-modal-region="body"]');
  if (!body) return;
  body.textContent = '';
  const err = document.createElement('p');
  err.className = 'qa-modal-error';
  err.textContent = message;
  body.appendChild(err);
}

function _qaModalRenderBody(payload) {
  if (!_qaModalState) return;
  const id = _qaModalState.sessionId;
  const root = _qaModalState.root;
  // Update header title with the test file path.
  const title = root.querySelector('.qa-modal-title');
  if (title) title.textContent = 'Drafted test — ' + (payload.path || '(unknown file)');

  // Augment meta strip with language + size.
  const meta = root.querySelector('[data-qa-modal-region="meta"]');
  if (meta) {
    const lang = document.createElement('span');
    lang.className = 'qa-modal-meta-item';
    lang.textContent = 'language: ' + (payload.language || 'plaintext');
    meta.appendChild(lang);
    const size = document.createElement('span');
    size.className = 'qa-modal-meta-item';
    size.textContent = 'size: ' + (typeof payload.size === 'number' ? payload.size + ' bytes' : 'unknown');
    meta.appendChild(size);
  }

  // Body: either code block or "too large" placeholder.
  const body = root.querySelector('[data-qa-modal-region="body"]');
  if (body) {
    body.textContent = '';
    if (payload.truncated) {
      const tooBig = document.createElement('p');
      tooBig.className = 'qa-modal-too-big';
      // payload.path is the relative session.testFile from the API; render the
      // hint with textContent so escHtml-style XSS is impossible by construction.
      tooBig.textContent = 'File is ' + (payload.size || 0) +
        ' bytes — open engine/qa-tests/' + id + '/' + (payload.path || '') +
        ' to review.';
      body.appendChild(tooBig);
    } else {
      const pre = document.createElement('pre');
      pre.className = 'qa-modal-code';
      const code = document.createElement('code');
      // Assign the raw file content via textContent — the DOM treats the value
      // as literal text and neutralizes any HTML/script for free. Do NOT wrap
      // with escHtml(): textContent would then render the escape entities
      // (&lt;, &gt;, &#39;, &amp;) verbatim, defeating the source preview for
      // any TS/JS/Py file with <, >, &, or ' characters (W-mpxpvpwn000ra419).
      code.textContent = String(payload.content || '');
      pre.appendChild(code);
      body.appendChild(pre);
    }
  }

  // Footer: Approve & run + Request edit + Close. Approve is suppressed when
  // the file is truncated (>256KB) so users can't blind-approve a megabyte.
  const footer = root.querySelector('[data-qa-modal-region="footer"]');
  if (footer) {
    footer.textContent = '';
    if (!payload.truncated) {
      const approve = document.createElement('button');
      approve.type = 'button';
      approve.className = 'btn-primary-solid';
      approve.textContent = 'Approve & run';
      approve.addEventListener('click', () => {
        _qaModalClose();
        qaSessionApprove(id);
      });
      footer.appendChild(approve);
    }
    const editBtn = document.createElement('button');
    editBtn.type = 'button';
    editBtn.className = 'btn-ghost';
    editBtn.textContent = 'Request edit';
    editBtn.addEventListener('click', () => {
      _qaModalClose();
      qaSessionEditPrompt(id);
    });
    footer.appendChild(editBtn);
    const closeBtn = document.createElement('button');
    closeBtn.type = 'button';
    closeBtn.className = 'btn-ghost';
    closeBtn.textContent = 'Close';
    closeBtn.addEventListener('click', _qaModalClose);
    footer.appendChild(closeBtn);
  }
}

// Inject modal CSS once. Keyed off a data attribute on the <style> tag so
// repeated calls (e.g. from a re-rendered card) don't duplicate the block.
function _qaInstallModalStyle() {
  if (document.querySelector('style[data-qa-modal-style]')) return;
  const style = document.createElement('style');
  style.setAttribute('data-qa-modal-style', '1');
  style.textContent = [
    '.qa-modal-backdrop {',
    '  position: fixed; inset: 0; z-index: 9999;',
    '  background: rgba(0,0,0,0.5);',
    '  display: flex; align-items: center; justify-content: center;',
    '}',
    '.qa-modal-shell {',
    '  background: var(--surface, #fff);',
    '  color: var(--text, #111);',
    '  border-radius: 8px;',
    '  max-width: 80vw; max-height: 80vh;',
    '  width: 800px;',
    '  display: flex; flex-direction: column;',
    '  box-shadow: 0 8px 32px rgba(0,0,0,0.3);',
    '  border: 1px solid var(--border, #ddd);',
    '}',
    '.qa-modal-header {',
    '  display: flex; align-items: center; justify-content: space-between;',
    '  padding: 12px 16px; border-bottom: 1px solid var(--border, #ddd);',
    '}',
    '.qa-modal-title { font-weight: 600; }',
    '.qa-modal-close { font-size:var(--text-stat); padding: 0 8px; }',
    '.qa-modal-meta {',
    '  display: flex; gap: 12px; flex-wrap: wrap;',
    '  padding: 8px 16px; border-bottom: 1px solid var(--border, #ddd);',
    '  font-size:var(--text-md); color: var(--muted, #666);',
    '}',
    '.qa-modal-meta-item { white-space: nowrap; }',
    '.qa-modal-body { flex: 1; overflow: auto; padding: 12px 16px; }',
    '.qa-modal-code {',
    '  font-family: ui-monospace, Menlo, Consolas, monospace;',
    '  font-size:var(--text-md); line-height: 1.5;',
    '  background: var(--surface2, #f6f6f6);',
    '  border: 1px solid var(--border, #ddd);',
    '  border-radius: 4px; padding: 12px;',
    '  white-space: pre; overflow: auto;',
    '  margin: 0;',
    '}',
    '.qa-modal-too-big { color: var(--muted, #666); font-style: italic; }',
    '.qa-modal-error { color: var(--red, #c00); }',
    '.qa-modal-loading { color: var(--muted, #666); }',
    '.qa-modal-footer {',
    '  display: flex; gap: 8px; justify-content: flex-end;',
    '  padding: 12px 16px; border-top: 1px solid var(--border, #ddd);',
    '}',
  ].join('\n');
  document.head.appendChild(style);
}
