// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering.
// Desktop view redesign (issue #1025): 三栏 master-detail、低噪声、渐进披露、排查直觉化。
// 保持纯 vanilla JS + 原生 CSS，无框架、无构建步骤，所有渲染通过 innerHTML 拼接。

/**
 * @typedef {{runId?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, stateRoot?: string|null, source?: string|null}} Traceability
 * @typedef {{id?: string|null, runId?: string|null, scenarioId?: string|null, status?: string|null, observerId?: string|null, stateDir?: string|null, reportJsonSha256?: string|null, findingCount?: number, artifactCount?: number, updatedAt?: string|null, maxSeverity?: string|null, traceability?: Traceability}} SentinelRun
 * @typedef {{ok: boolean, status?: string, node?: string, lane?: string, publicOrigin?: string|null, latestRun?: SentinelRun|null, runCounts?: Record<string, number>, severityCounts?: Record<string, number>, freshness?: Record<string, unknown>, targetValidation?: {maxSeconds?: number, scenarioId?: string|null}, valuesRedacted?: boolean}} SentinelOverview
 * @typedef {{code?: string|null, findingId?: string|null, severity?: string|null, scenarioId?: string|null, count?: number, runCount?: number, latestAt?: string|null, latestRunId?: string|null, latestReportJsonSha256?: string|null, summary?: string|null, traceability?: Traceability|null}} SentinelFindingGroup
 * @typedef {{groups?: SentinelFindingGroup[], findings?: SentinelFindingGroup[], page?: {limit?: number, hasMore?: boolean}}} SentinelFindingsResponse
 * @typedef {{items?: SentinelRun[], runs?: SentinelRun[], page?: {limit?: number, nextCursor?: string|null, hasMore?: boolean}}} SentinelRunsResponse
 * @typedef {{ok?: boolean, view?: string, renderedText?: string, renderedTextBytes?: number, truncated?: boolean, error?: string|null, finalResponse?: Record<string, unknown>|null}} SentinelRunView
 * @typedef {{ok?: boolean, run?: SentinelRun, summary?: Record<string, unknown>, findings?: Record<string, unknown>[], artifacts?: Record<string, unknown>, commands?: Record<string, string>, redaction?: Record<string, unknown>, traceability?: Traceability, viewsAvailable?: string[], valuesRedacted?: boolean}} SentinelRunDetail
 */

const root = document.getElementById("sentinel-dashboard");
const basePath = normalizeBasePath(root?.dataset?.basePath || "");
const refs = {
  statusPill: document.getElementById("status-pill"),
  loadingBanner: document.getElementById("loading-banner"),
  errorBanner: document.getElementById("error-banner"),
  manualRefresh: document.getElementById("manual-refresh"),
  autoRefreshEnabled: document.getElementById("auto-refresh-enabled"),
  autoRefreshInterval: document.getElementById("auto-refresh-interval"),
  latestRun: document.getElementById("latest-run"),
  filterRed: document.getElementById("filter-red"),
  originNote: document.getElementById("sentinel-origin-note"),
  statusSummary: document.getElementById("status-summary"),
  summaryStatus: document.getElementById("summary-status"),
  summaryLatest: document.getElementById("summary-latest"),
  summaryLatestAge: document.getElementById("summary-latest-age"),
  summaryFindings: document.getElementById("summary-findings"),
  summaryFindingsNote: document.getElementById("summary-findings-note"),
  summaryScheduler: document.getElementById("summary-scheduler"),
  summaryBudget: document.getElementById("summary-budget"),
  summaryChecks: document.getElementById("summary-checks"),
  overviewChecks: document.getElementById("overview-checks"),
  checkSummaryPill: document.getElementById("check-summary-pill"),
  checkConfig: document.getElementById("check-config"),
  checkPvc: document.getElementById("check-pvc"),
  checkAnalyzer: document.getElementById("check-analyzer"),
  checkPublic: document.getElementById("check-public"),
  checkMaintenance: document.getElementById("check-maintenance"),
  timeline: document.getElementById("run-timeline"),
  timelineCount: document.getElementById("timeline-count"),
  timelineToggle: document.getElementById("timeline-toggle"),
  runsFilterSummary: document.getElementById("runs-filter-summary"),
  filterForm: document.getElementById("runs-filter"),
  filterStatus: document.getElementById("filter-status"),
  filterSeverity: document.getElementById("filter-severity"),
  filterWindow: document.getElementById("filter-window"),
  filterSort: document.getElementById("filter-sort"),
  filterSearch: document.getElementById("filter-search"),
  clearFilters: document.getElementById("clear-filters"),
  runsBody: document.getElementById("runs-body"),
  runsCount: document.getElementById("runs-count"),
  findingsFilterSummary: document.getElementById("findings-filter-summary"),
  findingsFilterForm: document.getElementById("findings-filter"),
  findingFilterSeverity: document.getElementById("finding-filter-severity"),
  findingFilterWindow: document.getElementById("finding-filter-window"),
  findingFilterCode: document.getElementById("finding-filter-code"),
  findingFilterScenario: document.getElementById("finding-filter-scenario"),
  findingClearFilters: document.getElementById("finding-clear-filters"),
  findingAggregation: document.getElementById("finding-aggregation"),
  findingsList: document.getElementById("findings-list"),
  findingsCount: document.getElementById("findings-count"),
  findingsDrilldown: document.getElementById("findings-drilldown"),
  detailTabs: document.getElementById("detail-tabs"),
  detailSubtitle: document.getElementById("detail-subtitle"),
  detailContent: document.getElementById("detail-content"),
  copyToast: document.getElementById("copy-toast"),
};

const DETAIL_TABS = ["overview", "findings", "turn", "trace", "evidence"];

const state = {
  loading: false,
  selectedRunId: null,
  /** @type {SentinelOverview|null} */
  overview: null,
  /** @type {SentinelRun[]} */
  runs: [],
  /** @type {SentinelFindingGroup[]} */
  findings: [],
  /** @type {SentinelRunDetail|null} */
  runDetail: null,
  runViews: null,
  selectedTraceChoiceIndex: 0,
  lastUpdatedAt: null,
  filters: readFiltersFromLocation(),
  findingFilters: readFindingFiltersFromLocation(),
  selectedTab: readTabFromLocation(),
  selectedFindingCode: new URLSearchParams(window.location.search).get("finding") || "",
  findingDrilldownKey: "",
  runsFilterOpen: false,
  findingsFilterOpen: false,
  checksExpanded: false,
  timelineCollapsed: false,
  pausedByInteraction: false,
};

const dashboardLimits = {
  timeline: { mobile: 6, tablet: 9, desktop: 16 },
  runs: { mobile: 8, tablet: 12, desktop: 30 },
  findingsRed: { mobile: 4, tablet: 6, desktop: 8 },
  findingsOther: { mobile: 2, tablet: 3, desktop: 5 },
};

const dashboardApi = createDashboardApi();
const autoRefresh = createAutoRefresh({
  storageKey: "hwlab.webProbeSentinel.dashboard.autoRefresh",
  intervals: [5, 10, 30],
  defaultInterval: 10,
  onRefresh: () => loadDashboard({ silent: true }),
  shouldPause: () => document.hidden || state.loading || state.pausedByInteraction,
});

