// 2026-05-15: noop kept for any caller that survived the slim-down.
// The TZ label was on the removed hour-from/to scope picker.
function renderTzLabel(_tz) { /* removed with the scope picker */ }

// 2026-05-15: Cases sidebar band — mirrors Home's canonical numbers.
// 4 KPIs (Contained / Observing / Filtered out / Needs review) sourced
// from /api/overview canonical_counts fields. Sums to `warden_decisions
// + needs_review`. Header label "Today" + "<N> Warden decisions" so the
// operator-visible total agrees with Home's "<N> Warden decisions"
// strip. Single source of truth = canonical_counts, no period-vs-now
// duplication.
function renderCasesSidebarBand(overview) {
  var ov = overview || {};
  var contained = ov.blocked_count != null ? ov.blocked_count : 0;
  var observing = ov.observing_count != null ? ov.observing_count : 0;
  var filteredOut = ov.filtered_out_count != null ? ov.filtered_out_count : 0;
  var needsReview = ov.attention_count != null ? ov.attention_count : 0;
  var warden = (ov.warden_decisions_count != null)
    ? ov.warden_decisions_count
    : (contained + observing + filteredOut);

  var setNum = function(id, val) {
    var el = document.getElementById(id);
    if (!el) return;
    el.textContent = (val == null ? 0 : val);
  };
  setNum('kpi-contained', contained);
  setNum('kpi-observing', observing);
  setNum('kpi-filtered-out', filteredOut);
  setNum('kpi-needs-review', needsReview);

  var totalEl = document.getElementById('cases-band-total');
  if (totalEl) {
    var word = warden === 1 ? 'Warden decision' : 'Warden decisions';
    totalEl.textContent = warden + ' ' + word;
  }
}

// Backwards-compat alias for any caller that still says
// renderCurrentStateBand. The new band IS today's canonical, so
// route both names through the same function.
function renderCurrentStateBand(overview) { renderCasesSidebarBand(overview); }

// ── 2026-05-15: Enforcement audit modal ─────────────────────────────
// Cross-attacker view of every active block on every backend. Replaces
// the standalone Responses tab. Per-attacker enforcement detail lives
// inline on the journey panel (renderEnforcementBlock in journey.js);
// this modal is the OPERATOR's bird-eye view when they need to audit
// the whole kernel state at once.

function updateEnforcementAuditCount(responsesPayload) {
  var countEl = document.getElementById('enforcementAuditCount');
  if (!countEl) return;
  var active = (responsesPayload && responsesPayload.gauges && responsesPayload.gauges.active != null)
    ? responsesPayload.gauges.active
    : (responsesPayload && Array.isArray(responsesPayload.active) ? responsesPayload.active.length : 0);
  countEl.textContent = active;
  countEl.title = active === 1 ? '1 enforcement entry active right now' : (active + ' enforcement entries active right now');
}

function openEnforcementModal() {
  var modal = document.getElementById('enforcementModal');
  var content = document.getElementById('enforcementModalContent');
  if (!modal || !content) return;
  modal.style.display = 'flex';
  content.innerHTML = '<div style="color:var(--muted)">Loading…</div>';
  loadJson('/api/responses').then(function(r) {
    renderEnforcementModal(r);
  }).catch(function(e) {
    content.innerHTML = '<div style="color:var(--danger)">Failed to load: ' + esc(e.message || String(e)) + '</div>';
  });
}

function closeEnforcementModal() {
  var modal = document.getElementById('enforcementModal');
  if (modal) modal.style.display = 'none';
}

function renderEnforcementModal(r) {
  var content = document.getElementById('enforcementModalContent');
  if (!content) return;
  var active = (r && Array.isArray(r.active)) ? r.active : [];
  var gauges = (r && r.gauges) || {};

  var header = '<div style="margin-bottom:10px;color:var(--muted);font-size:0.74rem;letter-spacing:0.04em;text-transform:uppercase">'
    + (gauges.active || active.length) + ' active'
    + ' · ' + (gauges.orphaned || 0) + ' orphaned'
    + ' · ' + (gauges.in_retry || 0) + ' in retry'
    + ' · ' + (gauges.pending || 0) + ' pending'
    + '</div>';

  if (active.length === 0) {
    content.innerHTML = header + '<div style="color:var(--dim);padding:20px 0;text-align:center">No active blocks. All have expired or been reverted.</div>';
    return;
  }

  var rows = active.map(function(a) {
    var stateKind = (a.state && a.state.kind) || 'active';
    var stateBadge = '<span class="enf-state enf-state-' + esc(stateKind === 'revert_pending' ? 'pending' : (stateKind === 'revert_failed' ? 'failed' : 'active')) + '">' + esc(stateKind.replace('_', ' ')) + '</span>';
    var ttlSecs = a.ttl_secs || 0;
    var ttlH = Math.floor(ttlSecs / 3600);
    var ttlLabel = ttlH > 0 ? (ttlH + 'h') : Math.max(0, Math.floor(ttlSecs / 60)) + 'm';
    var remSecs = a.remaining_secs || 0;
    var remMins = Math.floor(remSecs / 60);
    var remHrs = Math.floor(remMins / 60);
    var remaining = remHrs > 0 ? (remHrs + 'h ' + (remMins % 60) + 'm') : (remMins + 'm');
    return '<tr>'
      + '<td class="enf-modal-target">' + esc(a.target || '') + '</td>'
      + '<td><span class="enf-backend">' + esc(a.backend || '') + '</span></td>'
      + '<td>' + stateBadge + '</td>'
      + '<td>' + esc(a.type || 'block_ip') + '</td>'
      + '<td>' + esc(ttlLabel) + '</td>'
      + '<td>' + esc(remaining) + '</td>'
      + '<td style="color:var(--dim);font-size:0.7rem">' + esc((a.incident_id || '').substring(0, 60)) + '</td>'
      + '</tr>';
  }).join('');

  content.innerHTML = header
    + '<table class="enf-modal-table">'
    + '<thead><tr><th>Target</th><th>Backend</th><th>State</th><th>Type</th><th>TTL</th><th>Remaining</th><th>Incident</th></tr></thead>'
    + '<tbody>' + rows + '</tbody>'
    + '</table>';
}