refs.manualRefresh.addEventListener("click", () => loadDashboard({ silent: false }));
refs.autoRefreshEnabled.addEventListener("change", () => autoRefresh.setEnabled(refs.autoRefreshEnabled.checked));
refs.autoRefreshInterval.addEventListener("change", () => autoRefresh.setInterval(Number(refs.autoRefreshInterval.value)));
refs.latestRun.addEventListener("click", () => jumpToLatestRun());
refs.filterRed.addEventListener("click", () => toggleRedOnly());
refs.runsFilterSummary.addEventListener("click", () => toggleRunsFilter());
refs.findingsFilterSummary.addEventListener("click", () => toggleFindingsFilter());
refs.checkSummaryPill.addEventListener("click", () => toggleChecks());
refs.timelineToggle.addEventListener("click", () => toggleTimeline());
refs.filterForm.addEventListener("submit", (event) => event.preventDefault());
for (const control of [refs.filterStatus, refs.filterSeverity, refs.filterWindow, refs.filterSort]) {
  control.addEventListener("change", () => applyFilterControls());
}
refs.filterSearch.addEventListener("input", debounce(() => applyFilterControls(), 250));
refs.clearFilters.addEventListener("click", () => {
  state.filters = { status: "", severity: "", window: "", search: "", sort: "updated" };
  writeFiltersToControls();
  updateRunsFilterSummary();
  syncLocationQuery();
  loadDashboard({ silent: false });
});
refs.findingsFilterForm.addEventListener("submit", (event) => event.preventDefault());
for (const control of [refs.findingFilterSeverity, refs.findingFilterWindow]) {
  control.addEventListener("change", () => applyFindingFilterControls());
}
for (const control of [refs.findingFilterCode, refs.findingFilterScenario]) {
  control.addEventListener("input", debounce(() => applyFindingFilterControls(), 250));
}
refs.findingClearFilters.addEventListener("click", () => {
  state.findingFilters = { severity: "", window: "24h", code: "", scenario: "" };
  state.findingDrilldownKey = "";
  writeFindingFiltersToControls();
  updateFindingsFilterSummary();
  syncLocationQuery();
  loadDashboard({ silent: false });
});
refs.detailTabs.addEventListener("click", (event) => {
  const button = event.target.closest("[data-detail-tab]");
  if (button) selectTab(button.dataset.detailTab);
});
document.addEventListener("visibilitychange", () => {
  if (!document.hidden && autoRefresh.enabled()) loadDashboard({ silent: true });
});
document.addEventListener("keydown", handleKeyboardShortcut);
window.addEventListener("resize", debounce(updateViewportClass, 150));

// 交互暂停：hover runs 表格或在 detail tab 阅读时暂停 auto-refresh
refs.runsBody.addEventListener("mouseenter", () => { state.pausedByInteraction = true; }, true);
refs.runsBody.addEventListener("mouseleave", () => { state.pausedByInteraction = false; }, true);
refs.detailContent.addEventListener("mouseenter", () => { state.pausedByInteraction = true; }, true);
refs.detailContent.addEventListener("mouseleave", () => { state.pausedByInteraction = false; }, true);

hydrateControls();
writeFiltersToControls();
writeFindingFiltersToControls();
updateRunsFilterSummary();
updateFindingsFilterSummary();
updateViewportClass();
loadDashboard({ silent: false }).catch((error) => renderError(error));

function createDashboardApi() {
  return {
    /** @returns {Promise<SentinelOverview>} */
    overview: () => getJson(apiPath("/api/overview")),
    /** @returns {Promise<SentinelRunsResponse>} */
    runs: (filters) => getJson(apiPath(`/api/runs?${runsQuery(filters)}`)),
    /** @returns {Promise<SentinelFindingsResponse>} */
    findings: (filters) => getJson(apiPath(`/api/findings?${findingsQuery(filters)}`)),
    /** @returns {Promise<SentinelRunDetail>} */
    runDetail: (runId) => getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}`)),
    runViews: (runId, view = null) => {
      const query = new URLSearchParams({ maxBytes: "24000" });
      if (view) query.set("view", view);
      return getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`));
    },
  };
}

function apiPath(path) {
  return `${basePath}${path}`;
}

function normalizeBasePath(value) {
  const text = String(value || "").replace(/\/+$/u, "");
  if (!text || text === "/") return "";
  return text.startsWith("/") ? text : `/${text}`;
}

async function getJson(path) {
  const response = await fetch(path, { headers: { accept: "application/json" } });
  const body = await response.json().catch(() => ({}));
  if (!response.ok || body.ok === false) {
    const message = body.error || `${response.status} ${response.statusText}`;
    throw new Error(message);
  }
  return body;
}

function createAutoRefresh(options) {
  let enabled = false;
  let intervalSeconds = options.defaultInterval;
  let countdown = intervalSeconds;
  let timer = null;

  try {
    const saved = JSON.parse(localStorage.getItem(options.storageKey) || "{}");
    if (saved.enabled === true) enabled = true;
    if (options.intervals.includes(Number(saved.intervalSeconds))) intervalSeconds = Number(saved.intervalSeconds);
  } catch {
    enabled = false;
  }

  function persist() {
    try {
      localStorage.setItem(options.storageKey, JSON.stringify({ enabled, intervalSeconds }));
    } catch {
      /* ignore */
    }
  }

  async function tick() {
    if (!enabled || options.shouldPause()) return;
    if (countdown > 0) {
      countdown -= 1;
      return;
    }
    countdown = intervalSeconds;
    await options.onRefresh();
  }

  function start() {
    if (timer !== null) return;
    timer = window.setInterval(tick, 1000);
  }

  function stop() {
    if (timer === null) return;
    window.clearInterval(timer);
    timer = null;
  }

  return {
    enabled: () => enabled,
    intervalSeconds: () => intervalSeconds,
    setEnabled(value) {
      enabled = value;
      countdown = intervalSeconds;
      persist();
      if (enabled) start();
      else stop();
    },
    setInterval(value) {
      if (!options.intervals.includes(value)) return;
      intervalSeconds = value;
      countdown = value;
      persist();
    },
    start,
  };
}

function hydrateControls() {
  refs.autoRefreshEnabled.checked = autoRefresh.enabled();
  refs.autoRefreshInterval.value = String(autoRefresh.intervalSeconds());
  if (autoRefresh.enabled()) autoRefresh.start();
}

async function loadDashboard(options) {
  if (state.loading) return;
  state.loading = true;
  renderLoading(!options.silent);
  try {
    const [overview, runs, findings] = await Promise.all([
      dashboardApi.overview(),
      dashboardApi.runs(state.filters),
      dashboardApi.findings(state.findingFilters),
    ]);
    state.overview = overview;
    state.runs = Array.isArray(runs.items) ? runs.items : Array.isArray(runs.runs) ? runs.runs : [];
    state.findings = Array.isArray(findings.groups) ? findings.groups : Array.isArray(findings.findings) ? findings.findings : [];
    state.lastUpdatedAt = new Date();
    const queryRun = new URLSearchParams(window.location.search).get("run");
    if (queryRun && state.selectedRunId === null) state.selectedRunId = queryRun;
    if (state.selectedRunId && !state.runs.some((run) => run.runId === state.selectedRunId || run.id === state.selectedRunId)) {
      state.selectedRunId = null;
      state.runDetail = null;
      state.runViews = null;
      state.selectedTraceChoiceIndex = 0;
    }
    if (state.selectedRunId === null) {
      const latestRunId = latestRunIdFrom(overview, state.runs);
      if (latestRunId) state.selectedRunId = latestRunId;
    }
    renderDashboard();
    if (state.selectedRunId && state.runDetail === null) await selectRun(state.selectedRunId);
  } catch (error) {
    renderError(error);
  } finally {
    state.loading = false;
    renderLoading(false);
  }
}

async function selectRun(runId) {
  if (!runId) return;
  state.selectedRunId = runId;
  state.runDetail = null;
  state.runViews = null;
  state.selectedTraceChoiceIndex = 0;
  syncLocationQuery();
  renderRuns();
  refs.detailSubtitle.textContent = runId;
  refs.detailTabs.hidden = false;
  refs.detailContent.innerHTML = '<div class="empty-state">加载中</div>';
  try {
    const [detail, views] = await Promise.all([
      dashboardApi.runDetail(runId),
      loadRunViews(runId),
    ]);
    state.runDetail = detail;
    state.runViews = views;
    renderDetail();
  } catch (error) {
    refs.detailContent.innerHTML = `<div class="empty-state">${escapeHtml(error.message || String(error))}</div>`;
  }
}

async function loadRunViews(runId, view = null) {
  try {
    return await dashboardApi.runViews(runId, view);
  } catch (error) {
    return { ok: false, error: error?.message || String(error), valuesRedacted: true };
  }
}

function renderDashboard() {
  refs.errorBanner.hidden = true;
  renderOverview();
  renderTimeline();
  renderRuns();
  renderFindings();
  renderDetail();
}