// Esc key closes the modal.
document.addEventListener('keydown', function(ev) {
  if (ev.key === 'Escape') closeEnforcementModal();
});

var DETECTOR_PRIORITY = {
  reverse_shell: 100, fileless_exec: 95, container_escape: 90,
  rootkit: 85, data_exfil_cmd: 80, sudo_abuse: 75,
  threat_intel: 70, dns_c2: 65, packet_flood: 60,
  credential_stuffing: 55, ssh_bruteforce: 50,
  proto_anomaly: 40, suspicious_execution: 35, discovery_burst: 30,
  web_scan: 25, port_scan: 20, network_sniffing: 15,
  host_drift: 10, kernel_module: 8, timing_anomaly: 5,
  logging_config_change: 3, suspicious_archive: 2
};

// Outcome display order and labels for the AI-first audit trail.
// Threats is NOT a triage queue — the AI decides and acts. The operator
// reads outcomes, not work items. Grouping by outcome answers "what did
// the AI do?" instead of "what detector fired?".
// Phase 7 (audit RC-2): "allowlisted" is now its own group, between
// Observing and Dismissed. The operator-relevant audit question
// "what did my trust rules silence today?" gets a dedicated answer
// instead of being hidden behind the "Hide allowlisted" toggle.
var OUTCOME_ORDER = ['needs_attention', 'blocked', 'honeypot', 'monitoring', 'allowlisted', 'dismissed'];

// 2026-04-30: replaced emoji icons with inline lucide SVGs to match
// the home pyramid (Phase 11B) and the marketing site. Same icon
// vocabulary across all dashboard surfaces — no more emoji/SVG mix.
//
// Icon source: lucide.dev — Ban (blocked), Bug (honeypot),
// Eye (monitoring/observing), AlertCircle (needs_attention),
// Handshake (allowlisted), Check (dismissed). All inherit
// `currentColor` so CSS controls per-row tint.
//
// Group label: "Blocked attackers" → "Currently blocked attackers"
// (Wave 10, 2026-05-05). The list section is a SNAPSHOT of unique IPs
// currently in the blocked-outcome bucket; the KPI tile above counts
// BLOCK ACTIONS today (decisions, not unique IPs). Pre-Wave-10 the
// labels read "Blocks · Today" and "Blocked attackers" — same page,
// related concepts, different answers, with no copy disclosing the
// "snapshot vs accumulator" axis. Operator looked at Home (26
// attackers handled today), then Threats (12 Blocked attackers) and
// could not reconcile the gap. The "Currently" prefix names the
// snapshot axis explicitly so a future relabel back to "Blocked
// attackers" without disclosing snapshot-vs-aggregate fails CI.
var SVG_ATTRS = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
var ICON_BAN          = '<svg ' + SVG_ATTRS + '><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>';
var ICON_BUG          = '<svg ' + SVG_ATTRS + '><path d="m8 2 1.88 1.88"/><path d="M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6"/><path d="M12 20v-9"/><path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>';
var ICON_EYE          = '<svg ' + SVG_ATTRS + '><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>';
var ICON_ALERT_CIRCLE = '<svg ' + SVG_ATTRS + '><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>';
var ICON_HANDSHAKE    = '<svg ' + SVG_ATTRS + '><path d="m11 17 2 2a1 1 0 1 0 3-3"/><path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88a1 1 0 1 1-3-3l2.81-2.81a5.79 5.79 0 0 1 7.06-.87l.47.28a2 2 0 0 0 1.42.25L21 4"/><path d="m21 3 1 11h-2"/><path d="M3 3 2 14l6.5 6.5a1 1 0 1 0 3-3"/><path d="M3 4h8"/></svg>';
var ICON_CHECK        = '<svg ' + SVG_ATTRS + '><path d="M20 6 9 17l-5-5"/></svg>';

// Spec 049 PR13: dismissed → Filtered out completes the §5.5 rename
// (PR3 covered Home strip + KPI tiles; the AI Defense Log group
// header still read `Dismissed`). Labels carry "today" semantics
// here; scope-aware variants live in `groupLabelForScope()`.
var OUTCOME_META = {
  blocked:         { icon: ICON_BAN,          label: 'Currently blocked attackers', cls: 'outcome-blocked' },
  honeypot:        { icon: ICON_BUG,          label: 'Honeypot',               cls: 'outcome-honeypot' },
  monitoring:      { icon: ICON_EYE,          label: 'Observing',              cls: 'outcome-observing' },
  needs_attention: { icon: ICON_ALERT_CIRCLE, label: 'Needs your attention',   cls: 'outcome-attention' },
  allowlisted:     { icon: ICON_HANDSHAKE,    label: 'Allowlisted (silenced)', cls: 'outcome-allowlisted' },
  filtered_out:    { icon: ICON_CHECK,        label: 'Filtered out',           cls: 'outcome-dismissed' },
  // Backwards-compat alias: backend still emits `outcome: "dismissed"`
  // on some legacy paths. Frontend treats both as the same bucket.
  dismissed:       { icon: ICON_CHECK,        label: 'Filtered out',           cls: 'outcome-dismissed' },
};

// Spec 049 PR13 — scope-aware group labels. When the operator picks
// a past date, "Currently blocked attackers" is misleading (those
// IPs may have been unblocked since). Switch to "Blocked during
// selected period" to honour the spec 049 §8.2.D contract.
//
// Returns the label string for the given outcome + scope. Scope is
// derived from `state.filters.date` — empty / today → 'today',
// otherwise 'past'.
function casesScopeFromDate(filterDate) {
  if (!filterDate) return 'today';
  var today = new Date().toISOString().slice(0, 10);
  return filterDate === today ? 'today' : 'past';
}