function renderOverview() {
  const overview = state.overview || {};
  const status = overview.status || (overview.ok ? "healthy" : "degraded");
  const latest = overview.latestRun || null;
  const latestStatus = latest?.status || status;
  refs.statusPill.textContent = displayStatus(latestStatus);
  refs.statusPill.className = `status-pill ${statusClass(latestStatus)}`;

  // 状态摘要条合并 status-pill 信息（B1 去重：不再单独显示 metric-overall 重复值）
  refs.statusSummary.hidden = false;
  refs.summaryStatus.textContent = displayStatus(latestStatus);
  refs.summaryStatus.className = statusClass(latestStatus);
  refs.originNote.textContent = overview.publicOrigin || root.dataset.publicOrigin || "";

  refs.summaryLatest.textContent = latest?.runId || latest?.id || "-";
  refs.summaryLatestAge.textContent = latest?.updatedAt ? `${formatRelative(latest.updatedAt)} 更新` : "-";

  const severityCounts = overview.severityCounts || {};
  const totalFindings = Object.values(severityCounts).reduce((sum, value) => sum + Number(value || 0), 0);
  refs.summaryFindings.textContent = formatNumber(totalFindings);
  refs.summaryFindingsNote.textContent = formatSeveritySummary(severityCounts);

  const scheduler = overview.scheduler || {};
  const heartbeat = scheduler.heartbeat || {};
  refs.summaryScheduler.textContent = heartbeat.at ? formatRelative(heartbeat.at) : "未知";
  const maxSeconds = overview.targetValidation?.maxSeconds ?? 120;
  refs.summaryBudget.textContent = `预算 ${maxSeconds}s`;

  const checks = overview.health?.checks || {};
  const checkResults = [
    { label: "配置", ok: checks.config?.ok, detail: checks.config?.status },
    { label: "PVC", ok: checks.pvc?.ok, detail: checks.pvc?.stateRoot },
    { label: "分析器", ok: checks.analyzer?.ok, detail: "observe analyze" },
    { label: "公开入口", ok: Boolean(overview.publicOrigin), detail: overview.publicOrigin || "-" },
  ];
  const maintenance = overview.maintenance || {};
  checkResults.push({ label: "维护窗口", ok: maintenance.active !== true, detail: maintenance.active ? "生效中" : "未生效" });

  const allOk = checkResults.every((item) => item.ok);
  const okCount = checkResults.filter((item) => item.ok).length;
  refs.summaryChecks.hidden = false;
  refs.checkSummaryPill.textContent = allOk ? `✓ ${okCount}/${checkResults.length} 检查通过` : `${okCount}/${checkResults.length} 检查`;
  refs.checkSummaryPill.className = `check-summary-pill ${allOk ? "check-ok" : "check-blocked"}`;
  refs.overviewChecks.hidden = false;

  renderCheckChip(refs.checkConfig, "配置", checks.config?.ok, checks.config?.status);
  renderCheckChip(refs.checkPvc, "PVC", checks.pvc?.ok, checks.pvc?.stateRoot);
  renderCheckChip(refs.checkAnalyzer, "分析器", checks.analyzer?.ok, "observe analyze");
  renderCheckChip(refs.checkPublic, "公开入口", Boolean(overview.publicOrigin), overview.publicOrigin || "-");
  renderCheckChip(refs.checkMaintenance, "维护窗口", maintenance.active !== true, maintenance.active ? "生效中" : "未生效");

  // B3: 全绿时默认折叠 overview checks 明细
  refs.overviewChecks.classList.toggle("overview-checks-collapsed", !state.checksExpanded);
}

function renderTimeline() {
  const visibleRuns = state.runs.slice(0, responsiveLimit(dashboardLimits.timeline));
  refs.timelineCount.textContent = `最近 ${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)}`;
  if (state.runs.length === 0) {
    refs.timeline.innerHTML = '<div class="empty-state compact">暂无时间线</div>';
    return;
  }
  refs.timeline.innerHTML = visibleRuns.map((run) => {
    const runId = run.runId || run.id || "-";
    const age = run.updatedAt ? formatRelative(run.updatedAt) : "-";
    const title = `${displayStatus(run.status)} · ${run.findingCount ?? 0} 个发现 · ${age}`;
    return `<button class="timeline-node ${statusClass(run.status)}" type="button" data-run-id="${escapeAttr(runId)}" title="${escapeAttr(title)}">
      <span class="timeline-label">${escapeHtml(displayStatus(run.status))}</span>
      <span class="timeline-age">${escapeHtml(age)}</span>
    </button>`;
  }).join("");
  for (const node of refs.timeline.querySelectorAll("[data-run-id]")) {
    node.addEventListener("click", () => selectRun(node.dataset.runId));
  }
}

function renderRuns() {
  const visibleLimit = responsiveLimit(dashboardLimits.runs);
  const visibleRuns = state.runs.slice(0, visibleLimit);
  const hiddenCount = Math.max(0, state.runs.length - visibleRuns.length);
  refs.runsCount.textContent = `${formatNumber(visibleRuns.length)} / ${formatNumber(state.runs.length)} 条`;
  if (state.runs.length === 0) {
    refs.runsBody.innerHTML = '<tr><td class="empty-state" colspan="5">暂无运行</td></tr>';
    return;
  }
  refs.runsBody.innerHTML = visibleRuns.map((run) => {
    const runId = run.runId || run.id || "-";
    const selected = state.selectedRunId === runId ? " selected-row" : "";
    const severityRowClass = run.maxSeverity ? ` ${severityClass(run.maxSeverity)}` : "";
    return `<tr class="${selected}${severityRowClass}" data-run-id="${escapeAttr(runId)}">
      <td data-label="运行"><div class="run-identity"><code>${escapeHtml(shortText(runId, 24))}</code><small>${escapeHtml(run.observerId ? shortText(run.observerId, 28) : "-")}</small></div></td>
      <td data-label="状态"><span class="status-pill ${statusClass(run.status)}">${escapeHtml(displayStatus(run.status))}</span></td>
      <td data-label="场景">${escapeHtml(run.scenarioId || "-")}</td>
      <td data-label="发现">${escapeHtml(String(run.findingCount ?? 0))}${run.maxSeverity ? ` <span class="severity-pill ${severityClass(run.maxSeverity)}">${escapeHtml(displaySeverity(run.maxSeverity))}</span>` : ""}</td>
      <td data-label="更新">${escapeHtml(run.updatedAt ? formatRelative(run.updatedAt) : "-")}${run.maintenance ? `<small>维护窗口</small>` : ""}</td>
    </tr>`;
  }).join("") + (hiddenCount > 0 ? `<tr class="limit-row"><td colspan="5">其余 ${formatNumber(hiddenCount)} 条通过筛选、搜索或 API drill-down 查看</td></tr>` : "");
  for (const row of refs.runsBody.querySelectorAll("tr[data-run-id]")) {
    row.addEventListener("click", () => selectRun(row.dataset.runId));
  }
}

function renderFindings() {
  renderFindingAggregation();
  refs.findingsCount.textContent = `${formatNumber(state.findings.length)} 组`;
  if (state.findingDrilldownKey) {
    renderFindingsDrilldown();
    return;
  }
  refs.findingsList.hidden = false;
  refs.findingsDrilldown.hidden = true;
  if (state.findings.length === 0) {
    refs.findingsList.innerHTML = '<div class="empty-state">暂无发现项</div>';
    return;
  }
  // B8: 默认只显示聚合，list 折叠为 severity group summary（点击展开明细）
  const groups = groupedFindingsBySeverity(state.findings);
  refs.findingsList.innerHTML = groups.map((group) => {
    const visibleItems = group.items.slice(0, findingVisibleLimit(group.key));
    const hiddenCount = Math.max(0, group.items.length - visibleItems.length);
    const defaultOpen = group.items.some(findingHasRootCause) || isBlockingSeverity(group.key) || groups.length <= 2;
    return `<details class="finding-group finding-group-${escapeAttr(group.key)}"${defaultOpen ? " open" : ""}>
      <summary>
        <span>${escapeHtml(displaySeverity(group.key))}</span>
        <strong>${formatNumber(group.totalCount)}</strong>
        <small>${formatNumber(group.items.length)} 组${hiddenCount > 0 ? ` · 其余 ${formatNumber(hiddenCount)} 组` : ""}</small>
      </summary>
      <div class="finding-group-list">${visibleItems.map((item) => renderFindingItemCollapsed(item)).join("")}</div>
    </details>`;
  }).join("");
  bindFindingItemEvents();
}