function groupLabelForScope(outcome, scope) {
  if (scope === 'today') {
    return (OUTCOME_META[outcome] || { label: outcome }).label;
  }
  // Past-scope labels — spec 049 §8.2.D.
  switch (outcome) {
    case 'blocked':         return 'Blocked during selected period';
    case 'honeypot':        return 'Honeypot during selected period';
    case 'monitoring':      return 'Observed during selected period';
    case 'needs_attention': return 'Needed review during selected period';
    case 'allowlisted':     return 'Allowlisted during selected period';
    case 'filtered_out':
    case 'dismissed':       return 'Filtered out during selected period';
    default: return (OUTCOME_META[outcome] || { label: outcome }).label;
  }
}

// Spec 049 PR13 — unit disambiguation helper. Each group header
// counts unique attackers (one row per IP). The Home strip counts
// cases (one per incident, multiple per IP possible). When they
// diverge, the group header surfaces BOTH so the operator can
// reconcile "170 flagged by system" on Home with the smaller
// attacker count on Cases.
//
// Returns:
//   { attackers: N, cases: M, label: "N attackers · M cases" }
// or { attackers: N, cases: N, label: "N" } when they match
// (one incident per attacker — no disambiguation needed).
function unitDisambigFromItems(items) {
  var attackers = items.length;
  var cases = items.reduce(function(s, it) {
    var inc = (it && (it.inc || it.incident_count)) || 0;
    return s + (typeof inc === 'number' ? inc : 0);
  }, 0);
  if (cases === 0 || cases === attackers) {
    return { attackers: attackers, cases: cases || attackers, label: String(attackers) };
  }
  return {
    attackers: attackers,
    cases: cases,
    label: attackers + (attackers === 1 ? ' attacker · ' : ' attackers · ') +
           cases + (cases === 1 ? ' case' : ' cases')
  };
}

function outcomeOf(item) {
  // 2026-04-29 (audit Phase 2): mirror the backend's
  // `threat_contract::classify_decision` so the front-end no longer
  // disagrees with the row's actual classification when only
  // `item.decision` is populated (a freshly-blocked IP arrives with
  // `decision="block_ip", outcome=null` from the SSE stream and the
  // pre-fix code classified it as "monitoring" instead of "blocked").
  // Backend now emits canonical strings; the legacy fallbacks
  // (`monitored`, `ignored`, `noise`, `active`) are kept so a
  // not-yet-deployed agent does not regress.
  var o = (item.outcome || '').toLowerCase();
  if (o === 'blocked') return 'blocked';
  if (o === 'honeypot') return 'honeypot';
  if (o === 'monitoring' || o === 'monitored') return 'monitoring';
  // Phase 7 (audit RC-2): backend emits outcome="allowlisted" for
  // attackers whose incidents were silenced by the operator's trust
  // rule. Treat as a first-class outcome — distinct from dismissed
  // (AI noise gate) and from monitoring (AI explicitly chose to
  // watch). The dedicated group makes the silenced trust auditable.
  if (o === 'allowlisted') return 'allowlisted';
  if (o === 'open') {
    // Phase 13 (QA fix #3, 2026-04-29): explicit `open` outcome from
    // the backend means "no decision yet" — that is, the AI has not
    // committed to an action. Pre-Phase-13 this branch mode-rewrote
    // open→monitoring under guard mode to "reduce alarm fatigue",
    // but the consequence was that the Home tile (which counts
    // `buckets.attention.unique_attackers`) said "Needs attention 3"
    // while the threats list grouped those same 3 IPs under
    // Observing. Two semantic interpretations of the same backend
    // field on the same screen — exactly the RC-2 drift class.
    //
    // Resolution: `open` always maps to `needs_attention`. The
    // Pending breakdown panel in the Home view (Phase 7B) already
    // distinguishes "in flight (<5 min)" from "stuck (>1h)" so the
    // operator gets the right alarm level without re-classifying
    // open as monitoring. If alarm-fatigue regresses, the right fix
    // is to refine the Pending panel copy, not to silently rewrite
    // the outcome.
    return 'needs_attention';
  }
  if (o === 'ignored' || o === 'noise' || o === 'dismissed') return 'dismissed';

  // No outcome string -- fall back to decision-driven classification.
  // Mirrors the backend's contract for the (decision, exec_result=None) path.
  var dec = (item.decision || item.action_taken || '').toLowerCase();
  if (dec === 'block_ip' || dec === 'kill_process' || dec === 'suspend_user_sudo' || dec === 'block_container') {
    return 'blocked';
  }
  if (dec === 'honeypot') return 'honeypot';
  if (dec === 'monitor') return 'monitoring';
  if (dec === 'ignore' || dec === 'dismiss') return 'dismissed';

  // Phase 13 (QA fix #3): 'active', '', escalate, request_confirmation,
  // unknown — these are all "no committed decision". They join the
  // `open` branch above and map to 'needs_attention' regardless of
  // mode. The mode-aware rewrite that used to live here was the same
  // class of semantic drift the explicit `open` branch had.
  return 'needs_attention';
}