function renderFindingsDrilldown() {
  refs.findingsList.hidden = true;
  refs.findingsDrilldown.hidden = false;
  const [filterKey, filterValue] = state.findingDrilldownKey.split(":", 2);
  const matched = state.findings.filter((item) => {
    if (filterKey === "severity") return String(item.severity || "unknown").toLowerCase() === filterValue;
    if (filterKey === "code") return (item.code || item.findingId || "unknown") === filterValue;
    if (filterKey === "scenario") return (item.scenarioId || "unknown") === filterValue;
    return false;
  });
  const label = drilldownLabel(filterKey, filterValue);
  refs.findingsDrilldown.innerHTML = `<button type="button" class="link-button findings-drilldown-back" id="findings-drilldown-back">← 返回聚合</button>
    <div class="view-note">drill-down: ${escapeHtml(label)} · ${formatNumber(matched.length)} 组</div>
    <div class="finding-group-list">${matched.map((item) => renderFindingItemCollapsed(item)).join("")}</div>`;
  document.getElementById("findings-drilldown-back")?.addEventListener("click", () => {
    state.findingDrilldownKey = "";
    syncLocationQuery();
    renderFindings();
  });
  bindFindingItemEvents(refs.findingsDrilldown);
}

function drilldownLabel(filterKey, filterValue) {
  if (filterKey === "severity") return `严重级别=${displaySeverity(filterValue)}`;
  if (filterKey === "code") return `代码=${displayFindingCode(filterValue)}`;
  if (filterKey === "scenario") return `场景=${filterValue}`;
  return filterValue;
}

function renderFindingItemCollapsed(item) {
  const code = item.code || item.findingId || "finding";
  const latestRunId = item.latestRunId || "-";
  const hasLatestRun = latestRunId !== "-";
  const codeLabel = displayFindingCode(code);
  const rootCause = findingRootCauseText(item);
  const evidence = findingEvidenceText(item);
  const nextAction = item.nextAction || findingNextAction(code);
  const directCause = rootCause || nextAction;
  return `<article class="finding-item is-collapsed" data-finding-run-id="${escapeAttr(latestRunId)}">
    <div class="finding-row" data-finding-toggle>
      <span class="finding-title">${escapeHtml(codeLabel)}${codeLabel === code ? "" : ` <small class="mono">${escapeHtml(code)}</small>`}<small> · ${formatNumber(item.count ?? 0)} 次</small></span>
      <span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span>
    </div>
    ${directCause ? `<div class="finding-direct-cause"><strong>${rootCause ? "根因" : "下一步"}</strong><span>${escapeHtml(shortText(directCause, 180))}</span></div>` : ""}
    <div class="finding-detail">
      <div class="finding-summary">${escapeHtml(shortText(displayFindingSummary(code, item.summary || ""), 220))}</div>
      <div class="finding-actions">
        <button type="button" class="filter-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(code)}</button>
        <button type="button" class="filter-chip" data-finding-filter="scenario" data-filter-value="${escapeAttr(item.scenarioId || "")}">${escapeHtml(item.scenarioId || "-")}</button>
      </div>
      <div class="finding-meta">次数=${escapeHtml(String(item.count ?? 0))} · 运行=${escapeHtml(String(item.runCount ?? 0))} · 最近=${escapeHtml(item.latestAt ? formatRelative(item.latestAt) : "-")}</div>
      <div class="finding-meta mono">run=${escapeHtml(latestRunId)} report=${escapeHtml(item.latestReportJsonSha256 || "-")}</div>
      ${rootCause ? `<div class="finding-root-cause"><strong>根因</strong><span>${escapeHtml(rootCause)}</span></div>` : ""}
      ${evidence ? `<div class="finding-meta mono">evidence=${escapeHtml(evidence)}</div>` : ""}
      <div class="finding-meta">${escapeHtml(nextAction)}</div>
      <button type="button" class="link-button" data-open-finding-run="${escapeAttr(latestRunId)}"${hasLatestRun ? "" : " disabled"}>打开最近运行</button>
    </div>
  </article>`;
}

function bindFindingItemEvents(scope) {
  const container = scope || refs.findingsList;
  for (const row of container.querySelectorAll("[data-finding-toggle]")) {
    row.addEventListener("click", (event) => {
      if (event.target.closest("[data-open-finding-run]") || event.target.closest("[data-finding-filter]")) return;
      const item = row.closest(".finding-item");
      if (item) item.classList.toggle("is-collapsed");
    });
  }
  for (const button of container.querySelectorAll("[data-open-finding-run]")) {
    button.addEventListener("click", () => selectRun(button.dataset.openFindingRun));
  }
  for (const button of container.querySelectorAll("[data-finding-filter]")) {
    button.addEventListener("click", () => applyFindingFilter(button.dataset.findingFilter, button.dataset.filterValue || ""));
  }
}

function responsiveLimit(limits) {
  if (window.matchMedia("(max-width: 560px)").matches) return limits.mobile;
  if (window.matchMedia("(max-width: 980px)").matches) return limits.tablet;
  return limits.desktop;
}

function findingVisibleLimit(severity) {
  const key = String(severity || "").toLowerCase();
  return responsiveLimit(key === "red" || key === "critical" || key === "error" ? dashboardLimits.findingsRed : dashboardLimits.findingsOther);
}

function groupedFindingsBySeverity(findings) {
  const severityOrder = ["red", "critical", "error", "warning", "amber", "info", "unknown"];
  const groups = new Map();
  for (const item of findings) {
    const key = String(item.severity || "unknown").toLowerCase();
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(item);
  }
  return Array.from(groups.entries())
    .map(([key, items]) => ({
      key,
      items: items.sort(compareFindingsForVisibility),
      totalCount: items.reduce((sum, item) => sum + Number(item.count || 0), 0),
    }))
    .sort((a, b) => severityRank(a.key, severityOrder) - severityRank(b.key, severityOrder));
}

function compareFindingsForVisibility(a, b) {
  return Number(findingHasRootCause(b)) - Number(findingHasRootCause(a))
    || Number(b.count || 0) - Number(a.count || 0)
    || String(a.code || a.findingId || "").localeCompare(String(b.code || b.findingId || ""));
}

function findingHasRootCause(item) {
  return Boolean(findingRootCauseText(item));
}

function isBlockingSeverity(severity) {
  const key = String(severity || "").toLowerCase();
  return key === "red" || key === "critical" || key === "error";
}

function severityRank(key, order) {
  const index = order.indexOf(key);
  return index >= 0 ? index : order.length;
}

function renderFindingAggregation() {
  if (state.findings.length === 0) {
    refs.findingAggregation.innerHTML = '<div class="empty-state compact">暂无发现聚合</div>';
    return;
  }
  const severityEntries = aggregateFindings((item) => item.severity || "unknown").slice(0, 5);
  const codeEntries = aggregateFindings((item) => item.code || item.findingId || "unknown").slice(0, 5);
  const scenarioEntries = aggregateFindings((item) => item.scenarioId || "unknown").slice(0, 5);
  const rootCauseEntries = state.findings.filter(findingHasRootCause).sort(compareFindingsForVisibility).slice(0, 3);
  refs.findingAggregation.innerHTML = [
    aggregationGroup("严重级别", severityEntries.map((item) => ({ ...item, label: displaySeverity(item.key) })), "severity"),
    aggregationGroup("代码", codeEntries.map((item) => ({ ...item, label: displayFindingCode(item.key) })), "code"),
    rootCauseEntries.length > 0 ? aggregationRootCauseGroup(rootCauseEntries) : "",
    aggregationGroup("场景", scenarioEntries, "scenario"),
    `<div class="aggregation-group"><span>窗口</span><button type="button" class="filter-chip active">${escapeHtml(state.findingFilters.window || "全部")}</button></div>`,
  ].join("");
  for (const button of refs.findingAggregation.querySelectorAll("[data-finding-filter]")) {
    button.addEventListener("click", () => openFindingsDrilldown(button.dataset.findingFilter, button.dataset.filterValue || ""));
  }
}

function aggregationGroup(label, entries, filterKey) {
  const chips = entries.length === 0
    ? '<span class="finding-meta">-</span>'
    : entries.map((entry) => `<button type="button" class="filter-chip" data-finding-filter="${escapeAttr(filterKey)}" data-filter-value="${escapeAttr(entry.key)}">${escapeHtml(entry.label || entry.key)} ${formatNumber(entry.count)}</button>`).join("");
  return `<div class="aggregation-group"><span>${escapeHtml(label)}</span>${chips}</div>`;
}

function aggregationRootCauseGroup(entries) {
  const chips = entries.map((item) => {
    const code = item.code || item.findingId || "finding";
    const text = findingRootCauseText(item);
    return `<button type="button" class="filter-chip root-cause-chip" data-finding-filter="code" data-filter-value="${escapeAttr(code)}">${escapeHtml(displayFindingCode(code))}: ${escapeHtml(shortText(text, 96))}</button>`;
  }).join("");
  return `<div class="aggregation-group aggregation-root-cause"><span>根因</span>${chips}</div>`;
}