function buildGroupedList(items) {
  // Spec 049 PR22 — frontend filtering REMOVED. Pre-PR22 this body
  // applied an extra `isIpTrusted + isPrivateIp` filter on top of
  // whatever the backend returned. Backend already drops self-traffic
  // (Cloudflare + RFC1918) consistently via `is_self_traffic_or_internal`
  // in `data_api.rs`. The double-filter is what caused the
  // strip-vs-panel mismatch operator-reported on 2026-05-13 ("40
  // Currently blocked" vs "7 attackers" in the list). Backend is the
  // single source of truth; frontend renders what it receives.
  // Filter by outcome if set (e.g. from Home CTA click).
  //
  // Spec 049 PR14 expanded the bucket vocabulary:
  //   - 'all_flagged'      \u2192 no filtering (every outcome visible)
  //   - 'warden_decisions' \u2192 keep blocked / honeypot / monitoring / dismissed (the four Warden-decided buckets)
  //   - 'needs_review'     \u2192 only needs_attention
  //   - 'contained'        \u2192 blocked + honeypot (legacy bucket, kept)
  //   - 'observing'        \u2192 monitoring
  //   - 'filtered_out'     \u2192 dismissed (wire key); UI label "Filtered out"
  var fOutcome = (state.filterOutcome || '').toLowerCase();
  if (fOutcome === 'warden_decisions') {
    items = items.filter(function(item) {
      var o = (item.outcome || '').toLowerCase();
      return o === 'blocked' || o === 'honeypot' || o === 'monitoring' || o === 'dismissed';
    });
  } else if (fOutcome === 'needs_review') {
    items = items.filter(function(item) {
      var o = (item.outcome || '').toLowerCase();
      return o === 'needs_attention';
    });
  } else if (fOutcome === 'observing') {
    items = items.filter(function(item) {
      var o = (item.outcome || '').toLowerCase();
      return o === 'monitoring';
    });
  } else if (fOutcome === 'filtered_out') {
    items = items.filter(function(item) {
      var o = (item.outcome || '').toLowerCase();
      return o === 'dismissed';
    });
  }
  // Note: `'contained'` is handled by the legacy branch below; `'all_flagged'` is a no-op.

  var titleEl = document.getElementById('entityTitle');
  // Spec 049 PR13 \u2014 unit-disambiguation subtitle.
  //
  // The list section counts UNIQUE ATTACKERS (one row per IP). The
  // Home strip counts CASES (one per incident; multiple per IP
  // possible). When operator sees `170 Flagged by system` on Home
  // but only ~50 rows here, the gap is real and confusing without
  // a label that names both units. This computes both totals so
  // the operator can reconcile "170 cases \u2248 50 attackers" at a
  // glance.
  var totalDisambig = unitDisambigFromItems(items);
  var subtitleHtml = totalDisambig.cases !== totalDisambig.attackers
    ? ' <span style="font-size:0.62rem;color:var(--muted);font-weight:500;letter-spacing:0.04em;margin-left:8px">' +
      '\u00b7 ' + esc(totalDisambig.label) + '</span>'
    : '';
  // Spec 049 PR14 \u2014 operator-readable scoped titles. Each bucket
  // gets its own header copy so the operator never wonders "why am
  // I seeing fewer items than expected?" (the filter IS the
  // reason). The `\u2715 show all` clear-link is the same across all
  // scoped views so the operator can bail out with one click.
  var clearLink = ' <span style="font-size:0.6rem;color:var(--muted);cursor:pointer;margin-left:6px" onclick="state.filterOutcome=null;refreshLeft(false)">\u2715 show all</span>';
  var scopedTitle = null;
  switch (fOutcome) {
    case 'contained':        scopedTitle = 'Contained threats'; break;
    case 'observing':        scopedTitle = 'Observing'; break;
    case 'filtered_out':     scopedTitle = 'Filtered out'; break;
    case 'warden_decisions': scopedTitle = 'Warden decisions'; break;
    case 'needs_review':     scopedTitle = 'Needs review'; break;
    case 'all_flagged':      scopedTitle = 'Flagged by system'; break;
    default: break;
  }
  if (state.filterOutcome === 'contained') {
    items = items.filter(function(item) {
      var o = (item.outcome || '').toLowerCase();
      return o === 'blocked' || o === 'honeypot';
    });
  }
  if (titleEl) {
    if (scopedTitle) {
      titleEl.innerHTML = esc(scopedTitle) + clearLink + subtitleHtml;
    } else {
      titleEl.innerHTML = 'AI Defense Log' + subtitleHtml;
    }
  }
  // Audit 2.6 partial: status dropdown filter. Restricts the
  // grouped list to a single outcome bucket. Country / campaign /
  // playbook-outcome / AI confidence band require backend extension
  // and ship in their own spec.
  var statusFilter = (state.filters && state.filters.status) || '';
  if (statusFilter) {
    items = items.filter(function(item) {
      return outcomeOf(item) === statusFilter;
    });
  }

  // Group by outcome (what the AI did), not by detector (what was found).
  var seen = {};
  var groups = {};
  OUTCOME_ORDER.forEach(function(o) {
    groups[o] = { outcome: o, items: [] };
  });

  items.forEach(function(item) {
    if (seen[item.value]) return;
    seen[item.value] = true;
    var o = outcomeOf(item);
    if (!groups[o]) groups[o] = { outcome: o, items: [] };
    groups[o].items.push(item);
  });

  // Sort items within each group: highest severity first, then most recent
  var sevRank = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
  Object.values(groups).forEach(function(g) {
    g.items.sort(function(a, b) {
      var sa = sevRank[(a.max_severity || '').toLowerCase()] || 0;
      var sb = sevRank[(b.max_severity || '').toLowerCase()] || 0;
      if (sb !== sa) return sb - sa;
      return (b.last_seen || '') > (a.last_seen || '') ? 1 : -1;
    });
  });

  // Spec 049 PR13: read scope once for all group headers below.
  // `state.filters.date` is the picker value (empty / today => live,
  // past date => scope-aware labels).
  var scope = casesScopeFromDate(state.filters.date);

  var html = '';
  OUTCOME_ORDER.forEach(function(o, idx) {
    var g = groups[o];
    if (!g) return;
    var meta = OUTCOME_META[o] || { icon: '', label: o, cls: '' };
    var count = g.items.length;

    // Always show "Needs your attention" group (even at 0 — reassuring).
    // Hide empty groups for other outcomes.
    if (count === 0 && o !== 'needs_attention') return;

    var startOpen = count > 0 && (o === 'needs_attention' || o === 'blocked' || idx === 0);
    // Spec 049 PR13 — scope-aware labels + unit disambiguation.
    var labelText = groupLabelForScope(o, scope);
    var disambig = unitDisambigFromItems(g.items);
    var countLabel;
    if (o === 'needs_attention' && count === 0) {
      countLabel = '<span style="color:var(--ok);font-weight:700">0</span>';
    } else {
      countLabel = disambig.label;
    }

    // Audit 4.2/4.3/4.4: attach glossary tooltip to the group header
    // so the operator sees the canonical definition for the bucket
    // they are reading. `glossaryTitle(o)` falls back to '' when the
    // bucket key is missing — safe to concatenate unconditionally.
    var groupTitle = (typeof glossaryTitle === 'function') ? glossaryTitle(o) : '';
    html += '<div class="threat-group ' + meta.cls + '">' +
      '<div class="threat-group-header" onclick="toggleThreatGroup(this)"' + groupTitle + '>' +
      '<span class="threat-group-chevron' + (startOpen ? ' open' : '') + '">\u25B8</span>' +
      '<span class="threat-group-label">' + meta.icon + '<span>' + esc(labelText) + '</span></span>' +
      '<span class="threat-group-meta">' + countLabel + '</span>' +
      '</div>' +
      '<div class="threat-group-body' + (startOpen ? ' open' : '') + '">' +
      (count === 0
        ? '<div class="empty" style="padding:12px 16px;color:var(--ok);font-size:0.75rem">' +
          (o === 'needs_attention'
            ? ((window._agentMode || 'guard') === 'guard'
                ? 'Nothing here. The AI is handling everything.'
                : 'Enable Guard mode for automatic threat response.')
            : 'None today.') +
          '</div>'
        : g.items.map(function(item) { return renderCard(item); }).join('')) +
      '</div></div>';
  });
  return html;
}

var _trustedIps = [];
var _trustedUsers = [];

function isIpTrusted(ip) {
  return _trustedIps.some(function(t) {
    if (t.includes('/')) {
      // CIDR match — simple prefix check for common cases
      var prefix = t.split('/')[0];
      var bits = parseInt(t.split('/')[1], 10);
      if (bits <= 16) return ip.startsWith(prefix.split('.').slice(0, 2).join('.'));
      if (bits <= 24) return ip.startsWith(prefix.split('.').slice(0, 3).join('.'));
      return ip === prefix;
    }
    return ip === t;
  });
}

function showContained() {
  state.filterOutcome = 'contained';
  showView('investigate');
}

function toggleAllowlistFilter() {
  state.hideAllowlisted = document.getElementById('hideAllowlisted')?.checked || false;
  // _trustedIps / _trustedUsers are loaded at boot by loadActionConfig()
  // in actions.js (called from sse.js module load). No lazy-load needed.
  refreshLeft(false);
  // Also refresh Home if visible
  if (document.getElementById('viewHome').style.display !== 'none') loadHome();
}

function toggleThreatGroup(header) {
  var chevron = header.querySelector('.threat-group-chevron');
  var body = header.nextElementSibling;
  if (chevron) chevron.classList.toggle('open');
  if (body) body.classList.toggle('open');
}

function renderCard(item) {
  const value = item.value;
  const active = state.selected.type === state.pivot && state.selected.value === value ? ' active' : '';
  const sev = item.max_severity || 'unknown';
  const sevCss = sevCls(sev);
  const outcome = item.outcome || 'unknown';
  const dets = (item.detectors || []).map(function(d) { return humanLabel(d); }).join(', ') || '-';

  // Build badges — outcome-first, no "OPEN" status exists in the AI-first model.
  let badges = '';
  const mappedOutcome = outcomeOf(item);
  const outBadgeMap = {
    blocked: 'badge-blocked', honeypot: 'badge-honeypot',
    monitoring: 'badge-monitor', dismissed: 'badge-noise',
    needs_attention: 'badge-unresolved'
  };
  const outBadgeCls = outBadgeMap[mappedOutcome] || 'badge-monitor';
  const outBadgeLabel = (OUTCOME_META[mappedOutcome] || {}).label || 'Observing';
  badges += `<span class="card-badge ${outBadgeCls}">${outBadgeLabel}</span>`;
  // Phase 4: kernel-evidence badge appended after the AI-decision
  // badge so the operator sees "Blocked (AI) · Kernel · 45m" when
  // the kernel is still enforcing, and "Blocked (AI) · Expired"
  // when the TTL elapsed but no fresh block has been re-issued.
  badges += blockStateBadgeHtml(item.block_state);
  // Spec 049 PR15 — when the operator is auditing a past date and
  // the kernel is STILL enforcing this attacker's block today, paint
  // an unambiguous "Still active now" pill. Closes the gap operators
  // kept asking about: "ontem foi contained — e hoje?". The cryptic
  // `KERNEL · 48h` pill from `blockStateBadgeHtml` keeps the granular
  // timing view; this pill is a plain-English answer to a plain
  // English question. Today-scope skips the pill entirely because
  // the outcome badge ("Contained") already conveys "live now".
  if (casesScopeFromDate(state.filters && state.filters.date) === 'past'
      && item.block_state && item.block_state.kind === 'blocked_now') {
    badges += '<span class="card-badge badge-still-active" title="This attacker was contained on the selected past date and the kernel is still enforcing the block today.">' +
              lucideIcon('shield-check',{size:12}) + ' Still active now</span>';
  }

  const ago = (ts) => {
    if (!ts) return '';
    const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
    if (diff < 60) return diff + 's ago';
    if (diff < 3600) return Math.floor(diff/60) + 'm ago';
    if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
    return Math.floor(diff/86400) + 'd ago';
  };

  const isRecent = item.last_seen && (Date.now() - new Date(item.last_seen).getTime()) < 300000;
  const isContained = ['blocked','monitoring','honeypot'].includes(outcome);
  const dotClass = isContained ? 'pulse-dot contained' : 'pulse-dot';
  const recentDot = isRecent ? '<span class="' + dotClass + '" title="Active in last 5 min"></span>' : '';
  const sevDim = isContained ? ';opacity:0.5' : '';

  // Phase 12 (QA fix #5, 2026-04-29): cards used to be <div onclick>
  // — not focusable, screen readers couldn't announce them as actions,
  // no Cmd/Ctrl+click affordance. Now <button> with type="button"
  // gives keyboard focus, screen-reader role=button by default, and
  // a visible focus ring. Layout stays the same via class re-use.
  var ariaLabel = 'Open threat details for ' + value + '. Severity ' +
    sev + ', ' + (item.incident_count || 0) + ' incidents.';
  return `
    <button type="button"
            class="attacker-card${active}"
            data-subject-type="${esc(state.pivot)}"
            data-subject-value="${esc(value)}"
            aria-label="${esc(ariaLabel)}"
            onclick="loadJourney('${esc(state.pivot)}','${esc(value)}')">
      <div class="card-row">
        <div class="card-ip">${recentDot} ${esc(value)}</div>
        <span class="${sevCss}" style="font-size:0.65rem;font-weight:700${sevDim}">${esc(sev.toUpperCase())}</span>
      </div>
      <div class="card-detectors">${esc(dets)}</div>
      <div class="card-meta">
        <span class="card-counts">${item.incident_count || 0} inc${(item.event_count || 0) > 0 ? ' · ' + item.event_count + ' evt' : ''}</span>
        <span class="card-time">${ago(item.last_seen)}</span>
      </div>
      ${badges ? `<div class="card-badges">${badges}</div>` : ''}
    </button>`;
}