function aggregateFindings(keyFn) {
  const counts = new Map();
  for (const item of state.findings) {
    const key = keyFn(item);
    counts.set(key, (counts.get(key) || 0) + Number(item.count || 0));
  }
  return Array.from(counts.entries())
    .map(([key, count]) => ({ key, count }))
    .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key));
}

function openFindingsDrilldown(filterKey, filterValue) {
  if (!filterValue || filterValue === "-") return;
  state.findingDrilldownKey = `${filterKey}:${filterValue}`;
  syncLocationQuery();
  renderFindings();
}

function applyFindingFilter(key, value) {
  if (!value || value === "-") return;
  if (key === "severity") state.findingFilters.severity = value;
  if (key === "code") state.findingFilters.code = value;
  if (key === "scenario") state.findingFilters.scenario = value;
  writeFindingFiltersToControls();
  updateFindingsFilterSummary();
  syncLocationQuery();
  loadDashboard({ silent: false });
}

function selectTab(tab) {
  if (!DETAIL_TABS.includes(tab)) return;
  state.selectedTab = tab;
  for (const button of refs.detailTabs.querySelectorAll("[data-detail-tab]")) {
    button.classList.toggle("active", button.dataset.detailTab === tab);
  }
  syncLocationQuery();
  renderDetail();
}

function renderDetail() {
  if (!state.selectedRunId || !state.runDetail) {
    refs.detailSubtitle.textContent = "未选择运行";
    refs.detailTabs.hidden = true;
    refs.detailContent.innerHTML = '<div class="empty-state">请选择一条运行</div>';
    return;
  }
  const tab = state.selectedTab || "overview";
  for (const button of refs.detailTabs.querySelectorAll("[data-detail-tab]")) {
    button.classList.toggle("active", button.dataset.detailTab === tab);
  }
  const detail = state.runDetail;
  const run = detail.run || {};
  const findings = Array.isArray(detail.findings) ? detail.findings : [];
  const artifacts = detail.artifacts || {};
  const commands = detail.commands || {};
  const turnSummaryView = selectedView(state.runViews, "turn-summary");
  refs.detailSubtitle.textContent = run.runId || state.selectedRunId;
  refs.detailContent.innerHTML = renderDetailTab(tab, detail, run, findings, artifacts, commands, turnSummaryView);
  bindDetailControls();
}

function renderDetailTab(tab, detail, run, findings, artifacts, commands, turnSummaryView) {
  if (tab === "findings") {
    return detailFindings(findings);
  }
  if (tab === "turn") {
    return detailTurnSummary(turnSummaryView);
  }
  if (tab === "trace") {
    return detailTraceReader(state.runViews, commands);
  }
  if (tab === "evidence") {
    return [
      detailArtifacts(artifacts),
      detailCommands(commands),
      detailEvidence(detail, artifacts),
      detailBlock("脱敏", [
        ["values", detail.valuesRedacted === true ? "已脱敏" : "-"],
        ["prompt", detail.redaction?.prompt || "-"],
        ["assistant", detail.redaction?.assistantFinal || "-"],
      ]),
    ].join("");
  }
  // overview
  return [
    detailBlock("追溯信息", [
      ["run", run.runId || "-"],
      ["observer", run.observerId || "-"],
      ["stateDir", run.stateDir || "-"],
      ["report", run.reportJsonSha256 || "-"],
    ]),
    detailBlock("摘要", [
      ["status", displayStatus(run.status)],
      ["scenario", run.scenarioId || "-"],
      ["findings", String(run.findingCount ?? 0)],
      ["artifacts", String(run.artifactCount ?? 0)],
      ["updated", run.updatedAt || "-"],
      ["views", Array.isArray(detail.viewsAvailable) ? detail.viewsAvailable.join(", ") : "-"],
    ]),
    detailBlock("报告摘要", safeSummaryRows(detail.summary)),
  ].join("");
}

function detailBlock(title, rows, extraClass) {
  const body = rows.map(([key, value]) => `<div><span>${escapeHtml(key)}</span><code>${escapeHtml(String(value ?? "-"))}</code></div>`).join("");
  return `<article class="detail-block${extraClass ? ` ${extraClass}` : ""}"><strong>${escapeHtml(title)}</strong><div class="detail-grid">${body}</div></article>`;
}

function detailFindings(findings) {
  if (!Array.isArray(findings) || findings.length === 0) {
    return '<article class="detail-block"><strong>运行发现项</strong><div class="empty-state compact">暂无发现项</div></article>';
  }
  return `<article class="detail-block"><strong>运行发现项 · ${formatNumber(findings.length)} 条</strong>
    <div class="detail-table-frame"><table class="detail-table"><thead><tr>
      <th>严重级别</th><th>代码</th><th>次数</th><th>摘要</th><th>根因</th><th>报告</th>
    </tr></thead><tbody>${findings.map((item) => `<tr>
      <td><span class="severity-pill ${severityClass(item.severity)}">${escapeHtml(displaySeverity(item.severity))}</span></td>
      <td class="mono">${escapeHtml(item.finding_id || item.findingId || "-")}</td>
      <td>${escapeHtml(String(item.count ?? 0))}</td>
      <td>${escapeHtml(shortText(displayFindingSummary(item.finding_id || item.findingId || "", item.summary || ""), 220))}</td>
      <td>${escapeHtml(shortText(findingRootCauseText(item) || item.nextAction || "-", 240))}</td>
      <td class="mono">${escapeHtml(shortText(item.report_json_sha256 || item.reportJsonSha256 || "-", 24))}</td>
    </tr>`).join("")}</tbody></table></div>
  </article>`;
}

function detailArtifacts(artifacts) {
  const screenshot = artifacts.screenshot || {};
  return detailBlock("产物", [
    ["artifactCount", String(artifacts.artifactCount ?? "-")],
    ["reportJsonSha256", artifacts.reportJsonSha256 || "-"],
    ["screenshotPath", screenshot.path || "-"],
    ["screenshotSha256", screenshot.sha256 || "-"],
    ["publicOrigin", artifacts.publicOrigin || "-"],
  ]);
}

function detailCommands(commands) {
  const rows = [
    ["summary", commands.summary || "-"],
    ["turn-summary", commands.turnSummary || "-"],
    ["trace-frame", commands.traceFrame || "-"],
  ];
  // D4: CLI 命令改可复制按钮
  return `<article class="detail-block"><strong>CLI 对照命令</strong>
    <div class="command-list">${rows.map(([label, command]) => `<div class="command-row">
      <span class="command-label">${escapeHtml(label)}</span>
      <code class="command-code">${escapeHtml(command)}</code>
      <button type="button" class="copy-button" data-copy-command="${escapeAttr(command)}">复制</button>
    </div>`).join("")}</div>
  </article>`;
}

function detailTurnSummary(view) {
  if (!view) return detailBlock("多轮摘要 - 第一层", [["status", "未索引"]]);
  if (view.ok === false) return detailBlock("多轮摘要 - 第一层", [["status", view.error || "不可用"]]);
  const text = redactDisplayText(view.renderedText || "");
  const note = `${formatNumber(view.renderedTextBytes || text.length)} bytes${view.truncated ? " 已截断" : ""}`;
  return `<article class="detail-block"><strong>多轮摘要 - 第一层</strong>
    <div class="view-note">${escapeHtml(note)}</div>
    <pre class="detail-pre">${escapeHtml(text || "-")}</pre>
  </article>`;
}