function renderClusterCard(cluster) {
  return `
    <div class="cluster-card" onclick="openCluster('${esc(cluster.pivot)}')">
      <div class="cluster-row">
        <span class="cluster-id">${esc(cluster.cluster_id)}</span>
        <span class="cluster-meta">${cluster.incident_count} incidents</span>
      </div>
      <div class="cluster-pivot">${esc(cluster.pivot)}</div>
      <div class="cluster-dets">${esc((cluster.detector_kinds || []).join(', '))}</div>
      <div class="cluster-meta">${esc(fmtTime(cluster.start_ts))} → ${esc(fmtTime(cluster.end_ts))}</div>
    </div>`;
}

function openCluster(pivotToken) {
  const parsed = parsePivotToken(pivotToken);
  state.pivot = parsed.type;
  updatePivotUi();
  refreshLeft(false).finally(() => {
    loadJourney(parsed.type, parsed.value);
  });
}

function openPivotShortcut(token) {
  const parsed = parsePivotToken(token);
  state.pivot = parsed.type;
  updatePivotUi();
  refreshLeft(false).finally(() => {
    loadJourney(parsed.type, parsed.value);
  });
}

async function downloadSnapshot(format) {
  try {
    syncFiltersFromUi();
    const qs = buildQuery({
      format,
      date: state.filters.date,
      severity_min: state.filters.severity_min,
      detector: state.filters.detector,
      group_by: state.pivot,
      subject_type: state.selected.value ? state.selected.type : '',
      subject: state.selected.value ? state.selected.value : '',
      window_seconds: state.filters.window_seconds,
    });
    const body = await loadText('/api/export?' + qs);
    // Spec 049 PR11: csv joins the existing json/md formats. The
    // backend sets Content-Disposition with an `innerwarden-audit-`
    // filename, but the operator's browser may save under the
    // anchor's `download` attribute — keep the JS-side filename
    // aligned for that path too.
    var ext, mime, filenamePrefix;
    if (format === 'md') {
      ext = 'md';
      mime = 'text/markdown; charset=utf-8';
      filenamePrefix = 'innerwarden-snapshot';
    } else if (format === 'csv') {
      ext = 'csv';
      mime = 'text/csv; charset=utf-8';
      filenamePrefix = 'innerwarden-audit';
    } else {
      ext = 'json';
      mime = 'application/json; charset=utf-8';
      filenamePrefix = 'innerwarden-snapshot';
    }
    const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
    downloadBlob(
      `${filenamePrefix}-${stamp}.${ext}`,
      mime,
      body
    );
  } catch (e) {
    var s = document.getElementById('refreshStatus');
    if (s) s.textContent = 'export err: ' + e.message;
  }
}

// D7 - update a KPI span; flash on change


// 2026-04-29 (audit Phase 2): `updateStatusHero` and
// `buildActivityFeed` removed -- both wrote to DOM IDs no
// longer in `index.html` (`statusHero`, `heroIcon`,
// `heroTitle`, `heroSub`, `activityFeed`) and were only
// called from the orphaned `loadHomeState` in helpers.js.
// The Home tab is now driven entirely by `home.js::loadHome`
// writing to `homeHero`/`homeHeroIcon`/`homeHeroTitle`/
// `homeHeroSub`. Removing the dead writes also retired the
// last front-end uses of stale outcome strings
// (`suspended`, `ignored`) that disagreed with the contract.


function updateKpi(id, newVal) {
  const el = document.getElementById(id);
  if (!el) return;
  const prev = el.textContent;
  el.textContent = newVal;
  if (String(prev) !== String(newVal)) {
    el.classList.remove('kpi-flash');
    void el.offsetWidth; // reflow to restart animation
    el.classList.add('kpi-flash');
    el.addEventListener('animationend', () => el.classList.remove('kpi-flash'), { once: true });
  }
}

// D7 - soft live refresh: only new cards get animated, existing stay in place.
// 2026-04-29: render a diagnostic-aware empty state inside the
// attackers list when /api/entities returns 0 items. Calls
// /api/threats/diagnostic to find out WHY (no incidents in scope,
// scope_mismatch, no entities) and provides clickable date chips
// when historical snapshots exist.
function renderEmptyDiagnostic(targetEl) {
  if (!targetEl) return;
  var qs = '';
  var fd = (state.filters && state.filters.date) || '';
  var fs = (state.filters && state.filters.severity_min) || '';
  var fdet = (state.filters && state.filters.detector) || '';
  if (fd) qs += (qs ? '&' : '?') + 'date=' + encodeURIComponent(fd);
  if (fs) qs += (qs ? '&' : '?') + 'severity_min=' + encodeURIComponent(fs);
  if (fdet) qs += (qs ? '&' : '?') + 'detector=' + encodeURIComponent(fdet);
  loadJson('/api/threats/diagnostic' + qs)
    .then(function(d) {
      var html = '';
      if (d.scope_mismatch) {
        html += '<div class="empty" style="padding:16px">';
        html += '<div style="margin-bottom:6px">' + lucideIcon('clipboard-list',{size:20}) + '</div>';
        html += '<div style="margin-bottom:10px">No incidents on <b>' + esc(fd || d.date) + '</b>.</div>';
        html += '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:8px">Pick a date with data:</div>';
        var chips = (d.available_dates || []).map(function(dd) {
          return '<button type="button" class="journey-btn" style="margin:2px;font-size:0.7rem;padding:3px 8px" onclick="setThreatsDate(\'' + esc(dd) + '\')">' + esc(dd) + '</button>';
        }).join('');
        html += '<div>' + (chips || '<span style="color:var(--muted)">none available</span>') + '</div>';
        html += '<div style="margin-top:10px"><button type="button" class="journey-btn" style="font-size:0.7rem" onclick="setThreatsDate(\'\')">Clear date filter</button></div>';
        html += '</div>';
      } else if (d.has_incidents && !d.has_entities) {
        html += '<div class="empty" style="padding:16px">';
        html += '<div style="margin-bottom:6px">' + lucideIcon('alert-triangle',{size:20}) + '</div>';
        html += '<div style="margin-bottom:8px">' + d.incidents_in_scope + ' incident(s) found, but no IP/User entities linked.</div>';
        if (d.detector_pivot_count > 0) {
          html += '<button type="button" class="journey-btn" style="font-size:0.7rem" onclick="setThreatsPivot(\'detector\')">Switch to Detector pivot</button>';
        }
        html += '</div>';
      } else if (!d.has_incidents) {
        html += '<div class="empty" style="padding:16px">';
        html += '<div style="margin-bottom:6px">' + lucideIcon('flame',{size:20}) + '</div>';
        html += '<div>No threats in scope. Either nothing fired today or the filter is too narrow.</div>';
        if (fs || fdet) {
          html += '<div style="margin-top:10px"><button type="button" class="journey-btn" style="font-size:0.7rem" onclick="clearThreatsFilters()">Clear filters</button></div>';
        }
        html += '</div>';
      } else {
        html += '<div class="empty">No records for the selected filters.</div>';
      }
      targetEl.innerHTML = html;
    })
    .catch(function() {
      targetEl.innerHTML = '<div class="empty">No records for the selected filters.</div>';
    });
}

function setThreatsDate(date) {
  var el = document.getElementById('flt-date');
  if (el) el.value = date;
  refreshLeft(true);
}

// 2026-05-15: removed syncThreatsKpiWindowLabels (the old KPI cards had
// "Today" / "Now" sub-labels; the new single-band layout drops the
// sub-label entirely — the date picker IS the scope indicator).

function setThreatsPivot(p) {
  // Phase 13 (QA fix #4 follow-up, 2026-04-29): the Phase-12 fix
  // targeted `#journeyPane` (an element that doesn't exist in the
  // current HTML) — the right panel kept showing the stale detail.
  // Real structure: `#rightPanel` is the container; `#homeState` is
  // the placeholder ("Select a threat to investigate"); the
  // `#journeyContent` div carries the actual detail when populated.
  // To clear: hide journeyContent, show homeState.
  if (state.pivot !== p) {
    state.selected = { type: null, value: null };
    state.lastSubjectKey = null;
    var journeyContent = document.getElementById('journeyContent');
    var homeState = document.getElementById('homeState');
    if (journeyContent) {
      journeyContent.style.display = 'none';
      journeyContent.innerHTML = '';
    }
    if (homeState) {
      homeState.style.display = '';
    }
    // Clear selection highlight on cards.
    document.querySelectorAll('.attacker-card.active').forEach(function(c) {
      c.classList.remove('active');
    });
    // Push a clean URL state so a refresh / share doesn't re-open
    // a stale subject from the pre-switch pivot.
    if (typeof history !== 'undefined' && history.replaceState) {
      var qs = '?pivot=' + encodeURIComponent(p);
      history.replaceState(null, '', qs + '#threats');
    }
  }
  state.pivot = p;
  document.querySelectorAll('.pivot-tab').forEach(function(t) {
    t.classList.toggle('active', t.getAttribute('data-pivot') === p);
  });
  refreshLeft(true);
}

function clearThreatsFilters() {
  ['flt-date', 'flt-severity', 'flt-detector'].forEach(function(id) {
    var e = document.getElementById(id);
    if (e) e.value = '';
  });
  refreshLeft(true);
}

async function refreshLeftLive() {
  try {
    syncFiltersFromUi();
    // Spec 049 PR5: scope picker hour range flows through every
    // Cases-tab query so the counters reconcile with the list under
    // the same window.
    const overviewQs = buildQuery({
      date: state.filters.date,
      hour_from: state.filters.hour_from,
      hour_to: state.filters.hour_to,
    });
    const entityQs = buildQuery({
      date: state.filters.date,
      severity_min: state.filters.severity_min,
      detector: state.filters.detector,
      group_by: state.pivot,
    });

    const [ov, entityData, responsesPayload] = await Promise.all([
      loadJson('/api/overview' + (overviewQs ? '?' + overviewQs : '')),
      state.pivot === 'ip'
        ? loadJson('/api/entities?' + entityQs).then((r) => ({
            items: (r.attackers || []).map((a) => ({ ...a, value: a.ip, group_by: 'ip' })),
          }))
        : loadJson('/api/pivots?' + entityQs),
      // 2026-05-15: keep the "View all enforcement (N)" link in sync
      // with the kernel ground-truth on every refresh.
      loadJson('/api/responses').catch(function() { return {}; }),
    ]);

    const items = entityData.items || [];

    window._lastOverview = ov;
    window._lastEntityItems = items;
    window._lastResponsesPayload = responsesPayload;
    updateEnforcementAuditCount(responsesPayload);

    // Spec 049 PR5: render operator TZ on the scope picker label so
    // the operator never has to guess what timezone "yesterday at
    // 15h" refers to. Backend-emitted (env TZ or /etc/timezone),
    // never browser-derived (which drifts across analysts).
    renderTzLabel(ov && ov.timezone);

    // 2026-05-15: Cases sidebar band reads the canonical overview
    // (single source, same as Home). No more period-vs-now duplicate
    // band — operator gets one set of numbers that match what Home
    // shows. renderCasesSidebarBand fills the 4 KPIs + total label.
    renderCasesSidebarBand(ov);

    const list = document.getElementById('attackerList');
    const newItems = items.filter(it => !state.knownItemValues.has(it.value));
    // SSE refresh can fire while Threats view is hidden; the list node
    // may be absent if the tab was not yet opened. Bail early in that
    // case so the live path never throws on null.innerHTML.
    if (!list) return;
    if (newItems.length > 0) {
      // Rebuild grouped list when new items arrive
      list.innerHTML = buildGroupedList(items);
      state.knownItemValues = new Set(items.map(it => it.value));
    }

    // Update counts on existing cards (incident/event count may change)
    for (const item of items) {
      const existing = list.querySelector(
        `[data-subject-type="${esc(state.pivot)}"][data-subject-value="${esc(item.value)}"]`
      );
      if (existing && !newItems.includes(item)) {
        const countEl = existing.querySelector('.card-counts');
        if (countEl) {
          // Phase 14 (QA polish): hide "0 evt" tail when we have no
          // event-count data (mirrors renderCard above so the SSE
          // refresh path doesn't reintroduce the noisy "· 0 evt"
          // suffix the operator complained about).
          const evt = item.event_count || 0;
          countEl.textContent = `${item.incident_count} inc${evt > 0 ? ' · ' + evt + ' evt' : ''}`;
        }
      }
    }
    if (newItems.length > 0) applyEntitySearch();  // D9: filter newly inserted cards
  } catch (e) {
    // silent - refreshLeft fallback handles error display
  }
}