function detailTraceReader(response, commands) {
  const turnView = selectedView(response, "turn-summary");
  const traceView = selectedView(response, "trace-frame");
  const choices = traceChoices(turnView?.renderedText || "", traceView?.renderedText || "");
  const selectedIndex = Math.min(state.selectedTraceChoiceIndex, Math.max(0, choices.length - 1));
  const selected = choices[selectedIndex] || { label: "已保存 trace-frame", meta: "当前运行", key: "stored" };
  const traceText = traceView?.ok === false ? "" : redactDisplayText(traceView?.renderedText || "");
  const traceNote = traceView
    ? `${formatNumber(traceView.renderedTextBytes || traceText.length)} bytes${traceView.truncated ? " 已截断" : ""}`
    : "trace-frame 未索引";
  return `<article class="detail-block trace-reader"><strong>Trace Frame - 第二层</strong>
    <div class="trace-reader-grid">
      <section class="trace-turn-picker" aria-label="turn、trace、sample 选择">
        <div class="view-note">选择 turn / trace / sample</div>
        <div class="trace-choice-list">${choices.map((choice, index) => `<button type="button" class="trace-choice ${index === selectedIndex ? "active" : ""}" data-trace-choice-index="${escapeAttr(String(index))}">
          <span>${escapeHtml(choice.label)}</span>
          <small>${escapeHtml(choice.meta)}</small>
        </button>`).join("")}</div>
      </section>
      <section class="trace-frame-view" aria-label="trace-frame 文字视图">
        <div class="view-note">已选择: ${escapeHtml(selected.label)} · ${escapeHtml(traceNote)}</div>
        <pre class="detail-pre trace-frame-pre">${escapeHtml(traceText || (traceView?.ok === false ? traceView.error || "trace-frame 不可用" : "trace-frame 未索引"))}</pre>
        ${finalResponseBlock(traceView)}
        <div class="trace-source-note mono">source=${escapeHtml(commands.traceFrame || "-")} · analyzer finding 不改写此文字证据</div>
      </section>
    </div>
  </article>`;
}

function finalResponseBlock(traceView) {
  if (!traceView) {
    return `<section class="final-response-block unavailable"><strong>Final Response</strong><div>trace-frame 未索引</div></section>`;
  }
  if (traceView.ok === false) {
    return `<section class="final-response-block unavailable"><strong>Final Response</strong><div>${escapeHtml(traceView.error || "trace-frame 不可用")}</div></section>`;
  }
  const block = traceView.finalResponse || {};
  const text = block.empty === true ? "(空内容)" : redactDisplayText(block.text || "");
  const bytes = Number(block.byteCount || 0);
  return `<section class="final-response-block ${block.empty === true ? "empty" : ""}">
    <strong>Final Response</strong>
    <div class="view-note">${block.empty === true ? "空内容" : "有内容"} · ${formatNumber(bytes)} bytes · 已脱敏</div>
    <pre class="final-response-text">${escapeHtml(text || "(空内容)")}</pre>
  </section>`;
}

function traceChoices(turnSummaryText, traceFrameText) {
  const sourceLines = String(turnSummaryText || "")
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => /(turn|trace|sample|final response|轮次|用户消息)/iu.test(line))
    .slice(0, 12);
  const lines = sourceLines.length > 0 ? sourceLines : String(traceFrameText || "")
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => /(trace|sample|total=|final response)/iu.test(line))
    .slice(0, 12);
  if (lines.length === 0) return [{ label: "已保存 trace-frame", meta: "当前运行", key: "stored" }];
  return lines.map((line, index) => {
    const trace = line.match(/trace(?:Id)?[=: ]+([A-Za-z0-9_.:-]+)/u)?.[1] || line.match(/\btrc_[A-Za-z0-9_.:-]+/u)?.[0] || null;
    const sample = line.match(/sample(?:Seq)?[=: ]+([0-9]+)/u)?.[1] || null;
    const turn = line.match(/turn[=: #]+([0-9A-Za-z_.:-]+)/iu)?.[1] || null;
    const meta = [turn ? `turn ${turn}` : null, trace ? `trace ${trace}` : null, sample ? `sample ${sample}` : null].filter(Boolean).join(" · ") || `第 ${index + 1} 行`;
    return { label: shortText(redactDisplayText(line), 120), meta, key: `${trace || "line"}-${sample || index}` };
  });
}

function detailEvidence(detail, artifacts) {
  const traceability = detail.traceability || {};
  return detailBlock("证据", [
    ["source", traceability.source || "-"],
    ["stateRoot", traceability.stateRoot || "-"],
    ["stateDir", traceability.stateDir || "-"],
    ["observerId", traceability.observerId || "-"],
    ["runId", traceability.runId || "-"],
    ["reportJsonSha256", traceability.reportJsonSha256 || artifacts.reportJsonSha256 || "-"],
  ]);
}

function selectedView(response, viewName) {
  if (response?.ok === false) return { ok: false, error: response.error || "不可用", view: viewName };
  const views = Array.isArray(response?.views) ? response.views : [];
  return views.find((item) => item.view === viewName) || null;
}

function bindDetailControls() {
  for (const button of refs.detailContent.querySelectorAll("[data-trace-choice-index]")) {
    button.addEventListener("click", () => {
      state.selectedTraceChoiceIndex = Number(button.dataset.traceChoiceIndex || 0);
      renderDetail();
    });
  }
  for (const button of refs.detailContent.querySelectorAll("[data-copy-command]")) {
    button.addEventListener("click", () => copyCommand(button));
  }
}

async function copyCommand(button) {
  const command = button.dataset.copyCommand || "";
  try {
    await navigator.clipboard.writeText(command);
  } catch {
    // 降级：用临时 textarea
    const textarea = document.createElement("textarea");
    textarea.value = command;
    textarea.style.position = "fixed";
    textarea.style.opacity = "0";
    document.body.appendChild(textarea);
    textarea.select();
    try { document.execCommand("copy"); } catch { /* ignore */ }
    document.body.removeChild(textarea);
  }
  button.classList.add("copied");
  button.textContent = "已复制";
  showCopyToast();
  window.setTimeout(() => {
    button.classList.remove("copied");
    button.textContent = "复制";
  }, 1500);
}

function showCopyToast() {
  refs.copyToast.hidden = false;
  window.setTimeout(() => { refs.copyToast.hidden = true; }, 1200);
}

function renderLoading(show) {
  refs.loadingBanner.hidden = !show;
  refs.manualRefresh.disabled = state.loading;
}

function renderCheckChip(element, label, ok, detail) {
  const status = ok ? "正常" : "阻塞";
  element.textContent = `${label} ${status}${detail ? ` · ${shortText(detail, 34)}` : ""}`;
  element.className = `check-chip ${ok ? "check-ok" : "check-blocked"}`;
}

function toggleChecks() {
  state.checksExpanded = !state.checksExpanded;
  refs.overviewChecks.classList.toggle("overview-checks-collapsed", !state.checksExpanded);
}

function toggleTimeline() {
  state.timelineCollapsed = !state.timelineCollapsed;
  const panel = refs.timeline.closest(".timeline-panel");
  panel.classList.toggle("collapsed", state.timelineCollapsed);
  refs.timelineToggle.textContent = state.timelineCollapsed ? "展开" : "折叠";
  refs.timelineToggle.setAttribute("aria-expanded", String(!state.timelineCollapsed));
}

function toggleRunsFilter() {
  state.runsFilterOpen = !state.runsFilterOpen;
  refs.filterForm.hidden = !state.runsFilterOpen;
  refs.runsFilterSummary.setAttribute("aria-expanded", String(state.runsFilterOpen));
  updateRunsFilterSummary();
}

function toggleFindingsFilter() {
  state.findingsFilterOpen = !state.findingsFilterOpen;
  refs.findingsFilterForm.hidden = !state.findingsFilterOpen;
  refs.findingsFilterSummary.setAttribute("aria-expanded", String(state.findingsFilterOpen));
  updateFindingsFilterSummary();
}

function updateRunsFilterSummary() {
  const f = state.filters;
  const parts = [];
  if (f.status) parts.push(`状态=${displayStatus(f.status)}`);
  if (f.severity) parts.push(`严重=${displaySeverity(f.severity)}`);
  if (f.window) parts.push(`时间=${f.window}`);
  if (f.search) parts.push(`搜索="${shortText(f.search, 16)}"`);
  if (f.sort && f.sort !== "updated") parts.push(`排序=${f.sort}`);
  refs.runsFilterSummary.textContent = `筛选: ${parts.length ? parts.join(" · ") : "全部"}`;
}

function updateFindingsFilterSummary() {
  const f = state.findingFilters;
  const parts = [];
  if (f.severity) parts.push(`严重=${displaySeverity(f.severity)}`);
  parts.push(`窗口=${f.window || "全部"}`);
  if (f.code) parts.push(`代码="${shortText(f.code, 16)}"`);
  if (f.scenario) parts.push(`场景="${shortText(f.scenario, 16)}"`);
  refs.findingsFilterSummary.textContent = `筛选: ${parts.join(" · ")}`;
}

function jumpToLatestRun() {
  const latest = state.runs[0];
  if (latest) selectRun(latest.runId || latest.id);
}

function latestRunIdFrom(overview, runs) {
  const latest = overview?.latestRun || null;
  return latest?.runId || latest?.id || runs[0]?.runId || runs[0]?.id || "";
}

function toggleRedOnly() {
  const active = refs.filterRed.classList.toggle("active");
  if (active) {
    state.filters.severity = "red";
  } else {
    state.filters.severity = "";
  }
  refs.filterSeverity.value = state.filters.severity;
  updateRunsFilterSummary();
  syncLocationQuery();
  loadDashboard({ silent: false });
}

function handleKeyboardShortcut(event) {
  if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || event.target instanceof HTMLTextAreaElement) return;
  if (event.metaKey || event.ctrlKey || event.altKey) return;
  const key = event.key.toLowerCase();
  if (key === "r") { event.preventDefault(); loadDashboard({ silent: false }); return; }
  if (key === "/") { event.preventDefault(); state.runsFilterOpen = true; refs.filterForm.hidden = false; refs.runsFilterSummary.setAttribute("aria-expanded", "true"); refs.filterSearch.focus(); return; }
  if (key === "j" || key === "k") { event.preventDefault(); navigateRun(key === "j" ? 1 : -1); return; }
  if (key === "enter") {
    event.preventDefault();
    if (state.selectedRunId) { selectTab("overview"); return; }
    const first = state.runs[0];
    if (first) selectRun(first.runId || first.id);
    return;
  }
}

function navigateRun(direction) {
  const ids = state.runs.map((run) => run.runId || run.id).filter(Boolean);
  if (ids.length === 0) return;
  const currentIndex = state.selectedRunId ? ids.indexOf(state.selectedRunId) : -1;
  let nextIndex = currentIndex + direction;
  if (nextIndex < 0) nextIndex = 0;
  if (nextIndex >= ids.length) nextIndex = ids.length - 1;
  const nextId = ids[nextIndex];
  if (nextId && nextId !== state.selectedRunId) selectRun(nextId);
}

function updateViewportClass() {
  const width = window.innerWidth;
  const viewport = width >= 1024 ? "desktop" : width >= 981 ? "tablet" : "mobile";
  root.dataset.viewport = viewport;
  if (state.overview) {
    renderTimeline();
    renderRuns();
    renderFindings();
  }
}

function applyFilterControls() {
  state.filters = {
    status: refs.filterStatus.value,
    severity: refs.filterSeverity.value,
    window: refs.filterWindow.value,
    search: refs.filterSearch.value.trim(),
    sort: refs.filterSort.value || "updated",
  };
  updateRunsFilterSummary();
  refs.filterRed.classList.toggle("active", state.filters.severity === "red");
  syncLocationQuery();
  loadDashboard({ silent: false });
}

function applyFindingFilterControls() {
  state.findingFilters = {
    severity: refs.findingFilterSeverity.value,
    window: refs.findingFilterWindow.value,
    code: refs.findingFilterCode.value.trim(),
    scenario: refs.findingFilterScenario.value.trim(),
  };
  updateFindingsFilterSummary();
  syncLocationQuery();
  loadDashboard({ silent: false });
}

function writeFiltersToControls() {
  refs.filterStatus.value = state.filters.status || "";
  refs.filterSeverity.value = state.filters.severity || "";
  refs.filterWindow.value = state.filters.window || "";
  refs.filterSearch.value = state.filters.search || "";
  refs.filterSort.value = state.filters.sort || "updated";
  refs.filterRed.classList.toggle("active", state.filters.severity === "red");
}

function writeFindingFiltersToControls() {
  refs.findingFilterSeverity.value = state.findingFilters.severity || "";
  refs.findingFilterWindow.value = state.findingFilters.window || "24h";
  refs.findingFilterCode.value = state.findingFilters.code || "";
  refs.findingFilterScenario.value = state.findingFilters.scenario || "";
}

function readFiltersFromLocation() {
  const query = new URLSearchParams(window.location.search);
  return {
    status: query.get("status") || "",
    severity: query.get("severity") || "",
    window: query.get("window") || "",
    search: query.get("search") || "",
    sort: query.get("sort") || "updated",
  };
}

function readFindingFiltersFromLocation() {
  const query = new URLSearchParams(window.location.search);
  return {
    severity: query.get("fseverity") || "",
    window: query.get("fwindow") || "24h",
    code: query.get("fcode") || "",
    scenario: query.get("fscenario") || "",
  };
}

function readTabFromLocation() {
  const tab = new URLSearchParams(window.location.search).get("tab");
  return DETAIL_TABS.includes(tab) ? tab : "overview";
}

function syncLocationQuery() {
  const query = new URLSearchParams();
  for (const [key, value] of Object.entries(state.filters)) {
    if (value && !(key === "sort" && value === "updated")) query.set(key, value);
  }
  const findingQueryKeys = {
    severity: "fseverity",
    window: "fwindow",
    code: "fcode",
    scenario: "fscenario",
  };
  for (const [key, value] of Object.entries(state.findingFilters)) {
    if (value && !(key === "window" && value === "24h")) query.set(findingQueryKeys[key], value);
  }
  if (state.selectedRunId) query.set("run", state.selectedRunId);
  // C3: URL 支持 tab deep-link
  if (state.selectedRunId && state.selectedTab && state.selectedTab !== "overview") query.set("tab", state.selectedTab);
  if (state.findingDrilldownKey) query.set("finding", state.findingDrilldownKey);
  const next = `${window.location.pathname}${query.toString() ? `?${query.toString()}` : ""}`;
  window.history.replaceState(null, "", next);
}

function runsQuery(filters) {
  const query = new URLSearchParams({ limit: "40", sort: filters.sort || "updated", direction: "desc" });
  if (filters.status) query.set("status", filters.status);
  if (filters.severity) query.set("severity", filters.severity);
  if (filters.search) query.set("search", filters.search);
  const from = windowFrom(filters.window);
  if (from) query.set("from", from);
  return query.toString();
}

function findingsQuery(filters) {
  const query = new URLSearchParams({ limit: "30" });
  if (filters.severity) query.set("severity", filters.severity);
  if (filters.window) query.set("window", filters.window);
  if (filters.code) query.set("code", filters.code);
  if (filters.scenario) query.set("scenario", filters.scenario);
  return query.toString();
}

function windowFrom(value) {
  if (!value) return "";
  const match = /^(\d+)(h|d)$/u.exec(value);
  if (!match) return "";
  const amount = Number(match[1]);
  const ms = match[2] === "h" ? amount * 3_600_000 : amount * 86_400_000;
  return new Date(Date.now() - ms).toISOString();
}

function debounce(fn, waitMs) {
  let timer = null;
  return (...args) => {
    if (timer !== null) window.clearTimeout(timer);
    timer = window.setTimeout(() => fn(...args), waitMs);
  };
}

function safeSummaryRows(summary) {
  const source = summary && typeof summary === "object" ? summary : {};
  const rows = [];
  for (const [key, value] of Object.entries(source)) {
    if (!safeDisplayKey(key)) continue;
    if (rows.length >= 8) break;
    rows.push([key, safeDisplayValue(value)]);
  }
  return rows.length === 0 ? [["status", "未索引"]] : rows;
}

function safeDisplayKey(key) {
  return !/(prompt|provider|cookie|token|authorization|payload|stdout|stderr|assistant|secret|password)/iu.test(key);
}

function safeDisplayValue(value) {
  if (value === null || typeof value === "undefined") return "-";
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
  if (Array.isArray(value)) return `${value.length} 项`;
  const entries = Object.entries(value).filter(([key]) => safeDisplayKey(key)).slice(0, 4);
  if (entries.length === 0) return "已脱敏对象";
  return entries.map(([key, item]) => `${key}=${primitiveValue(item)}`).join(", ");
}

function primitiveValue(value) {
  if (value === null || typeof value === "undefined") return "-";
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return shortText(String(value), 80);
  if (Array.isArray(value)) return `${value.length} 项`;
  return "对象";
}

function redactDisplayText(value) {
  return String(value || "")
    .split("\n")
    .map((line) => {
      if (/(prompt|cookie|token|authorization|provider|payload|api[_-]?key|password|secret|stdout|stderr)/iu.test(line)) return "[已脱敏]";
      if (/assistant/iu.test(line) && line.length > 180) return "[assistant 正文已脱敏]";
      return shortText(line, 360);
    })
    .join("\n");
}

function renderError(error) {
  refs.errorBanner.hidden = false;
  refs.errorBanner.textContent = error?.message || String(error);
}