async function refreshLeft(forceRefreshJourney = false) {
  try {
    syncFiltersFromUi();

    // Spec 049 PR5: same scope-picker plumbing as `refreshLeftLive`.
    // Both paths must pass the same query params or the live + manual
    // refresh paths would show different counts under the same picker
    // — exactly the "Dashboard count != Site count" recurring-bug class.
    const overviewQs = buildQuery({
      date: state.filters.date,
      hour_from: state.filters.hour_from,
      hour_to: state.filters.hour_to,
    });
    const entityQs = buildQuery({
      date: state.filters.date,
      severity_min: state.filters.severity_min,
      detector: state.filters.detector,
      group_by: state.pivot,
    });
    const clusterQs = buildQuery({
      date: state.filters.date,
      severity_min: state.filters.severity_min,
      detector: state.filters.detector,
      window_seconds: state.filters.window_seconds,
    });

    const [ov, entityData, clusterData, statusData, responsesPayload] = await Promise.all([
      loadJson('/api/overview' + (overviewQs ? '?' + overviewQs : '')),
      state.pivot === 'ip'
        ? loadJson('/api/entities?' + entityQs).then((r) => ({
            items: (r.attackers || []).map((a) => ({
              ...a,
              value: a.ip,
              group_by: 'ip',
            })),
          }))
        : loadJson('/api/pivots?' + entityQs),
      loadJson('/api/clusters?' + clusterQs),
      loadJson('/api/status').catch(() => ({ mode: 'guard' })),
      // 2026-05-15: keep the enforcement-audit count fresh on manual refresh.
      loadJson('/api/responses').catch(function() { return {}; }),
    ]);

    // Store agent mode globally so outcomeOf() can adapt.
    // guard = AI blocks autonomously, watch/read_only = AI detects only.
    window._agentMode = statusData.mode || 'guard';
    window._lastResponsesPayload = responsesPayload;
    updateEnforcementAuditCount(responsesPayload);

    // Phase 12 (QA fix #1): persistent header pill must reflect
    // runtime SystemHealth, not just the static GUARD/WATCH mode.
    // Without this, the operator sees green "PROTECTED" alongside
    // a red Hero alert — two contradictory states on one screen.
    if (typeof syncModeBadgeFromHealth === 'function') {
      syncModeBadgeFromHealth(ov, typeof actionCfg !== 'undefined' ? actionCfg : null);
    }

    const items = entityData.items || [];
    state.clusters = clusterData.items || [];

    window._lastOverview = ov;
    window._lastEntityItems = items;

    // 2026-05-15: Cases sidebar band — single canonical source.
    renderCasesSidebarBand(ov);
    // Spec 037 Threats UX bundle: the kpi-events / kpi-incidents /
    // kpi-attackers / clusterList / topDetectors writes that used to
    // live here targeted DOM nodes that PR #188 already removed. The
    // dead writes are gone; they were silently no-ops with the null
    // guard but kept misleading the reader of this file.

    const list = document.getElementById('attackerList');
    if (list) {
      if (items.length === 0) {
        // 2026-04-29: when the list is empty, ask /api/threats/diagnostic
        // why and surface an actionable hint (clear date / pick a
        // historical date with data) instead of the generic message
        // that left the operator stuck.
        list.innerHTML = '<div class="empty">No records yet. Loading diagnostic...</div>';
        state.knownItemValues.clear();
        renderEmptyDiagnostic(list);
      } else {
        list.innerHTML = buildGroupedList(items);
        state.knownItemValues = new Set(items.map(it => it.value));
      }
    }

    // Spec 037 Threats UX bundle: clusterList + topDetectors writes
    // removed -- those DOM nodes were dropped in PR #188 and the
    // setHtml() calls were silent no-ops. Cluster data is still
    // fetched (state.clusters) for the journey panel that consumes
    // it; only the dead left-panel render is gone.

    if (state.selected.value) {
      const stillExists =
        state.selected.type === state.pivot &&
        items.some((it) => it.value === state.selected.value);
      if (!stillExists) {
        state.selected = { type: state.pivot, value: null };
        showHomeState();
      } else if (forceRefreshJourney) {
        await loadJourney(state.selected.type, state.selected.value);
      }
    }

    applyEntitySearch();  // D9: re-apply filter after full reload
    syncUrl();
    var s = document.getElementById('refreshStatus');
    if (s) s.textContent = new Date().toLocaleTimeString();
  } catch (e) {
    var s = document.getElementById('refreshStatus');
    if (s) s.textContent = 'err: ' + e.message;
  }
}

// Boot vars (init code in sse.js which loads last)
const today = new Date().toISOString().slice(0, 10);