function statusClass(value) {
  const normalized = String(value || "idle").toLowerCase();
  if (["healthy", "observed", "analyzed", "pass", "ok"].includes(normalized)) return "status-healthy";
  if (["warning", "degraded", "planned", "running", "queued"].includes(normalized)) return "status-warning";
  if (["blocked", "failed", "error", "timeout", "interrupted"].includes(normalized)) return "status-blocked";
  return "status-idle";
}

function severityClass(value) {
  const normalized = String(value || "unknown").toLowerCase();
  if (["red", "critical", "error"].includes(normalized)) return `severity-${normalized}`;
  if (["warning", "amber"].includes(normalized)) return `severity-${normalized}`;
  if (["info"].includes(normalized)) return "severity-info";
  return "severity-unknown";
}

function displayStatus(value) {
  const normalized = String(value || "idle").toLowerCase();
  const labels = {
    healthy: "健康",
    observed: "已观察",
    analyzed: "已分析",
    pass: "通过",
    ok: "正常",
    warning: "警告",
    degraded: "降级",
    planned: "已计划",
    running: "运行中",
    queued: "排队中",
    blocked: "阻塞",
    failed: "失败",
    error: "错误",
    timeout: "超时",
    interrupted: "已中断",
    idle: "空闲",
  };
  return labels[normalized] || String(value || "-");
}

function displaySeverity(value) {
  const normalized = String(value || "unknown").toLowerCase();
  const labels = {
    red: "红色",
    critical: "严重",
    error: "错误",
    warning: "警告",
    amber: "警告",
    info: "信息",
    unknown: "未知",
  };
  return labels[normalized] || String(value || "-");
}

function displayFindingCode(code) {
  const normalized = String(code || "").toLowerCase();
  const labels = {
    "quick-verify-no-business-turn": "quick verify 未触达业务 turn",
    "session-rail-title-fallback-root-cause": "INV-02 会话标题 fallback 根因",
    "trace-events-page-read-404-root-cause": "INV-07 trace events 404 根因",
    "trace-events-page-read-http-error-root-cause": "trace events HTTP 错误根因",
    "trace-events-page-read-requestfailed-root-cause": "trace events 网络失败根因",
    "observer-command-failed": "观察器控制命令失败",
    "runtime-requestfailed": "运行时请求失败",
    "runtime-console-alerts": "运行时控制台告警",
    "browser-console-or-page-errors": "浏览器控制台或页面错误",
    "browser-timeout": "页面 readiness 超时",
    "target-page-readiness-timeout": "页面 readiness 超时",
    "network-or-api-fetch-bug": "网络或 API 请求异常",
  };
  return labels[normalized] || String(code || "finding");
}

function displayFindingSummary(code, summary) {
  const normalized = String(code || "").toLowerCase();
  const summaries = {
    "quick-verify-no-business-turn": "quick verify 没有形成 sendPrompt、session、trace rows 或 Final Response；不能把公开 dashboard 200 当作 HWLAB 恢复证据。",
    "session-rail-title-fallback-root-cause": "会话列表可见 Session ses_* fallback 标题；根因定位到 session list projection/read model 或 rail 数据绑定缺稳定 title/preview。",
    "trace-events-page-read-404-root-cause": "trace events page read 返回 404；根因定位到 /v1/workbench/traces/:traceId/events API 分页/read-model 合约，早于 DOM 渲染。",
    "trace-events-page-read-http-error-root-cause": "trace events page read 返回 HTTP 错误；根因定位到 trace-events API 路径，不是通用前端渲染失败。",
    "trace-events-page-read-requestfailed-root-cause": "trace events page read 在浏览器网络层失败；monitor 已定位路径，但需要 OTel/API 字段确认后端根因。",
    "observer-command-failed": "observe control command 失败，需要查看 observer timeline 和 failed command 文件确认是 readiness、session API、超时还是 runner shutdown。",
    "runtime-requestfailed": "页面运行时存在请求失败，需要按路径聚合确认是 asset/provenance 噪声、public origin、auth/session 还是 Workbench API。",
    "runtime-console-alerts": "页面控制台出现告警，需要结合 run detail 的 network/console 证据判断是否影响业务 turn。",
    "browser-console-or-page-errors": "浏览器页面报错，需要先看 finalUrl、readiness、session create 和 API 证据，再判断是否是前端缺陷。",
    "browser-timeout": "页面或 Workbench readiness 超时，不应默认判断为浏览器安装环境问题。",
    "target-page-readiness-timeout": "页面或 Workbench readiness 超时，不应默认判断为浏览器安装环境问题。",
  };
  const translated = summaries[normalized];
  if (!translated) return String(summary || "");
  const source = String(summary || "").trim();
  return source && !source.includes(translated) ? `${translated} 原始摘要: ${source}` : translated;
}

function findingNextAction(code) {
  const normalized = String(code || "").toLowerCase();
  const actions = {
    "quick-verify-no-business-turn": "下一步: 打开该 run 的 turn-summary/trace-frame，并用 CLI 对照命令确认没有业务 turn。",
    "session-rail-title-fallback-root-cause": "下一步: 查同 run 的 OTel session_list_read fallbackTitleCount/fallbackTitleRatio，修 session list projection/read model title/preview。",
    "trace-events-page-read-404-root-cause": "下一步: 查同 trace 的 OTel trace_events_read sinceSeq/range/hasMore/fullTraceLoaded，修 trace-events 分页合约或 instrumentation。",
    "trace-events-page-read-http-error-root-cause": "下一步: 按 traceId/afterProjectedSeq 对齐 OTel trace_events_read 和 HTTP route span。",
    "trace-events-page-read-requestfailed-root-cause": "下一步: 先排除 observer refresh/navigation；若非刷新噪声，补 HTTP route span 的 traceId/status/afterProjectedSeq 字段。",
    "observer-command-failed": "下一步: 查看 observe collect timeline 和 failed command 文件，定位失败阶段。",
    "runtime-requestfailed": "下一步: 按请求路径聚合失败，区分网络、auth/session、API 或静态资源问题。",
    "runtime-console-alerts": "下一步: 结合 console 样本与业务 trace 判断是否为阻塞级。",
    "browser-console-or-page-errors": "下一步: 先查 Workbench readiness 和 session create，再决定是否修前端。",
    "browser-timeout": "下一步: 查 finalUrl、loading、session create、network requestfailed 和 reportPath，不先改浏览器安装。",
    "target-page-readiness-timeout": "下一步: 查 finalUrl、loading、session create、network requestfailed 和 reportPath，不先改浏览器安装。",
  };
  return actions[normalized] || "下一步: 打开最近运行详情，并用 CLI 对照命令复核同一 run/observer/report。";
}

function findingRootCauseText(item) {
  const rootCause = item.rootCause || "";
  const status = item.rootCauseStatus || "";
  const confidence = item.rootCauseConfidence || "";
  if (!rootCause && !status && !confidence) return "";
  return [
    rootCause ? `rootCause=${rootCause}` : "",
    status ? `status=${status}` : "",
    confidence ? `confidence=${confidence}` : "",
  ].filter(Boolean).join(" · ");
}

function findingEvidenceText(item) {
  const text = item.evidenceSummary || "";
  return text ? shortText(String(text), 220) : "";
}

function formatSeveritySummary(counts) {
  const entries = Object.entries(counts || {}).filter(([, value]) => Number(value || 0) > 0);
  if (entries.length === 0) return "无";
  return entries.map(([key, value]) => `${displaySeverity(key)} ${formatNumber(Number(value || 0))}`).join(" · ");
}

function formatNumber(value) {
  return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

function shortText(value, limit) {
  const text = String(value || "");
  return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 3))}...`;
}

function formatRelative(iso) {
  const ms = Date.parse(String(iso || ""));
  if (!Number.isFinite(ms)) return "-";
  const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
  if (seconds < 5) return "刚刚";
  if (seconds < 60) return `${seconds} 秒前`;
  const minutes = Math.floor(seconds / 60);
  if (minutes < 60) return `${minutes} 分前`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours} 时前`;
  return `${Math.floor(hours / 24)} 天前`;
}

function escapeHtml(value) {
  return String(value ?? "")
    .replace(/&/gu, "&amp;")
    .replace(/</gu, "&lt;")
    .replace(/>/gu, "&gt;")
    .replace(/"/gu, "&quot;")
    .replace(/'/gu, "&#39;");
}

function escapeAttr(value) {
  return escapeHtml(value);
}
