// AI Arena — Background Service Worker v3.0.0

// 从 sidepanel 缓存的屏幕尺寸；双屏时用于判断 AI 窗口应放到哪块屏幕。
let lastKnownScreen = { width: 1920, height: 1080, left: 0, top: 0 };

// v4.8.29 F37 混合模式: Tab 模式走 chrome.debugger 持久 attach（黄条不影响 UI），
// 并列模式走 MAIN world visibility patch（无黄条）— 两套并存按需启用
// v4.9.0: 加入 gatekeeper 三个模块（rules → store → engine 顺序，store/engine 依赖 BUILTIN_RULES）
// v5.2.4-storage-p1: arena-errors.js 最先 importScripts，让其余子模块都能用 makeArenaError/ArenaErrorCode/ArenaStage
importScripts("arena-errors.js", "selectors-config.js", "state-machine.js", "templates-builtin.js", "template-store.js", "debate-engine.js", "cdp-extractor.js", "captain-mode.js", "chat-bus.js", "ppt-prompts.js", "debate-summary-template.js", "gatekeeper-rules.js", "gatekeeper-store.js", "gatekeeper-engine.js", "memo-store.js");

const SERVICES = {
  claude:   { url: "https://claude.ai/new",              name: "Claude" },
  gemini:   { url: "https://gemini.google.com/app",      name: "Gemini" },
  chatgpt:  { url: "https://chatgpt.com",                name: "ChatGPT" },
  deepseek: { url: "https://chat.deepseek.com",          name: "DeepSeek" },
  doubao:   { url: "https://www.doubao.com/chat",        name: "豆包" },
  qwen:     { url: "https://www.qianwen.com",             name: "千问" },
  kimi:     { url: "https://www.kimi.com",               name: "Kimi" },
  yuanbao:  { url: "https://yuanbao.tencent.com/chat",   name: "元宝" },
  grok:     { url: "https://grok.com",                   name: "Grok" },
};

const MAX_PARTICIPANTS = 3;
const _removingTabs = new Set();
let _modeSwitchPromise = Promise.resolve();  // v4.8.30 F38-②: setWindowMode 切换串行化
let windowMode = "tiled"; // "tab" | "tiled"
// v4.8.30 F38-①: windowMode 异步加载竞态修复 — 包成 Promise 加入 initPromise，
// 让 injectBootstrapToExistingTabs 等 windowMode 真加载完再分流 tab/tiled 路径
const _windowModeLoaded = new Promise(resolve => {
  chrome.storage.local.get("windowMode", (d) => {
    void chrome.runtime.lastError;   // v5.0.48: SW 上下文异常 / 存储不可用时 d 可能为 undefined，先吞掉 lastError
    if (d && (d.windowMode === "tab" || d.windowMode === "tiled")) windowMode = d.windowMode;
    resolve();
  });
});

// ── 初始化 ──
const initPromise = Promise.all([StateMachine.init(), ChatBus.init(), _windowModeLoaded]);

// v5.0.17 P0-4: SW 重启后恢复进行中的轮询 — pollers 是内存 setInterval，SW 30s 空闲被
//   回收后全部蒸发；不恢复的话长辩论必然僵死（AI 在网页里答完，扩展气泡永远转圈）。
initPromise.then(() => {
  try { ChatBus.resumePollingForPending?.(); } catch (e) { console.warn("[bg] resumePolling fail:", e?.message); }
}).catch((e) => console.warn("[bg] init failed:", e?.message));

// v4.2.0 Phase 2: 默认点扩展图标 → 开 popup 群聊窗口（而非 sidepanel）
// sidepanel 仍可通过 popup 内"打开 sidepanel"按钮或 openSidepanel message 进入
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: false }).catch(() => {});

chrome.action.onClicked.addListener(async () => {
  try {
    await ChatBus.openChatPopup();
  } catch (e) {
    console.warn("[Arena] action.onClicked openChatPopup fail:", e);
    // fallback: 退化到 sidepanel
    try {
      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
      if (tabs[0]?.windowId != null) {
        await chrome.sidePanel.open({ windowId: tabs[0].windowId });
      }
    } catch (_) {}
  }
});

// ── 右键菜单 ──
// v4.3.9/12: MV3 SW 重启 / 扩展 reload 时 onInstalled 可能重复触发 → 同 id create 报错。
// 双层兜底：① async/await + try/catch 任一步骤失败都不抛 ② lastError 也消费
async function ensureContextMenu() {
  try {
    await chrome.contextMenus.removeAll();
  } catch (e) {
    console.warn("[Arena] contextMenus.removeAll:", e?.message);
  }
  try {
    await chrome.contextMenus.create({
      id: "ai-arena-ask",
      title: "用 AI圆桌派 提问",
      contexts: ["selection"],
    });
  } catch (e) {
    // 即使理论上 removeAll 后 create 不该重复，也兜底吞掉
    console.warn("[Arena] contextMenus.create:", e?.message);
  }
  // 显式消费 lastError 防 Chrome 错误页弹红字
  if (chrome.runtime.lastError) {
    console.warn("[Arena] runtime.lastError consumed:", chrome.runtime.lastError.message);
  }
}
chrome.runtime.onInstalled.addListener(ensureContextMenu);
chrome.runtime.onStartup.addListener(ensureContextMenu);
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (info.menuItemId === "ai-arena-ask" && info.selectionText) {
    chrome.runtime.sendMessage({ type: "contextMenuText", text: info.selectionText }).catch(() => {});
    if (tab?.windowId) chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => {});
  }
});

// v4.8.19 F32: 旧 injectVisibilityOverride 已删除 — manifest content_scripts
// bootstrap-main-world.js 在 document_start 即注入完整 visibility patch（更早、更全）

// v4.8.20 F32+: 扩展启动时主动注入 bootstrap-main-world.js 到现有 AI tab
// 原因：manifest content_scripts 只对 navigation 时生效。用户 reload 扩展但
// AI tab 没刷新时，bootstrap 不会注入 → 仍被 background throttle。
// 这里 onInstalled / onStartup 主动给所有 AI tab 注入一次。
const AI_URL_PATTERNS = [
  "https://claude.ai/*",
  "https://gemini.google.com/*",
  "https://chatgpt.com/*",
  "https://chat.deepseek.com/*",
  "https://www.doubao.com/*",
  "https://tongyi.aliyun.com/*",
  "https://www.qianwen.com/*",
  "https://kimi.moonshot.cn/*",
  "https://www.kimi.com/*",
  "https://yuanbao.tencent.com/*",
  "https://grok.com/*",
];
// v4.8.21 F32+: cooldown + 失败重试 + onUpdated 兜底
// v5.0.8 perf: 5s → 30s — 减少 onUpdated/navigation 频繁触发 executeScript IPC 开销
//   IIFE 顶部已有 __arenaMainWorldPatched guard 保证幂等，cooldown 主要是减少 chrome
//   API 调用本身的成本。30s 内 navigation 多次完成不重新注入仍然安全。
const _lastBootstrapInject = new Map();  // tabId -> timestamp
const BOOTSTRAP_INJECT_COOLDOWN_MS = 30000;

async function injectBootstrapToTab(tabId, url, reason) {
  const last = _lastBootstrapInject.get(tabId);
  if (last && Date.now() - last < BOOTSTRAP_INJECT_COOLDOWN_MS) {
    return { ok: true, skipped: "cooldown" };
  }
  // 失败重试 1 次（针对 "Frame is showing error page" 这种 navigation 瞬态）
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      await chrome.scripting.executeScript({
        target: { tabId },
        world: "MAIN",
        files: ["bootstrap-main-world.js"],
      });
      _lastBootstrapInject.set(tabId, Date.now());
      console.log(`[F32+] ✅ inject tab=${tabId} reason=${reason}${attempt > 0 ? ` retry=${attempt}` : ""} url=${url?.slice(0, 50)}`);
      return { ok: true };
    } catch (e) {
      const msg = e?.message || String(e);
      // 第一次失败若是 navigation 瞬态错误，延迟 1.5s 重试
      if (attempt === 0 && /error page|cannot access|no tab|frame.*error/i.test(msg)) {
        await new Promise(r => setTimeout(r, 1500));
        continue;
      }
      console.warn(`[F32+] ❌ inject tab=${tabId} reason=${reason} attempt=${attempt} url=${url?.slice(0, 50)}: ${msg}`);
      return { ok: false, error: msg };
    }
  }
}

// v4.8.46: AI URL → 业务 content scripts 映射（与 manifest content_scripts 对齐）
//   reload 扩展后 manifest 不会自动重注入，需要主动执行；ping 失败时也按需重注入
// v4.8.47: 加 service 字段，用于 globalThis flag 检查避免重复注入
// v5.0.8 perf: files 头部加 content-shared.js（千问额外加 qwen-noise-filter.js）—
//   原 fallback 漏注入 content-shared.js 时，content-{ai}.js 调 globalThis.ArenaShared?.xxx
//   全部走可选链短路返回 undefined → streaming 检测失效 → polling 拖到 9-12s 兜底完成。
//   content-shared.js 内有 _loaded guard 保证幂等。
const AI_PATTERN_TO_SCRIPTS = [
  { re: /^https:\/\/claude\.ai\//, service: "claude", files: ["content-shared.js", "inject-images.js", "content-claude.js"] },
  { re: /^https:\/\/gemini\.google\.com\//, service: "gemini", files: ["content-shared.js", "inject-images.js", "content-gemini.js"] },
  { re: /^https:\/\/chatgpt\.com\//, service: "chatgpt", files: ["content-shared.js", "inject-images.js", "content-chatgpt.js"] },
  { re: /^https:\/\/chat\.deepseek\.com\//, service: "deepseek", files: ["content-shared.js", "inject-images.js", "content-deepseek.js"] },
  { re: /^https:\/\/www\.doubao\.com\//, service: "doubao", files: ["content-shared.js", "inject-images.js", "content-doubao.js"] },
  { re: /^https:\/\/tongyi\.aliyun\.com\//, service: "qwen", files: ["content-shared.js", "inject-images.js", "qwen-noise-filter.js", "content-qwen.js"] },
  { re: /^https:\/\/www\.qianwen\.com\//, service: "qwen", files: ["content-shared.js", "inject-images.js", "qwen-noise-filter.js", "content-qwen.js"] },
  { re: /^https:\/\/kimi\.moonshot\.cn\//, service: "kimi", files: ["content-shared.js", "inject-images.js", "content-kimi.js"] },
  { re: /^https:\/\/www\.kimi\.com\//, service: "kimi", files: ["content-shared.js", "inject-images.js", "content-kimi.js"] },
  { re: /^https:\/\/yuanbao\.tencent\.com\//, service: "yuanbao", files: ["content-shared.js", "inject-images.js", "content-yuanbao.js"] },
  { re: /^https:\/\/grok\.com\//, service: "grok", files: ["content-shared.js", "inject-images.js", "content-grok.js"] },
];

function getAiContentScriptsForUrl(url) {
  if (!url) return null;
  for (const { re, files } of AI_PATTERN_TO_SCRIPTS) {
    if (re.test(url)) return files;
  }
  return null;
}

// v4.8.47: 取 service 名（用于 globalThis flag 检查 __AI_ARENA_CS_LOADED_<service>__）
function getServiceForUrl(url) {
  if (!url) return null;
  for (const { re, service } of AI_PATTERN_TO_SCRIPTS) {
    if (re.test(url)) return service;
  }
  return null;
}

// v4.8.46: 注入业务 content scripts（ISOLATED world），用于 reload 扩展后 AI tab 失联恢复
//   先 ping 检测，已 ready 就跳过避免双 onMessage listener；失联才注入
// v4.8.47: ping 失败时不立刻注入 — 先用 globalThis flag 检查 content script 是否已注入（listener
//   尚未就绪的 race 情况），避免无谓的重复注入。即使重复注入，content scripts 顶部 IIFE guard
//   也会 early-return，但额外的 executeScript 调用仍有开销 + 日志噪音。
async function ensureContentScriptInjected(tabId, url) {
  try {
    await chrome.tabs.sendMessage(tabId, { action: "ping" });
    return { ok: true, alreadyReady: true };
  } catch (_) {
    // ping 失败 → 进一步判断
  }
  // v4.8.47: ping 失败 → 检查 globalThis flag 区分 "未注入" vs "已注入但 listener 未就绪"
  const svc = getServiceForUrl(url);
  if (svc) {
    try {
      const results = await chrome.scripting.executeScript({
        target: { tabId },
        func: (key) => !!globalThis[key],
        args: [`__AI_ARENA_CS_LOADED_${svc}__`],
      });
      if (results?.[0]?.result === true) {
        // 已注入但 listener 异步未就绪 — 不重复注入，由上层重试 ping
        return { ok: true, alreadyLoaded: true, listenerNotReady: true };
      }
    } catch (_) {
      // executeScript 都失败说明 tab 状态异常，继续走注入路径让 chrome 报具体错误
    }
  }
  const files = getAiContentScriptsForUrl(url);
  if (!files) return { ok: false, error: "URL 不匹配 AI 平台" };
  try {
    await chrome.scripting.executeScript({ target: { tabId }, files });
    console.log(`[F46/47] inject content scripts tab=${tabId} files=${files.join(",")}`);
    return { ok: true, justInjected: true };
  } catch (e) {
    const msg = e?.message || String(e);
    console.warn(`[F46/47] inject fail tab=${tabId}: ${msg}`);
    return { ok: false, error: msg };
  }
}

// v4.8.62: Tab 模式新建 AI tab 后，title 流光闪烁 2.4s 让用户在 tab 栏看到新 tab
//   chrome 不允许直接给 tab 加动画，只能通过 setTitle 模拟（页面 <title> 元素变化驱动 tab 标签刷新）
//   注入一次 script，里面用 setInterval 自闪烁，期间 AI 网页可能也在 setTitle（race），可见效果是
//   闪烁的 emoji prefix 与 page 自带 title 交替显示——用户感知到"这个 tab 在闪/换"，足够定位新 tab
async function flashNewAiTabTitle(tabId, aiName) {
  try {
    await chrome.scripting.executeScript({
      target: { tabId },
      func: (name) => {
        const ICONS = ["✨", "⭐", "🌟", "💫"];
        const originalTitle = document.title || name;
        let i = 0;
        const timer = setInterval(() => {
          if (i >= 8) {  // 8 次 × 300ms = 2.4s
            clearInterval(timer);
            // 不强制还原 title — page 自己会持续 setTitle，让浏览器使用 page 最新的 title
            return;
          }
          document.title = `${ICONS[i % ICONS.length]} ${name} ${ICONS[i % ICONS.length]}`;
          i++;
        }, 300);
      },
      args: [aiName],
    });
  } catch (e) {
    // tab 可能已关闭 / 注入失败：静默忽略（非关键功能）
    console.log(`[F62] flash title failed for tab=${tabId}: ${e?.message}`);
  }
}

async function injectBootstrapToExistingTabs() {
  // v4.8.30 F38-①: 等 windowMode 真加载完（tab/tiled 影响下面的 CDP 路由）
  try { await _windowModeLoaded; } catch (_) {}
  console.log(`[F32+] scan existing AI tabs (mode=${windowMode})`);
  // v5.0.8 perf: 全部 AI tab 并行 inject —
  //   原 for-loop 串行 await 每个 tab 至少 100-500ms RTT，9 个 tab 累计 1-5s 卡死 SW 启动。
  //   各 tab inject 互相独立，无需顺序保证；executeScript 内部 cooldown + IIFE guard 保证幂等。
  //   tab query 先全部收齐再并行，避免 query 本身串行
  const queryResults = await Promise.allSettled(
    AI_URL_PATTERNS.map(p => chrome.tabs.query({ url: p }))
  );
  const allTabs = [];
  for (const r of queryResults) {
    if (r.status === "fulfilled" && Array.isArray(r.value)) {
      for (const t of r.value) allTabs.push(t);
    } else if (r.status === "rejected") {
      console.warn(`[F32+] query fail:`, r.reason?.message);
    }
  }
  const totalMatched = allTabs.length;
  const aiTabIds = allTabs.map(t => ({ id: t.id, windowId: t.windowId }));
  // 并行 inject bootstrap + 业务 content scripts
  const results = await Promise.allSettled(
    allTabs.map(async (tab) => {
      const r = await injectBootstrapToTab(tab.id, tab.url, "startup");
      try { await ensureContentScriptInjected(tab.id, tab.url); } catch (_) {}
      return r;
    })
  );
  let injected = 0, failed = 0;
  for (const r of results) {
    if (r.status === "fulfilled" && r.value?.ok && !r.value.skipped) injected++;
    else if (r.status === "rejected" || !r.value?.ok) failed++;
  }
  console.log(`[F32+] scan DONE matched=${totalMatched} injected=${injected} failed=${failed}`);

  // v4.8.34: 取消并列模式下的 activateAiWindowsOnce — 用户反馈"扫一遍 AI 窗口"视觉抖动
  //   旧行为（v4.8.26-v4.8.33）：chrome 启动后第一次激活扩展，把每个已开的 AI window
  //   依次 focus 800ms 再还原原焦点，3 个窗口 = 2.4s 可见抖动。
  //   新行为：扩展启动时只静默注入 bootstrap JS（已在 injectBootstrapToTab 完成）。
  //   代价：首次辩论前 AI tab 可能仍被 chrome heavy-throttle，但辩论代码本身会
  //   tabs.update(active:true) 切到目标 tab，chrome 自动解 throttle。
  //   Tab 模式 CDP attachAndWake 保留（无视觉抖动）。
  if (windowMode === "tab" && self.CDPExtractor && aiTabIds.length) {
    console.log(`[F37] tab mode, attaching ${aiTabIds.length} existing AI tab(s)`);
    for (const { id } of aiTabIds) {
      try {
        const r = await self.CDPExtractor.attachAndWake(id);
        console.log(`[F37] attach tab=${id} ok=${r?.ok} code=${r?.code}`);
      } catch (_) {}
    }
  }
  // 并列模式：不再做强制 focus（v4.8.34）
}

// v4.8.34: activateAiWindowsOnce / _activatedOnce / storage.session activatedOnce 整套删除
// （视觉抖动元凶；详见上方注释）

// v5.0.23 D(萌新友好): 全新安装自动打开欢迎页（教钉图标 + 一键开圆桌）。
//   双保险防重复：reason 严格 === "install"（本项目 onInstalled 在 SW 重启/reload 时
//   也会触发，见 ensureContextMenu 注释）+ storage welcomeShown 标记。
chrome.runtime.onInstalled.addListener(async (details) => {
  if (details?.reason !== "install") return;
  try {
    const d = await chrome.storage.local.get(["welcomeShown"]);
    if (d.welcomeShown) return;
    await chrome.storage.local.set({ welcomeShown: true });
    chrome.tabs.create({ url: chrome.runtime.getURL("welcome.html") }).catch(() => {});
  } catch (_) {}
});

// 触发 1: 安装 / 启动 / SW 唤醒
chrome.runtime.onInstalled.addListener(() => { injectBootstrapToExistingTabs(); });
chrome.runtime.onStartup.addListener(() => { injectBootstrapToExistingTabs(); });
injectBootstrapToExistingTabs();

// 触发 2: AI tab navigation 完成时兜底（最可靠的时机，error page 瞬态已过）
function isAiUrl(url) {
  if (!url) return false;
  return AI_URL_PATTERNS.some(p => {
    try {
      const re = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
      return re.test(url);
    } catch { return false; }
  });
}
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
  if (changeInfo.status !== "complete") return;
  if (!isAiUrl(tab.url)) return;
  injectBootstrapToTab(tabId, tab.url, "navigation");
});

// v4.8.23 F33: 删除冗余 auto-reconnect inject —— 真凶
// 老代码 onUpdated complete 时给 AI tab 主动 inject content-${service}.js
// 但 manifest content_scripts 已经在 document_idle 自动注入了 → 第二次 inject
// 导致 `const SITE = "deepseek"` 重复声明 SyntaxError → 整个 script 抛错 →
// chrome.runtime.onMessage listener 注册失败 → sendMessage readResponse 收不到
// 响应 → polling 读到空文本 → 45s empty timeout → "未提取到内容"
//
// manifest content_scripts 已经处理 navigation 重注入，auto-reconnect 是冗余

// ── 标签页关闭 → 直接移除参与者 ──
chrome.tabs.onRemoved.addListener((closedId) => {
  // v4.8.30 F38-⑤: 显式 detach CDP 防 attachedTabs Map 残留 stale 条目
  // （onDetach 事件通常会兜底但异常路径可能漏发）
  if (self.CDPExtractor) self.CDPExtractor.detach(closedId).catch(() => {});
  if (_removingTabs.delete(closedId)) return; // We initiated this removal
  const p = StateMachine.participants.find(p => p.tabId === closedId);
  if (p) {
    StateMachine.removeParticipant(p.id);
    notifyStatus(`${p.name} 标签页已关闭，已移除`);
    StateMachine._broadcastStateUpdate();
  }
});
chrome.windows.onRemoved.addListener(async (windowId) => {
  // v4.8.63: popup 关闭时自动 detach 所有 debugger session（chrome 顶部黄条消失）
  // v4.8.64: polling 在跑时延迟 detach — 防"用户关 popup 时正在生成回答 → 后台 AI 失反节流变慢"
  //   策略：若 pollers 非空，每 1.5s 检查一次；polling 全完成立即 detach；最长 30s 兜底
  //         期间若 popup 重新打开（getPopupWindowId 不为 null）→ 取消 detach 保留 attach
  let wasPopup = false;
  try { wasPopup = (typeof ChatBus.getPopupWindowId === "function") && (windowId === ChatBus.getPopupWindowId()); } catch (_) {}
  ChatBus.onWindowRemoved(windowId);
  if (!wasPopup || !self.CDPExtractor) return;

  const activePollers = (typeof ChatBus.getActivePollerCount === "function") ? ChatBus.getActivePollerCount() : 0;
  if (activePollers === 0) {
    try {
      await self.CDPExtractor.detachAll();
      console.log("[F63] popup closed (no active polling) → detached all debugger sessions");
    } catch (e) {
      console.warn("[F63] detachAll on popup close failed:", e?.message);
    }
    return;
  }

  // v4.8.64 延迟 detach：等所有 polling 完成或 30s 超时
  console.log("[F64] popup closed but " + activePollers + " polling still running → defer detach (max 30s)");
  const DETACH_DEADLINE = Date.now() + 30000;
  const checkAndMaybeDetach = async () => {
    if ((typeof ChatBus.getPopupWindowId === "function") && ChatBus.getPopupWindowId() != null) {
      console.log("[F64] popup reopened during defer window → cancel detach");
      return;
    }
    const pending = (typeof ChatBus.getActivePollerCount === "function") ? ChatBus.getActivePollerCount() : 0;
    if (pending === 0 || Date.now() >= DETACH_DEADLINE) {
      try {
        await self.CDPExtractor.detachAll();
        console.log("[F64] deferred detach done (pending=" + pending + ", timeout=" + (Date.now() >= DETACH_DEADLINE) + ")");
      } catch (e) {
        console.warn("[F64] deferred detach failed:", e?.message);
      }
      return;
    }
    setTimeout(checkAndMaybeDetach, 1500);
  };
  setTimeout(checkAndMaybeDetach, 1500);
});
chrome.windows.onBoundsChanged?.addListener((win) => {
  ChatBus.rememberBounds(win.id);
});

// ── 消息处理 ──
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  initPromise.then(async () => {
    try {
      switch (msg.type) {
        case "addParticipant":
          if (msg.screen) lastKnownScreen = msg.screen;
          sendResponse(await addParticipant(msg.service)); break;
        case "removeParticipant": sendResponse(await removeParticipant(msg.id)); break;
        // v5.0.22 B: 登录红绿灯 — popup 驱动复检 + 一键去登录页
        case "recheckLogin": sendResponse(await recheckLoginFor(msg.id)); break;
        case "activateParticipantTab": sendResponse(await activateParticipantTab(msg.id)); break;
        // v5.0.23 D: 欢迎页 CTA → 打开圆桌窗口
        case "openArenaPopup":
          try { await ChatBus.openChatPopup(); sendResponse({ ok: true }); }
          catch (e) { sendResponse({ ok: false, error: e?.message }); }
          break;
        // v5.0.24 G: 一键体检 + 修复动作
        case "runDiagnosis": sendResponse(await runDiagnosis()); break;
        case "reopenParticipantTab": sendResponse(await reopenParticipantTab(msg.id)); break;
        case "reloadParticipantTab": sendResponse(await reloadParticipantTab(msg.id)); break;
        case "broadcast":
          sendResponse(await guardedSend({
            text: msg.text || "",
            msg,
            handler: () => handleBroadcast(msg.text, msg.images),
          }));
          break;
        case "debateRound":
          sendResponse(await guardedSend({
            text: msg.guidance || "",
            msg,
            handler: () => handleDebateRound(msg.style, msg.guidance, msg.concise, msg.force),
          }));
          break;
        // v5.0.60 顺序接力：单 AI 串行接力，前面 AI 的回答纳入后面 AI 的上下文
        case "debateRoundSequential":
          sendResponse(await guardedSend({
            text: msg.guidance || "",
            msg,
            handler: () => handleDebateRoundSequential(msg.userInput, msg.guidance, msg.concise, msg.force, msg.sequence),
          }));
          break;
        case "summary":
          sendResponse(await guardedSend({
            text: msg.customInstruction || "",
            msg,
            handler: () => handleSummary(msg.judgeId, msg.customInstruction, msg.format),
          }));
          break;
        case "checkAllCompletion": sendResponse(await checkAllCompletion()); break;
        case "focusTab":          sendResponse(await handleFocusTab(msg.id)); break;
        case "focusAllAiTabs":    sendResponse(await focusAllAiTabs()); break;
        case "focusServiceTabThenPopup": sendResponse(await focusServiceTabThenPopup(msg.participantId)); break;
        case "readOneResponse":   sendResponse(await readOneResponse(msg.participantId)); break;
        case "readLastImage":     sendResponse(await readLastImage(msg.participantId)); break;
        case "sendPromptToService": {
          // v4.9.0.2 fix I1: bubble resend 按钮发送时 msg.text 为空（实际 prompt 在 lastSentByPid），
          // 用 fallback 取出真实 prompt 让守门员扫描，避免被空文本短路绕过
          // v5.0.10: scanText 取值优先级与 sendPromptToService 内对齐 —
          //   originalQuestion → lastSentByPid，保证守门员扫描的就是真正要发的 prompt
          let scanText = msg.text || "";
          if (!scanText.trim()) {
            scanText = (StateMachine.debateSession?.originalQuestion || "").trim();
          }
          if (!scanText) {
            const svc = msg.service || "chatgpt";
            const p = (StateMachine.participants || []).find(x => x.service === svc);
            scanText = (p && StateMachine.lastSentByPid?.[p.id]) || "";
          }
          sendResponse(await guardedSend({
            text: scanText,
            msg,
            handler: () => sendPromptToService(msg.service || "chatgpt", msg.text || ""),
          }));
          break;
        }
        case "exportSession":     sendResponse(exportSession()); break;
        case "getState":          sendResponse(StateMachine.getFullState()); break;
        case "getSelectors":      sendResponse(DEFAULT_SELECTORS[msg.platform] || {}); break;
        case "setWindowMode": {
          // v4.8.29 F37 混合模式: 切换 Tab/并列 时同步 attach/detach CDP
          // v4.8.30 F38-②: Promise chain 串行化防快速切换竞态（attach fire-and-forget
          // 还没完成 detachAll 看到空 Map → 残留 debugger attach）
          const oldMode = windowMode;
          windowMode = msg.mode;
          chrome.storage.local.set({ windowMode: msg.mode });
          _modeSwitchPromise = _modeSwitchPromise.then(async () => {
            if (!self.CDPExtractor) return;
            if (msg.mode === "tab" && oldMode !== "tab") {
              // v5.0.22 C: 切到 Tab 模式且有成员 → 即将 attach，先安抚黄条
              if (StateMachine.participants.length) maybeNotifyDebuggerNotice();
              console.log(`[F37] mode → tab, attaching ${StateMachine.participants.length} AI tabs (serial)`);
              for (const p of StateMachine.participants) {
                if (p.tabId) await self.CDPExtractor.attachAndWake(p.tabId).catch(() => {});
              }
            } else if (msg.mode === "tiled" && oldMode === "tab") {
              console.log(`[F37] mode → tiled, detaching all CDP (after prior attach finished)`);
              await self.CDPExtractor.detachAll();
            }
          }).catch(e => console.warn("[F38] mode switch chain:", e?.message));
          await _modeSwitchPromise;
          sendResponse({ ok: true });
          break;
        }
        case "arrangeWindows":
          if (msg.screen) lastKnownScreen = msg.screen;
          sendResponse(await arrangeWindows(msg.screen || lastKnownScreen));
          break;
        case "openChatPopup":
          sendResponse(await ChatBus.openChatPopup()); break;
        case "miniModeToggle":
          // v4.8.15 F30: popup-mini-mode.js 触发，resize popup window 到 mini/full
          // v5.0.12: MV3 SW 重启后内存里的 popupWindowId 会丢；点击时带 windowId 重新登记。
          if (typeof msg.windowId === "number") ChatBus.setPopupWindowId(msg.windowId);
          sendResponse(await ChatBus.toggleMiniMode(msg.mode)); break;
        case "miniMenuExpand":
          // v4.8.28: mini 模式下 task-menu 打开时临时撑大窗口让菜单向下露出
          sendResponse(await ChatBus.miniMenuExpand(msg.expand)); break;
        // v4.8.31: 删除 setMiniSkip — mini 头像点击改 removeParticipant 共享 hero-slot 逻辑
        case "popupReady":
          // v4.6.7 F17: popup DOMContentLoaded 主动告知 SW 自己的 windowId
          // SW 重启后 ChatBus.popupWindowId 是 null，靠这条消息恢复
          if (typeof msg.windowId === "number") ChatBus.setPopupWindowId(msg.windowId);
          sendResponse({ ok: true });
          break;
        case "chatBroadcast":
          sendResponse(await guardedSend({
            text: msg.text || "",
            msg,
            handler: () => ChatBus.broadcast(msg.text, msg.targets || [], msg.images || []),
          }));
          break;
        // v4.9.0.2 fix: 守门员统计 stat 上报 — bridge.onConfirm/onCancel 发的消息要落地
        case "_bumpGatekeeperStat":
          if (msg.key && self.GatekeeperStore) {
            try { await self.GatekeeperStore.bumpStat(msg.key); } catch (_) {}
          }
          sendResponse({ ok: true }); break;
        case "chatRestoreLog":
          sendResponse({ messages: ChatBus.getLog() }); break;
        case "chatClear":
          ChatBus.clearLog(); sendResponse({ ok: true }); break;
        case "chatJumpToOrigin":
          sendResponse(await ChatBus.jumpToOrigin(msg.participantId)); break;
        // v5.0.30: 查看本轮发给某 AI 的 prompt 全文（气泡 participantId 是 service，lastSentByPid 用 pid）
        case "getSentPrompt": {
          const svc = msg.participantId;
          const p = (StateMachine.participants || []).find(x => x.service === svc || x.id === svc);
          sendResponse({ ok: !!p, prompt: p ? (StateMachine.lastSentByPid?.[p.id] || "") : "", name: p?.name || svc });
          break;
        }
        case "chatReextractOne":
          sendResponse(await ChatBus.reextractOne(msg.participantId)); break;
        case "chatSkipParticipant":
          sendResponse(ChatBus.skipParticipant(msg.participantId, msg.msgId)); break;
        // v4.8.43: 用户在 popup 编辑下轮回答 → 写 p.response + 标记 userEdited
        case "setParticipantResponse":
          sendResponse(StateMachine.setParticipantResponse(msg.id, msg.text, { userEdited: !!msg.userEdited }));
          break;

        // ── 手动操作 ──
        case "sendToOne":
          sendResponse(await sendToOneParticipant(msg.participantId));
          break;
        case "retryInject":
          sendResponse(await retryInjectParticipant(msg.id));
          break;
        case "retryDebateInjectForParticipant":
          // v5.0.11: 弹窗"补发缺失 AI"按钮触发 — 用暂存的辩论 prompt 重新 inject 给指定 AI
          // v5.0.19: msg.compress=true → 重建压缩版 prompt 后补发（应对网关上传限额）
          sendResponse(await retryDebateInjectForParticipant(msg.participantId, { compress: !!msg.compress }));
          break;
        // v5.0.21: 划线收藏（备忘录）— 来源：AI 原网页浮钮（content-shared）/ 圆桌主界面（popup-memos）
        case "memoAdd":    sendResponse(await self.ArenaMemoStore.add(msg.text, msg.source)); break;
        case "memoList":   sendResponse({ ok: true, items: await self.ArenaMemoStore.list() }); break;
        case "memoRemove": sendResponse(await self.ArenaMemoStore.remove(msg.id)); break;
        case "memoClear":  sendResponse(await self.ArenaMemoStore.clear()); break;
        case "resetSession":
          StateMachine.resetSession();
          notifyStatus("会话已重置");
          sendResponse({ ok: true });
          break;
        case "hardReset":
          // v4.3.6: hardReset 之前关闭所有 AI 网页 tab
          // v4.6.6 F14: 提前批量加入 _removingTabs 防 chrome.tabs.onRemoved 监听器
          // 对每个 tab 触发一次 removeParticipant + _broadcastStateUpdate + notifyStatus
          // （N 次重复噪音 + 给 popup-members 推 N+1 次 stateUpdate 抖动）
          try {
            const tabIds = (StateMachine.participants || [])
              .map(p => p.tabId)
              .filter(id => typeof id === "number");
            if (tabIds.length) {
              tabIds.forEach(id => _removingTabs.add(id));
              await chrome.tabs.remove(tabIds).catch(() => {});
            }
          } catch (_) {}
          // v4.6.6 F13: 清 polling — 防旧 polling 下个 tick 调失效 tabId 触发 catch
          // 推送"⚠ XX 已断开"残留消息到 popup（用户报"重置后不再同步问答"主因）
          try { ChatBus.clearAllPollers(); } catch (_) {}
          StateMachine.hardReset();
          try { ChatBus.clearLog(); } catch (_) {}
          // v4.5.4 F8: 主动广播 hardReset 让 popup-members 清空 streamStatus，
          // 否则重置后立即添加同 service 新 AI，其状态会显示上一次的 ready/error 鬼影
          chrome.runtime.sendMessage({ type: "hardReset" }).catch(() => {});
          // v4.8.65: 补一次 stateUpdate — v4.6.6 F14 优化吞掉了 N+1 次广播但漏了 final 那次，
          // 导致 mini-roster（输入框上方状态条）拿不到空 participants，重置后还显示 3 个 AI
          StateMachine._broadcastStateUpdate();
          notifyStatus("已彻底重置（AI 标签页 + 群聊 + 辩论上下文）");
          sendResponse({ ok: true });
          break;
        case "pptBuildPrompt": {
          // v4.3.0：popup PPT 工坊调此 handler 拿 prompt 字符串
          try {
            const kind = msg.kind || "copy";
            const templateKey = msg.template || "intro";
            const state = StateMachine.getFullState();
            const participants = state.participants || [];
            // 从 participants.response 收集讨论
            const responses = (StateMachine.participants || [])
              .filter(p => (p.response || p.responsePreview || "").trim())
              .map(p => ({ name: p.name || p.service, text: (p.response || p.responsePreview || "").trim() }));
            const question = msg.question || "";
            const ctx = { question, responses, imageBrief: msg.imageBrief || "" };
            let prompt = "";
            if (kind === "copy") prompt = self.PptPrompts.buildCopyPrompt(ctx);
            // v5.2.4: image + template="all" → buildAllImagePrompt（一次生成 5 种风格）
            else if (kind === "image" && templateKey === "all") prompt = self.PptPrompts.buildAllImagePrompt(ctx);
            else if (kind === "image") prompt = self.PptPrompts.buildImagePrompt(ctx, templateKey);
            else if (kind === "pptx") prompt = self.PptPrompts.buildPptxPrompt();
            else { sendResponse({ ok: false, error: `未知 kind: ${kind}` }); break; }
            sendResponse({ ok: true, prompt, template: self.PptPrompts.TEMPLATE_META[templateKey] || null });
          } catch (e) {
            sendResponse({ ok: false, error: e.message });
          }
          break;
        }
        case "openSidepanel":
          try {
            const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
            if (tabs[0]?.windowId != null && chrome.sidePanel?.open) {
              await chrome.sidePanel.open({ windowId: tabs[0].windowId });
              sendResponse({ ok: true });
            } else {
              sendResponse({ ok: false, error: "sidePanel API 不可用" });
            }
          } catch (e) {
            sendResponse({ ok: false, error: e.message });
          }
          break;

        default: sendResponse({ ok: false, error: "Unknown message type" });
      }
    } catch (e) {
      sendResponse({ ok: false, error: e.message });
    }
  }).catch((e) => {
    // v5.0.17 P0-5: initPromise reject（storage 损坏等）时旧版 .then 不执行，sendResponse
    //   永不被调但 return true 已承诺异步回应 → popup 所有按钮点了无反应像死机且无提示。
    //   兜底保证 sendResponse 任何路径都被调用。
    try { sendResponse({ ok: false, error: `SW 初始化失败: ${e?.message || e}` }); } catch (_) {}
  });
  return true;
});

// ── v4.7.2 F22: 添加 AI 时检测登录状态 ──
// 用 chrome.scripting.executeScript 在 AI tab 跑通用启发式：DOM 含登录关键字 +
// 没有对话输入框 → 未登录。比依赖每个 content-*.js 的 isLoginBlocked 更通用。
// v5.0.22 B(萌新友好): 登录检测从"一次性文字警告"升级为参与者红绿灯 —
//   participant.loginStatus: "checking" | "ok" | "login_required"，随 stateUpdate 广播，
//   popup 成员卡渲染 🔑去登录 角标；login_required 期间 popup 每 8s 发 recheckLogin
//   复检（popup 侧驱动，规避 MV3 SW 30s 空闲回收杀掉长 setTimeout 链的问题），
//   用户登录完成自动翻绿。探测无结论（tab 关/脚本失败）→ 不误报，维持原状。
const LOGIN_PROBE_FUNC = () => {
        // v4.7.5 F22 启发式：CTA / Modal / URL / not-login class 四个维度任一命中即未登录

        // 1. 找登录 CTA 按钮 — selector 扩展到 div/span/li（Kimi 用 <div class="not-login-container">登录</div>）
        const LOGIN_CTA = /^(登录|登陆|登入|立即登录|账号登录|账户登录|微信登录|扫码登录|手机登录|Sign in|Sign up|Sign Up|Log in|Log In|Login|Get started|Continue with)$/i;
        const ctas = Array.from(document.querySelectorAll(
          'button, a, [role="button"], div, span, li'
        )).filter(el => {
          const t = (el.innerText || el.textContent || "").trim();
          if (!t || t.length > 10) return false;  // 收紧到 10 字
          if (!LOGIN_CTA.test(t)) return false;
          // 排除"父含子也含同样文本"的重复（取最深的）
          if (el.querySelector('*')) {
            const childTexts = Array.from(el.children).map(c => (c.innerText || "").trim());
            if (childTexts.some(ct => ct === t)) return false;
          }
          const r = el.getBoundingClientRect?.();
          return r && r.width > 20 && r.height > 10;
        });
        const hasLoginCTA = ctas.length > 0;

        // 2. 登录 modal（含登录字眼的 dialog/modal）
        const hasLoginModal = Array.from(document.querySelectorAll(
          '[role="dialog"], [class*="modal"]:not([class*="modal-hidden"]):not([class*="not-modal"])'
        )).some(el => {
          const r = el.getBoundingClientRect?.();
          if (!r || r.width < 100 || r.height < 80) return false;
          const t = (el.innerText || "").trim();
          return /登录|Sign in|Log in|Sign up|登录即可/i.test(t);
        });

        // 3. URL 路径含 login/sign_in
        const path = (location.pathname || "") + (location.hash || "");
        const urlLooksLikeLogin = /(?:^|\/)(login|signin|sign[_-]?in|sign[_-]?up|account\/(?:login|signin))(?:\/|\?|$)/i.test(path);

        // 4. v4.7.5 关键新增：明显的"未登录"class 标识（Kimi 的 not-login-container 等）
        // 这是最 strong 的信号 — 前端用 class 标记登录态时，登录后通常会替换类名
        const hasNotLoginClass = Array.from(document.querySelectorAll(
          '[class*="not-login"], [class*="not_login"], [class*="unlogin"], '
          + '[class*="un-login"], [class*="logged-out"], [class*="loggedOut"], '
          + '[class*="anonymous"], [class*="guest-user"]'
        )).some(el => {
          const r = el.getBoundingClientRect?.();
          return r && r.width > 0 && r.height > 0;
        });

        const loggedIn = !(hasLoginCTA || hasLoginModal || urlLooksLikeLogin || hasNotLoginClass);
        return { loggedIn, hasLoginCTA, hasLoginModal, urlLooksLikeLogin, hasNotLoginClass, ctaCount: ctas.length };
};

async function probeLoginOnce(tabId) {
  const results = await chrome.scripting.executeScript({
    target: { tabId },
    func: LOGIN_PROBE_FUNC,
  }).catch(() => null);
  return results?.[0]?.result || null;
}

// 探测结果落到参与者状态 + 广播；翻绿时发 toast 级提示。返回最新 loginStatus。
function applyLoginProbe(participantId, probe) {
  const p = StateMachine.getParticipant(participantId);
  if (!p || !probe) return null;
  const next = probe.loggedIn ? "ok" : "login_required";
  if (p.loginStatus !== next) {
    const wasRequired = p.loginStatus === "login_required";
    p.loginStatus = next;
    StateMachine.save();
    StateMachine._broadcastStateUpdate();
    if (next === "ok" && wasRequired) notifyStatus(`✓ ${p.name} 已登录就绪`);
  }
  return next;
}

async function checkLoginStatus(participantId, displayName, service) {
  try {
    // 等页面初步加载（DOM ready），但不强求 complete（部分 SPA 永远不到 complete）
    await new Promise(r => setTimeout(r, 3500));
    let p = StateMachine.getParticipant(participantId);
    if (!p) return;
    let probe = await probeLoginOnce(p.tabId);
    if (!probe) {
      // 页面可能还在加载，4s 后再试一次；仍无结论则维持 checking（popup 侧有放行兜底）
      await new Promise(r => setTimeout(r, 4000));
      p = StateMachine.getParticipant(participantId);
      if (!p) return;
      probe = await probeLoginOnce(p.tabId);
      if (!probe) return;
    }
    const status = applyLoginProbe(participantId, probe);
    if (status === "login_required") {
      const tipMsgId = `m${Date.now()}_login_${service}`;
      // 用同一个 chatStreamUpdate 通道推一条 ai 警告气泡（popup updateAIBubble 自动按 service 加头像）
      chrome.runtime.sendMessage({
        type: "chatStreamUpdate", role: "ai",
        msgId: tipMsgId, participantId: service,
        text: `⚠ ${displayName} 似乎未登录。请到 ${displayName} 网页登录（没有账号用手机号注册即可），登录后会自动就绪。`,
        isDone: true,
        loginWarning: true,
        loginPid: participantId,   // v5.0.23 F: 气泡内嵌「去登录页」按钮要用
      }).catch(() => {});
      notifyStatus(`⚠ ${displayName} 未登录，提问前请先登录`);
    }
  } catch (_) {
    // 检测失败（tab 已关 / 没 scripting 权限等）不影响添加
  }
}

// popup 驱动的复检（login_required 期间每 8s 一次）— 用户在 AI 网页登录完成后自动翻绿
async function recheckLoginFor(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p) return { ok: false, error: "participant not found" };
  const probe = await probeLoginOnce(p.tabId);
  if (!probe) return { ok: true, loginStatus: p.loginStatus || "checking" };
  return { ok: true, loginStatus: applyLoginProbe(participantId, probe) };
}

// 萌新一键"去登录"：激活该参与者的 AI 标签页/窗口
async function activateParticipantTab(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p?.tabId) return { ok: false, error: "tab not found" };
  try {
    const tab = await chrome.tabs.update(p.tabId, { active: true });
    if (tab?.windowId != null) await chrome.windows.update(tab.windowId, { focused: true });
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e?.message || "activate failed" };
  }
}

// ── v5.0.24 G: 一键体检 ──
// 每个成员三项：标签页活着 / content script 响应 ping / 登录状态（顺带刷新红绿灯）
// 审查修复：成员间并行 + 单项 5s 超时 race — 串行无超时时 3 个失联成员可拖 15-30s 无反馈
function _withTimeout(promise, ms) {
  return Promise.race([promise, new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), ms))]);
}
async function runDiagnosis() {
  const PER_CHECK_MS = 5000;
  const items = await Promise.all(StateMachine.participants.map(async (p) => {
    const item = { id: p.id, service: p.service, name: p.name, tabAlive: false, csAlive: false, loginStatus: p.loginStatus || null };
    try { item.tabAlive = !!(await chrome.tabs.get(p.tabId)); } catch (_) {}
    if (item.tabAlive) {
      try {
        const r = await _withTimeout(chrome.tabs.sendMessage(p.tabId, { action: "ping" }), PER_CHECK_MS);
        item.csAlive = !!r?.ready;
      } catch (_) {}
      try {
        const probe = await _withTimeout(probeLoginOnce(p.tabId), PER_CHECK_MS);
        if (probe) item.loginStatus = applyLoginProbe(p.id, probe);
      } catch (_) {}
    }
    return item;
  }));
  return { ok: true, items, windowMode };
}

// 体检修复①：标签页被关 → 原成员身份重开（保 id/名字/会话，换 tabId）
// 审查修复：按 windowMode 分叉 — tiled 走独立窗口 + 重排，与 addParticipant 对称
async function reopenParticipantTab(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p) return { ok: false, error: "participant not found" };
  try { await chrome.tabs.get(p.tabId); return activateParticipantTab(participantId); } catch (_) {}
  const info = SERVICES[p.service];
  if (!info) return { ok: false, error: "unknown service" };
  try {
    let newTabId;
    if (windowMode === "tiled") {
      const targetLayout = await getAiTargetLayout(lastKnownScreen);
      const win = await chrome.windows.create({
        url: info.url, state: "normal", focused: true,
        ...windowBoundsForCreate(targetLayout.screen)
      });
      newTabId = win.tabs[0].id;
      setTimeout(async () => {
        try { await arrangeWindows(); } catch (_) {}
        try { await ChatBus.focusPopup(); } catch (_) {}
      }, 500);
    } else {
      const currentWindow = await chrome.windows.getCurrent().catch(() => null);
      const tab = await chrome.tabs.create({ url: info.url, windowId: currentWindow?.id, active: false });
      newTabId = tab.id;
    }
    p.tabId = newTabId;
    p.loginStatus = "checking";
    StateMachine.save();
    StateMachine._broadcastStateUpdate();
    notifyStatus(`已为 ${p.name} 重新打开网页`);
    checkLoginStatus(p.id, p.name, p.service).catch(() => {});
    if (windowMode === "tab" && self.CDPExtractor) {
      maybeNotifyDebuggerNotice();
      setTimeout(() => { self.CDPExtractor.attachAndWake(newTabId).catch(() => {}); }, 1500);
    }
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e?.message || "reopen failed" };
  }
}

// 体检修复②：content script 失联 → 刷新页面（navigation 会重新注入 manifest content_scripts）
async function reloadParticipantTab(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p?.tabId) return { ok: false, error: "tab not found" };
  try {
    await chrome.tabs.reload(p.tabId);
    return { ok: true };
  } catch (e) {
    return { ok: false, error: e?.message || "reload failed" };
  }
}

// v5.0.22 C(萌新友好): 首次 CDP attach 前安抚"已开始调试此浏览器"黄条 —
//   萌新看到"调试"会恐慌点取消（导致后台提取降速）。只弹一次（storage 标记由 popup 写）。
//   审查修复：storage 标记由 popup 收到消息后才写，addParticipant 与 setWindowMode 短窗内
//   连续调用会双发 → 内存哨兵先行去重（SW 重启后哨兵清零，但届时 storage 标记已落盘兜底）
let _debuggerNoticeSent = false;
async function maybeNotifyDebuggerNotice() {
  if (_debuggerNoticeSent) return;
  _debuggerNoticeSent = true;
  try {
    const d = await chrome.storage.local.get(["debuggerNoticeShown"]);
    if (d.debuggerNoticeShown) return;
    chrome.runtime.sendMessage({ type: "debuggerNotice" }).catch(() => {});
  } catch (_) {}
}

// ── 参与者管理 ──

async function addParticipant(service) {
  if (StateMachine.participants.length >= MAX_PARTICIPANTS) {
    notifyStatus(`最多 ${MAX_PARTICIPANTS} 个参与者`);
    return { ok: false, error: `最多 ${MAX_PARTICIPANTS} 个参与者` };
  }
  // v5.2.23: 千问与其他 AI 互斥 — 千问 Web 上下文窗口过短（实测 1 轮 ~9000 字回答后即触发
  //   send button 持续 disabled 137s+ / 上下文超限），无法支持多轮辩论。已加这条策略阻止
  //   "千问 + 其他 AI" 组合，避免用户进入辩论后才发现千问拖累所有 AI。
  const others = StateMachine.participants.filter(p => p.service !== "qwen").length;
  const qwens = StateMachine.participants.filter(p => p.service === "qwen").length;
  const QWEN_HINT = "由于千问 Web 版本功能受限，上下文窗口过短无法支持辩论，建议采用单 AI 问答方式或替换为其他 AI。";
  if (service === "qwen" && others > 0) {
    notifyStatus(QWEN_HINT);
    return { ok: false, error: "QWEN_INCOMPATIBLE", message: QWEN_HINT };
  }
  if (service !== "qwen" && qwens > 0) {
    notifyStatus(QWEN_HINT);
    return { ok: false, error: "QWEN_INCOMPATIBLE", message: QWEN_HINT };
  }
  const info = SERVICES[service];
  if (!info) return { ok: false };
  const count = StateMachine.participants.filter(p => p.service === service).length + 1;
  const id = `p${StateMachine.nextId++}`;

  let tabId;
  if (windowMode === "tiled") {
    // 并列模式：每个 AI 开独立窗口
    const isFirst = StateMachine.participants.length === 0;
    const targetLayout = await getAiTargetLayout(lastKnownScreen);
    // v4.8.24 F34: focused:true 让 AI window 短暂可见，触发 Chrome 内核的"曾 visible"
    // 标记 — 否则 from-never-visible tab 触发 Heavy Timer Throttling (1/min 严格 throttle)
    // 即使 visibility patch 都救不了，因为 chrome chain count 看 C++ 层 history，不看 JS getter
    // 500ms 后 focusPopup 会切回 popup，用户视觉上仅看到一瞬间 AI window 闪现
    const win = await chrome.windows.create({
      url: info.url,
      state: "normal",
      focused: true,
      ...windowBoundsForCreate(targetLayout.screen)
    });
    tabId = win.tabs[0].id;
    // 双屏时 AI 窗口放到另一屏，sidepanel 保留在用户当前屏；单屏沿用旧体验。
    if (isFirst && !targetLayout.isDifferentDisplay) chrome.sidePanel.open({ windowId: win.id }).catch(() => {});
  } else {
    // Tab 模式：同一窗口的不同标签页
    const currentWindow = await chrome.windows.getCurrent();
    const tab = await chrome.tabs.create({ url: info.url, windowId: currentWindow.id, active: false });
    tabId = tab.id;
    // v4.8.62: 新建 AI tab 后 title 闪烁让用户在 tab 栏看到新 tab
    flashNewAiTabTitle(tabId, `${info.name}-${count}`).catch(() => {});
  }

  StateMachine.addParticipant(id, service, tabId, `${info.name}-${count}`);
  // v5.0.22 B: 登录红绿灯初始态 — checking；探测落定后翻 ok / login_required
  const newP = StateMachine.getParticipant(id);
  if (newP) newP.loginStatus = "checking";
  notifyStatus(`已添加 ${info.name}-${count}`);
  StateMachine._broadcastStateUpdate();
  // v4.7.2 F22 → v5.0.22: 异步检测登录态（带一次重试），结果落 participant.loginStatus
  checkLoginStatus(id, `${info.name}-${count}`, service).catch(() => {});

  // 并列模式下自动排列窗口
  if (windowMode === "tiled") {
    // 等页面稍微加载后再排列
    setTimeout(async () => {
      try { await arrangeWindows(); } catch (_) {}
      // v4.3.0：排列窗口后把 popup 拉回前端，避免用户失焦
      try { await ChatBus.focusPopup(); } catch (_) {}
    }, 500);
  }

  // v4.3.0：立即把 popup 拉回前端（不等 arrange）
  try { await ChatBus.focusPopup(); } catch (_) {}

  // v4.8.29 F37 混合模式: Tab 模式持久 attach chrome.debugger（黄条不影响 tab UI），
  // 并列模式靠 MAIN world bootstrap-main-world.js 自动注入（已在 manifest content_scripts）
  if (windowMode === "tab" && self.CDPExtractor) {
    // v5.0.22 C: 黄条出现前就地安抚（只弹一次）
    maybeNotifyDebuggerNotice();
    setTimeout(async () => {
      // v4.8.30 F38-③: timeout 内重检 windowMode — 1.5s 内若切到 tiled，不该再 attach
      if (windowMode !== "tab") {
        console.log(`[F38] addParticipant timeout: mode changed to ${windowMode}, skip attach`);
        return;
      }
      try {
        const r = await self.CDPExtractor.attachAndWake(tabId);
        console.log(`[F37] tab-mode CDP attach service=${service} tab=${tabId} ok=${r?.ok} code=${r?.code}`);
      } catch (e) { console.warn(`[F37] attach fail:`, e?.message); }
    }, 1500);
  }

  return { ok: true, participants: StateMachine.getFullState().participants };
}

async function removeParticipant(id) {
  const p = StateMachine.getParticipant(id);
  if (!p) return { ok: false };
  // v4.8.29 F37: 移除参与者前 detach 该 tab 的 CDP（如已 attach）
  if (p.tabId && self.CDPExtractor) {
    try { await self.CDPExtractor.detach(p.tabId); } catch (_) {}
  }
  if (p.tabId) { _removingTabs.add(p.tabId); try { await chrome.tabs.remove(p.tabId); } catch {} }
  StateMachine.removeParticipant(id);
  notifyStatus(`已移除 ${p.name}`);
  StateMachine._broadcastStateUpdate();
  return { ok: true, participants: StateMachine.getFullState().participants };
}

// ── 广播（状态机驱动） ──

async function handleBroadcast(text, images) {
  StateMachine.debateSession.originalQuestion = text;
  StateMachine.debateSession.rounds = [];
  StateMachine.debateSession.summaryText = "";
  StateMachine.setFlowState(FlowState.BROADCASTING);

  StateMachine.participants.forEach(p => {
    p.response = null;
    p.responsePreview = null;
    // v4.8.43: 广播新一题 → 清除用户编辑标记，让 polling 写入新 AI 答案
    delete p.userEdited;
  });
  StateMachine.save();
  StateMachine._broadcastStateUpdate();

  const results = {};
  const captainEnabled = await self.ArenaCaptainMode.isEnabled();
  const allParticipants = StateMachine.participants || [];
  await Promise.all(StateMachine.participants.map(async (p) => {
    if (!p.tabId) {
      results[p.id] = { name: p.name, status: "error", error: "未打开", code: "TAB_NOT_FOUND", snapshot: { service: p.service, stage: "injecting" } };
      return;
    }
    const prompt = self.ArenaCaptainMode.decoratePrompt(text, p, allParticipants, captainEnabled);
    // 记录"刚发给该 p 的 prompt"——readOneResponse 用此校验防把用户消息当成 AI 回复
    StateMachine.setLastSent(p.id, prompt);
    const tryInject = async () => {
      const ready = await waitForContentScript(p.tabId);
      if (!ready) return { name: p.name, status: "error", error: "页面未就绪" };
      if (images && images.length > 0) {
        // v5.0.17 P1-6: 注图失败不再让整个 participant 的 async 回调直接 throw —
        //   旧版 results[p.id] 缺失，该 AI 不注入、不 polling、气泡永远空白且无提示。
        //   图片注入失败降级为"只发文字"，并在结果里标记让 popup 可见。
        try {
          await sendMessageWithTimeout(p.tabId, { action: "injectImages", images }, 15000);
        } catch (e) {
          console.warn(`[bg] injectImages 失败 (${p.service})，降级只发文字:`, e?.message);
          notifyStatus(`⚠ ${p.name} 图片注入失败，已只发送文字`);
        }
      }
      const result = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
      return { name: p.name, ...result };
    };
    try {
      const r = await tryInject();
      if (r.status === "error") {
        await new Promise(ok => setTimeout(ok, 2000));
        results[p.id] = await tryInject();
      } else {
        results[p.id] = r;
      }
    } catch (e) {
      try {
        await new Promise(ok => setTimeout(ok, 2000));
        results[p.id] = await tryInject();
      } catch (e2) {
        results[p.id] = { name: p.name, status: "error", error: e2.message };
      }
    }
  }));

  StateMachine.save();
  StateMachine._broadcastStateUpdate();

  const allOk = Object.values(results).every(r => r.status === "sent" || r.status === "inputted");
  const anyOk = Object.values(results).some(r => r.status === "sent" || r.status === "inputted");
  if (allOk) {
    StateMachine.setFlowState(FlowState.AWAITING_RESPONSES);
    notifyStatus("广播完成，等待回复...");
    // 唤醒所有 AI 标签页，确保后台标签页恢复 DOM 渲染
  } else {
    StateMachine.setFlowState(anyOk ? FlowState.AWAITING_RESPONSES : FlowState.IDLE);
    notifyStatus("部分发送失败，请处理后继续");
  }

  return results;
}

// v4.8.5 F25: 鲁棒化 — 3 次重试 + 超时 + 启动 polling + popup loading 占位
async function retryInjectParticipant(id) {
  const p = StateMachine.getParticipant(id);
  if (!p || !p.tabId) return { ok: false, error: "参与者无效" };
  const text = StateMachine.debateSession.originalQuestion;
  if (!text) return { ok: false, error: "无原始问题可重发（debateSession 为空）" };
  // v4.8.43: 用户重发 → 清除 userEdited 让新 AI 答案能被 polling 写入
  try { StateMachine.clearUserEdited?.(id); } catch (_) {}

  const pendingMsgId = `m${Date.now()}_retry_${p.id}`;
  const displayText = `🔄 重发原题：${text.length > 40 ? text.slice(0, 40) + "…" : text}`;
  // 立刻推 popup loading 占位
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "user",
      msgId: pendingMsgId,
      text: `${displayText} · 正在发送…`,
    }).catch(() => {});
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: p.service,
      text: "", isDone: false,
    }).catch(() => {});
  } catch (_) {}

  const MAX_TRIES = 3;
  let lastError = "未知错误";
  let injectResult = null;
  for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
    try {
      const ready = await waitForContentScript(p.tabId);
      if (!ready) {
        lastError = "页面未就绪 (content script 失联)";
        console.warn(`[F35] retryInject ${p.service} attempt ${attempt + 1}/${MAX_TRIES}: ${lastError}`);
      } else {
        StateMachine.setLastSent(p.id, text);
        const result = await sendMessageWithTimeout(p.tabId, { action: "inject", text }, 15000);
        if (result?.status === "sent" || result?.status === "inputted") {
          injectResult = result;
          break;
        }
        lastError = result?.error || `inject 异常状态: ${result?.status}`;
        console.warn(`[F35] retryInject ${p.service} attempt ${attempt + 1}/${MAX_TRIES}: status=${result?.status} err=${result?.error}`);
      }
    } catch (e) {
      lastError = e?.message || lastError;
      console.warn(`[F35] retryInject ${p.service} attempt ${attempt + 1}/${MAX_TRIES} EXCEPTION:`, e?.message);
    }
    if (attempt < MAX_TRIES - 1) {
      await new Promise(r => setTimeout(r, 1500));
    }
  }

  if (injectResult) {
    notifyStatus(`已重发原题给 ${p.name}`);
    try {
      ChatBus.notifyRoundStart(displayText, [p.service], pendingMsgId);
    } catch (e) { console.warn("[bg] notifyRoundStart fail:", e?.message); }
    return { ok: true, result: injectResult };
  }

  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: p.service,
      text: `⚠ 重发失败：${lastError}\n\n请确认 ${p.name} 网页可用，必要时刷新页面后再试。`,
      isDone: true,
    }).catch(() => {});
  } catch (_) {}
  notifyStatus(`⚠ 重发给 ${p.name} 失败: ${lastError}`);
  return { ok: false, error: lastError };
}

// ── 手动发送给单个参与者（根据当前阶段自动构建 prompt） ──
async function sendToOneParticipant(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p?.tabId) return { ok: false, error: "参与者无效" };

  try {
    const ready = await waitForContentScript(p.tabId);
    if (!ready) return { ok: false, error: "页面未就绪" };

    let text;
    const rounds = StateMachine.debateSession.rounds;
    if (rounds.length === 0) {
      // 初始广播阶段：发原始问题
      text = StateMachine.debateSession.originalQuestion || "";
    } else {
      // 辩论阶段：构建该参与者的辩论 prompt
      const lastRound = rounds[rounds.length - 1];
      const responses = lastRound.responses || {};
      text = DebateEngine.buildDebatePrompt(
        participantId, responses, lastRound.style || "free",
        lastRound.roundNum, lastRound.guidance || "", false
      );
    }

    if (!text) return { ok: false, error: "无可发送内容" };
    text = await self.ArenaCaptainMode.apply(text, p, StateMachine.participants);
    // 记录"刚发给该 p 的 prompt"——sanity check 用
    StateMachine.setLastSent(participantId, text);
    const result = await chrome.tabs.sendMessage(p.tabId, { action: "inject", text });
    return { ok: result.status !== "error", result };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

// ── 向指定服务单独发送任意 prompt（PPT 制作 / 气泡 🔄 重发） ──
// v4.8.5 F25: 鲁棒化 — 3 次重试 + 立刻 popup loading 占位 + 成功后启动 polling
//   让 popup 同步新 AI 回答（之前 inject 完不启动 polling，popup 永远看不到结果）
// v4.8.7 F26: text 缺省时从 StateMachine.lastSentByPid 取完整 prompt
//   修复辩论/总结场景重发 bug — popup user 气泡显示的是 "⚔️ 第N轮辩论..." 短文本
//   不是完整 prompt，气泡 🔄 重发取 popup 显示文本会丢完整内容
// v5.0.10 fix: 气泡 🔄 重发**优先取 originalQuestion**（最新一次用户广播原题），
//   而非 lastSentByPid（被辩论/总结 prompt 污染）。
//   用户场景：广播 Q1 → 辩论 3 轮 → 在 R1 气泡点 🔄，期望"用最新提问重答"而不是"重发第 3 轮辩论 prompt"。
//   originalQuestion 只在 broadcast / handleBroadcast 路径被覆盖，是"用户最近输入框原题"的可靠源。
//   仅当 originalQuestion 也为空（从未广播过）才 fallback 到 lastSentByPid。
async function sendPromptToService(service, text) {
  const p = StateMachine.participants.find(x => x.service === service);
  if (!p?.tabId) return { ok: false, error: `未找到已打开的 ${SERVICES[service]?.name || service} 参与者` };

  let prompt = (text || "").trim();
  if (!prompt) {
    prompt = (StateMachine.debateSession?.originalQuestion || "").trim();
  }
  if (!prompt) {
    // 兜底（从未广播过、PPT 等非常规调用路径）— 仍用 lastSentByPid
    prompt = (StateMachine.lastSentByPid?.[p.id] || "").trim();
  }
  if (!prompt) return { ok: false, error: "无可重发的 prompt（未找到最新提问也无上次发送内容）" };

  // v4.8.5 F25: 立刻推 popup loading 占位（pendingMsgId 复用模式，类似 F20/F21）
  const pendingMsgId = `m${Date.now()}_resend_${p.id}`;
  const displayText = `🔄 重发：${prompt.length > 40 ? prompt.slice(0, 40) + "…" : prompt}`;
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "user",
      msgId: pendingMsgId,
      text: `${displayText} · 正在发送…`,
    }).catch(() => {});
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: p.service,
      text: "", isDone: false,
    }).catch(() => {});
  } catch (_) {}

  // v4.8.5 F25: 3 次重试 inject + sendMessageWithTimeout 15s 防挂死
  const MAX_TRIES = 3;
  let lastError = "未知错误";
  let injectResult = null;
  for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
    try {
      const ready = await waitForContentScript(p.tabId);
      if (!ready) {
        lastError = "页面未就绪 (content script 失联)";
      } else {
        p.response = null;
        p.responsePreview = null;
        StateMachine.setLastSent(p.id, prompt);
        StateMachine.setFlowState(FlowState.BROADCASTING);
        StateMachine.save();
        StateMachine._broadcastStateUpdate();

        const result = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
        if (result?.status === "sent" || result?.status === "inputted") {
          injectResult = result;
          break;
        }
        lastError = result?.error || `inject 异常状态: ${result?.status}`;
      }
    } catch (e) {
      lastError = e?.message || lastError;
    }
    if (attempt < MAX_TRIES - 1) {
      await new Promise(r => setTimeout(r, 1500));
    }
  }

  if (injectResult) {
    StateMachine.setFlowState(FlowState.AWAITING_RESPONSES);
    notifyStatus(`已重发给 ${p.name}`);
    // 启动 polling 让 AI 新回答能同步进 popup（复用 pendingMsgId 让占位气泡自动升级）
    try {
      ChatBus.notifyRoundStart(displayText, [p.service], pendingMsgId);
    } catch (e) { console.warn("[bg] notifyRoundStart fail:", e?.message); }
    return { ok: true, participantId: p.id, name: p.name, result: injectResult };
  }

  // 3 次都失败 — 推明确错误气泡（复用 pendingMsgId 升级 loading 为错误信息）
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: p.service,
      text: `⚠ 重发失败：${lastError}\n\n请确认 ${p.name} 网页可用，必要时刷新页面后再试。`,
      isDone: true,
    }).catch(() => {});
  } catch (_) {}
  notifyStatus(`⚠ 重发给 ${p.name} 失败: ${lastError}`);
  return { ok: false, error: lastError };
}

// ── 辩论（状态机驱动） ──

// v4.8.38 + v4.8.39: 辩论前 sanity 检查 — 三类警告合并到一个 needsConfirm
//   ① polling: 有 AI 正在 polling 中 → p.response 是上一轮的（v4.8.38）
//   ② too_short: 回答 < 50 字 → 可能 ChatGPT Pro 在思考中被误判为完成（v4.8.39）
//   ③ same_as_last: 回答与上一轮完全相同 → 可能提取 bug（v4.8.39）
const DEBATE_TOO_SHORT_THRESHOLD = 50;

function _buildDebateWarnings(responses) {
  const warnings = [];
  // ① polling
  let pollingServices = [];
  try {
    pollingServices = ChatBus.getActivePollingServices?.() || [];
  } catch (_) {}
  for (const svc of pollingServices) {
    const p = StateMachine.participants.find(pp => pp.service === svc);
    if (p) warnings.push({ type: "polling", name: p.name, service: svc });
  }
  // ② / ③ 逐个 response 检查（同一 AI 字数过短优先，不再叠加 same_as_last）
  const lastRound = StateMachine.debateSession.rounds.slice(-1)[0];
  for (const [id, r] of Object.entries(responses)) {
    const text = (r.text || "").trim();
    if (text.length < DEBATE_TOO_SHORT_THRESHOLD) {
      warnings.push({ type: "too_short", name: r.name, length: text.length });
      continue;
    }
    if (lastRound?.responses?.[id]?.text === r.text) {
      warnings.push({ type: "same_as_last", name: r.name });
    }
  }
  return warnings;
}

function _formatDebateWarningMessage(warnings) {
  const polling = warnings.filter(w => w.type === "polling");
  const tooShort = warnings.filter(w => w.type === "too_short");
  const sameAsLast = warnings.filter(w => w.type === "same_as_last");
  const lines = [];
  if (polling.length) {
    lines.push(`⏳ ${polling.length} 个 AI 仍在回答中：${polling.map(w => w.name).join("、")}`);
  }
  if (tooShort.length) {
    lines.push(`⚠ ${tooShort.length} 个 AI 回答过短（< ${DEBATE_TOO_SHORT_THRESHOLD} 字，可能在思考中未输出完）：${tooShort.map(w => `${w.name}(${w.length}字)`).join("、")}`);
  }
  if (sameAsLast.length) {
    lines.push(`⚠ ${sameAsLast.length} 个 AI 回答与上一轮完全相同（可能提取 bug）：${sameAsLast.map(w => w.name).join("、")}`);
  }
  lines.push("");
  lines.push("用当前内容继续辩论？");
  return lines.join("\n");
}

async function handleDebateRound(style = "free", guidance = "", concise = false, force = false) {
  if (StateMachine.participants.length < 2) {
    notifyStatus("至少需要 2 个参与者");
    return { ok: false, error: "参与者不足", reason: "not_enough_participants" };
  }

  const responses = {};
  for (const p of StateMachine.participants) {
    if (p.response) {
      responses[p.id] = { name: p.name, text: p.response };
    }
  }

  if (Object.keys(responses).length < 2) {
    notifyStatus("至少需要 2 个有效回答");
    // v4.8.65: reason 让 popup 弹自定义 modal（重新提取 / 切同时提问），代替 alert
    // missingIds 让 popup 知道哪些 AI 还没回答 → 重新提取按钮只针对它们
    const missing = StateMachine.participants
      .filter(p => !p.response)
      .map(p => ({ id: p.id, name: p.name, service: p.service }));
    const haveCount = Object.keys(responses).length;
    return {
      ok: false,
      error: "回答不足",
      reason: "insufficient_responses",
      haveCount,
      totalCount: StateMachine.participants.length,
      missing,
    };
  }

  // v4.8.38 + v4.8.39: sanity 检查 — 三类警告合并到一个 needsConfirm
  if (!force) {
    const warnings = _buildDebateWarnings(responses);
    if (warnings.length > 0) {
      const polling = warnings.filter(w => w.type === "polling");
      return {
        ok: false,
        needsConfirm: true,
        reason: "suspicious_state",
        warnings,
        // 向后兼容字段（v4.8.38 时只有 polling 一类）
        pollingServices: polling.map(w => w.service),
        pollingNames: polling.map(w => w.name),
        message: _formatDebateWarningMessage(warnings),
      };
    }
  }

  const roundNum = StateMachine.debateSession.rounds.length + 1;
  StateMachine.setFlowState(FlowState.BROADCASTING);
  notifyStatus(`第${roundNum}轮：以「${DEBATE_STYLES[style]?.name || style}」风格交叉发送...`);

  // v4.6.13 F20: 立刻推 pending 占位气泡 — 在 inject 1-3s 等待前先让 popup 显示反馈
  // 避免用户按下辩论按钮后觉得"插件卡住了"。inject 完成后用同 msgId 替换为正式 displayText。
  const styleName = DEBATE_STYLES[style]?.name || style;
  const guidanceSuffix = guidance ? `：${guidance}` : "";
  const displayText = `⚔️ 第${roundNum}轮辩论·${styleName}${guidanceSuffix}`;
  const pendingMsgId = `m${Date.now()}_d${roundNum}`;
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "user",
      msgId: pendingMsgId,
      text: `${displayText} · 正在发起...`,
    }).catch(() => {});
    // 给每个候选参与者推 loading 气泡占位
    for (const id of Object.keys(responses)) {
      const p = StateMachine.getParticipant(id);
      if (p?.service) {
        chrome.runtime.sendMessage({
          type: "chatStreamUpdate", role: "ai",
          msgId: pendingMsgId, participantId: p.service,
          text: "", isDone: false,
        }).catch(() => {});
      }
    }
  } catch (_) {}

  // v4.5.5 F4: 进入新一轮前先清所有参与者 response，防止上一轮某个 AI 晚到的回答
  // 污染下一轮 — race 场景：A/B 5-8s 完成 → 用户启动第 1 轮 → C 15s 晚到，C 的旧轮
  // 初始回答会塞进 p.response，下一轮 buildDebatePrompt 拿到混乱上下文
  StateMachine.participants.forEach(p => {
    p.response = null;
    p.responsePreview = null;
    // v4.8.43: 辩论新轮 → 清除用户编辑标记，让 polling 写入新 AI 答案
    delete p.userEdited;
  });

  const sendResults = {};
  // v5.0.11: 暂存每个 pid 的辩论 prompt — 用于"补发缺失 AI"按钮（用户场景：3 选其中 1 个
  //   inject 失败被静默丢，弹窗让用户点补发，background 用这里暂存的 prompt 重 inject）
  const promptsByPid = {};
  const captainEnabled = await self.ArenaCaptainMode.isEnabled();
  // v5.0.19: 上下文压缩开关（设置 tab，默认关）— 多轮辩论队友回答全文转发会偶发触顶
  //   公司网关上传限额；开启后按预算压缩队友回答（详见 debate-engine compactTextForRelay）
  const contextCompress = await _isContextCompressEnabled();
  const allParticipants = StateMachine.participants || [];
  await Promise.all(Object.keys(responses).map(async (id) => {
    const p = StateMachine.getParticipant(id);
    if (!p?.tabId) {
      // v5.0.11: tab 失联也记一条 error 进 sendResults，让下方 missingIds 检测能捕获
      //   原逻辑直接 return → sendResults[id] 缺失 → sentIds 不含它 → 静默丢失（用户感知 bug）
      sendResults[id] = { site: p?.service || id, status: "error", error: "tab 失联（参与者未打开）" };
      _endDebateLoadingBubble(pendingMsgId, p, "tab 失联（参与者未打开）");
      return;
    }
    let prompt = DebateEngine.buildDebatePrompt(id, responses, style, roundNum, guidance, concise, contextCompress);
    prompt = self.ArenaCaptainMode.decoratePrompt(prompt, p, allParticipants, captainEnabled);
    promptsByPid[id] = prompt;  // v5.0.11
    // 记录"刚发给该 p 的 prompt"——sanity check 用
    StateMachine.setLastSent(id, prompt);
    // v5.0.17 P0-3: 裸 sendMessage 改 15s 超时（对齐 retry 路径）— content script 收到消息
    //   但 promise 链异常永不 sendResponse（tab 冻结边缘态）时，旧版整轮 Promise.all 永挂。
    try {
      sendResults[id] = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
    } catch (e) {
      await new Promise(ok => setTimeout(ok, 2000));
      try {
        sendResults[id] = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
      } catch (e2) {
        sendResults[id] = { site: p.service, status: "error", error: e2.message };
        notifyStatus(`注入 ${p.name} 失败: ${e2.message}`);
      }
    }
    // v5.0.17 P0-3 解耦核心：inject 成功立即启动该 AI 自己的 polling，不等其他 AI。
    //   旧版"全部发完才统一启 polling"（Promise.all 屏障后 notifyRoundStart），千问 inject
    //   慢/失败会拖累 gemini/豆包早已就绪的回答提取（用户实锤：去掉千问一切正常）。
    //   已知 tradeoff：sendMessageWithTimeout 超时 ≠ 未发送（content script 可能 15s 后仍完成
    //   发送），重试存在小概率双发；与旧版 catch+重试行为一致，根治需幂等 token 协议，暂记录。
    const st = sendResults[id]?.status;
    if (st === "sent" || st === "inputted") {
      try { ChatBus.startPollingForService(p, pendingMsgId); }
      catch (e) { console.warn("[bg] startPollingForService fail:", p.service, e?.message); }
    } else {
      // v5.0.17: 终结该 AI 的 loading 占位气泡 — 旧版 inject 失败的 AI 气泡永远转圈无提示
      _endDebateLoadingBubble(pendingMsgId, p, sendResults[id]?.error || sendResults[id]?.status || "未知原因");
    }
  }));

  const sentIds = Object.entries(sendResults)
    .filter(([, r]) => r?.status === "sent" || r?.status === "inputted")
    .map(([id]) => id);

  if (sentIds.length >= 2) {
    StateMachine.debateSession.rounds.push({
      roundNum, style, guidance,
      responses: Object.fromEntries(Object.entries(responses).map(([id, r]) => [id, { name: r.name, text: r.text }]))
    });
    // v5.0.17 P0-3: 删除此处对 sentIds 的二次 response 清空 — 入口处（inject 前）已统一清过。
    //   解耦后 polling 在 inject 成功即启动，快 AI 可能在 Promise.all 屏障结束前就完成回答
    //   并写入 p.response，这里再清会把刚到手的辩论回答抹掉。
  }

  StateMachine.save();
  StateMachine.setFlowState(sentIds.length > 0 ? FlowState.AWAITING_RESPONSES : FlowState.IDLE);

  // v5.0.11: 检测 partial inject — 候选 AI 中实际 inject 成功的少于全部
  //   场景：3 个 AI 都已回答原题（response 都非空），但发起辩论时 1 个 inject 失败/tab 失联
  //   被静默丢。新增 partialInject 信号让 popup 弹窗，用户选"补发"或"跳过"
  const candidateIds = Object.keys(responses);
  const missingIds = candidateIds.filter(id => !sentIds.includes(id));
  const missingPayload = missingIds.map(id => {
    const p = StateMachine.getParticipant(id);
    return {
      id, name: p?.name || id, service: p?.service || "",
      error: sendResults[id]?.error || sendResults[id]?.status || "未知原因",
    };
  });
  // 暂存 missing AI 的 prompt（弹窗"补发"按钮会调 retryDebateInjectForParticipant 用这个）
  if (missingIds.length > 0) {
    StateMachine._lastPartialDebatePrompts = {};
    for (const id of missingIds) {
      if (promptsByPid[id]) StateMachine._lastPartialDebatePrompts[id] = promptsByPid[id];
    }
    // v5.0.19: 一并暂存本轮构建上下文 — "压缩后补发"用它重建压缩版 prompt
    //   （直接压缩已拼好的 prompt 字符串无法区分模板/队友回答边界，必须重建）
    StateMachine._lastPartialDebateContext = { responses, style, roundNum, guidance, concise };
  }

  // v5.0.19: 超长 prompt 提醒 — 未开压缩且本轮有 prompt 超阈值时提示用户（仅提示不拦截）
  const maxPromptLen = Math.max(0, ...Object.values(promptsByPid).map(s => s?.length || 0));
  const longPromptHint = !contextCompress && maxPromptLen > LONG_PROMPT_WARN_CHARS;
  if (longPromptHint) {
    notifyStatus(`⚠ 本轮辩论 prompt 最长 ${maxPromptLen} 字，可能触发公司网关上传限额；可在设置开启「上下文压缩」`);
  }

  if (sentIds.length < 2) {
    notifyStatus("辩论发送失败：有效接收方不足");
    return {
      ok: false, error: "有效接收方不足",
      roundNum, activeIds: sentIds, results: sendResults,
      // v5.0.11: 即使辩论未启动也带 partialInject 让弹窗能提示
      partialInject: missingIds.length > 0,
      missing: missingPayload,
      sentCount: sentIds.length,
      totalCount: candidateIds.length,
      debateStarted: false,
      longPromptHint, maxPromptLen,  // v5.0.19: 弹窗可提示"压缩后补发"
    };
  }
  notifyStatus(`第${roundNum}轮辩论已发送`);

  // 同步 popup 群聊：显示用户气泡（polling 已在各 AI inject 成功时逐个启动，skipPolling 防重启重置计数）
  // v4.6.13 F20: 用 pendingMsgId 复用入口时推的占位气泡 → popup 收到时更新文本而非新增气泡
  try {
    const services = sentIds.map(id => StateMachine.getParticipant(id)?.service).filter(Boolean);
    ChatBus.notifyRoundStart(displayText, services, pendingMsgId, { skipPolling: true });
  } catch (e) {
    // v5.0.17 P1-7: fail-loud — 旧版只 console.warn，polling 启动失败时气泡永远 loading 无提示。
    //   解耦后 polling 已逐 AI 启动，这里失败只影响气泡文本，但仍通知用户避免困惑。
    console.warn("[chat-bus] notifyRoundStart failed:", e.message);
    notifyStatus(`⚠ 辩论已发送但群聊气泡更新失败: ${e.message}`);
  }

  return {
    ok: true, roundNum, activeIds: sentIds, results: sendResults,
    // v5.0.11: 辩论已开始但有 AI 漏掉 → popup 弹窗让用户补发
    partialInject: missingIds.length > 0,
    missing: missingPayload,
    sentCount: sentIds.length,
    totalCount: candidateIds.length,
    debateStarted: true,
    longPromptHint, maxPromptLen,  // v5.0.19
  };
}

// v5.0.60 顺序接力 prompt：第一棒 = 纯用户提问；后面每棒 = 提问 + 前面 AI 已答（按发言先后累积）。
function buildSequentialPrompt(question, priorList, guidance) {
  let p = String(question || "");
  if (guidance && guidance.trim()) p += `\n\n（讨论引导：${guidance.trim()}）`;
  if (Array.isArray(priorList) && priorList.length) {
    p += "\n\n―――― 前面 AI 已经回答（按发言先后） ――――\n";
    priorList.forEach((r, i) => { p += `\n【${i + 1}. ${r.name}】\n${r.text}\n`; });
    p += "\n――――――――――――――――――――\n请在认真阅读以上回答后发表你的观点：可补充新角度、纠正或反驳，但不要简单重复别人已说过的内容。";
  }
  return p;
}

// v5.0.60 顺序接力：单 AI 严格串行，每家只出一棒；从「用户提问」开始（不要求预先有回答）——
//   第一棒收到纯提问，第 N 棒收到「提问 + 前 N-1 个 AI 的回答」（buildSequentialPrompt 累积）。
//   - sequence.order：service id 列表（发言顺序），含重复 service 直接拒绝
//   - 逐个 inject → await 该 AI 答完（waitForOneParticipant 180s）→ 回答塞进 accumulatedResponses
//   - 某 AI 超时/失联 → catch 跳过（不进 accumulatedResponses），整体继续不中止
async function handleDebateRoundSequential(userInput = "", guidance = "", concise = false, force = false, sequence = {}) {
  if (StateMachine.participants.length < 2) {
    notifyStatus("至少需要 2 个参与者");
    return { ok: false, error: "参与者不足", reason: "not_enough_participants" };
  }
  // 顺序接力从用户提问开始，不依赖预先回答
  const question = String(userInput || "").trim();
  if (!question) {
    notifyStatus("请先在输入框输入要讨论的问题");
    return { ok: false, error: "缺少问题", reason: "empty_question" };
  }

  // order → participant 映射；重复 service 拒绝（坑4）
  const order = Array.isArray(sequence?.order) ? sequence.order : [];
  if (!order.length) {
    return { ok: false, error: "未指定发言顺序", reason: "empty_order" };
  }
  if (new Set(order).size !== order.length) {
    return { ok: false, error: "发言顺序含重复 AI", reason: "duplicate_service" };
  }
  const orderedParticipants = [];
  for (const svc of order) {
    const p = (StateMachine.participants || []).find(x => x.service === svc);
    if (p) orderedParticipants.push(p);  // 不在会话的 service 静默忽略（fail-soft，下方按实际长度判断）
  }
  if (orderedParticipants.length < 1) {
    return { ok: false, error: "发言顺序无有效参与者", reason: "no_valid_participants" };
  }

  const roundNum = StateMachine.debateSession.rounds.length + 1;
  StateMachine.setFlowState(FlowState.BROADCASTING);
  notifyStatus(`第${roundNum}轮·顺序接力：${orderedParticipants.length} 个 AI 依次发言...`);

  // 进入新一轮前先清所有参与者 response（与 handleDebateRound 同款，防上一轮晚到污染）
  StateMachine.participants.forEach(p => {
    p.response = null;
    p.responsePreview = null;
    delete p.userEdited;
  });

  // 推 pending 占位气泡（🔗 接力）
  const guidanceSuffix = guidance ? `：${guidance}` : "";
  const displayText = `🔗 第${roundNum}轮·顺序接力${guidanceSuffix}`;
  const pendingMsgId = `m${Date.now()}_s${roundNum}`;
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "user", msgId: pendingMsgId,
      text: `${displayText} · 正在发起...`,
    }).catch(() => {});
    // 改进2：AI 占位气泡不再一次性全推 —— 改到主循环内、轮到该 AI 才推（串行逐个弹出）
  } catch (_) {}

  const captainEnabled = await self.ArenaCaptainMode.isEnabled();
  const contextCompress = await _isContextCompressEnabled();
  const allParticipants = StateMachine.participants || [];

  // 串行接力主循环：accumulatedResponses 随每个 AI 答完逐步增长
  const accumulatedResponses = {};
  const sequenceResults = [];
  for (let idx = 0; idx < orderedParticipants.length; idx++) {
    const p = orderedParticipants[idx];
    // 坑3：SW 长任务保活 — 每步前摸一下 storage 防 30s idle 回收
    try { await chrome.storage.local.get(["__noop"]); } catch (_) {}

    // 改进2：轮到该 AI 才推它的占位气泡 → 串行逐个弹出（符合"AI 逐一作答"真实节奏，
    //   前一个答完气泡填好回答后，下一个才弹"提取中"，不再一次性三个全弹）
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai", msgId: pendingMsgId,
      participantId: p.service, text: "", isDone: false,
    }).catch(() => {});

    if (!p.tabId) {
      _endDebateLoadingBubble(pendingMsgId, p, "tab 失联（参与者未打开）");
      sequenceResults.push({ service: p.service, skipped: true, text: "", reason: "no_tab" });
      continue;
    }

    // 构建 prompt：顺序接力 —— 第一棒纯用户提问，后面每棒带上前面 AI 已答（按顺序累积）
    const priorList = Object.values(accumulatedResponses);
    let prompt = buildSequentialPrompt(question, priorList, guidance);
    prompt = self.ArenaCaptainMode.decoratePrompt(prompt, p, allParticipants, captainEnabled);
    StateMachine.setLastSent(p.id, prompt);  // sanity check 基准

    // inject（复用 handleDebateRound 的注入写法：waitForContentScript + sendMessageWithTimeout 15s + 1 次重试）
    let injectResult = null;
    try {
      const ready = await waitForContentScript(p.tabId);
      if (!ready) throw new Error("content script 未就绪");
      injectResult = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
    } catch (e) {
      await new Promise(ok => setTimeout(ok, 2000));
      try {
        const ready2 = await waitForContentScript(p.tabId);
        if (!ready2) throw new Error("content script 未就绪");
        injectResult = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
      } catch (e2) {
        injectResult = { site: p.service, status: "error", error: e2.message };
        notifyStatus(`注入 ${p.name} 失败: ${e2.message}`);
      }
    }
    const st = injectResult?.status;
    if (st !== "sent" && st !== "inputted") {
      _endDebateLoadingBubble(pendingMsgId, p, injectResult?.error || injectResult?.status || "未知原因");
      sequenceResults.push({ service: p.service, skipped: true, text: "", reason: injectResult?.error || "inject_failed" });
      continue;
    }

    // 启动该 AI 的 polling（pollOnce 完成时 resolve waitForOneParticipant）
    try { ChatBus.startPollingForService(p, pendingMsgId); }
    catch (e) { console.warn("[bg] sequential startPollingForService fail:", p.service, e?.message); }

    // 等该 AI 答完，把回答纳入上下文；超时/失联 → 跳过，下游不引用它
    try {
      const r = await ChatBus.waitForOneParticipant(p.service, 180000);
      accumulatedResponses[p.id] = { name: p.name, text: r.text };
      sequenceResults.push({ service: p.service, skipped: false, text: r.text });
    } catch (e) {
      // 跳过：pollOnce 已推过 emptyTimeout/断开气泡（含错误原因），这里补一条接力跳过提示
      chrome.runtime.sendMessage({
        type: "chatStreamUpdate", role: "ai", msgId: pendingMsgId,
        participantId: p.service,
        text: `⏭ ${p.name} 本轮接力超时/失联，已跳过（后续 AI 不引用其回答）`,
        isDone: true, skipped: true,
      }).catch(() => {});
      sequenceResults.push({ service: p.service, skipped: true, text: "", reason: e?.message || "timeout" });
    }
  }

  // 落库本轮（style 标记 sequential）
  StateMachine.debateSession.rounds.push({
    roundNum, style: "sequential", guidance,
    responses: Object.fromEntries(Object.entries(accumulatedResponses).map(([id, r]) => [id, { name: r.name, text: r.text }])),
  });
  StateMachine.save();

  const answeredCount = Object.keys(accumulatedResponses).length;
  StateMachine.setFlowState(answeredCount > 0 ? FlowState.AWAITING_RESPONSES : FlowState.IDLE);

  // 更新用户气泡为正式文案 —— 只更 user 气泡，绝不重推 AI 占位。
  //   （notifyRoundStart 的 for 循环会用空占位 text:"" 覆盖已填好的回答气泡，
  //    这正是 v5.0.60 顺序接力"卡片空白/提取失败"的根因，改为直接更 user 气泡。）
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "user", msgId: pendingMsgId, text: displayText,
    }).catch(() => {});
  } catch (_) {}

  notifyStatus(`第${roundNum}轮·顺序接力完成（${answeredCount}/${orderedParticipants.length} 个 AI 已答）`);
  return {
    ok: true, roundNum,
    answeredCount, totalCount: orderedParticipants.length,
    sequenceResults,
  };
}

// v5.0.19: 上下文压缩开关 + 超长 prompt 阈值
const CONTEXT_COMPRESS_KEY = "debateContextCompressEnabled";
const LONG_PROMPT_WARN_CHARS = 8000;
async function _isContextCompressEnabled() {
  try {
    const data = await chrome.storage.local.get([CONTEXT_COMPRESS_KEY]);
    return data?.[CONTEXT_COMPRESS_KEY] === true;  // 默认关，用户显式打开才压缩
  } catch (_) {
    return false;
  }
}

// v5.0.17: 辩论 inject 失败时终结该 AI 的 loading 占位气泡（isDone:true），
//   配合 partialInject 弹窗给用户"补发"指引；旧版气泡永远转圈且无提示。
function _endDebateLoadingBubble(msgId, p, reason) {
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId, participantId: p?.service,
      text: `⚠ ${p?.name || p?.service || "该 AI"} 辩论注入失败：${reason}（可在弹窗中点"补发缺失"重试）`,
      isDone: true, injectError: true,
    }).catch(() => {});
  } catch (_) {}
}

// v5.0.11: 补发辩论 prompt 给 partial inject 失败的 AI
//   触发场景：弹窗 showPartialDebateInject 用户点"补发缺失"按钮 → popup 对每个 missing
//   AI 调本 message。用 StateMachine._lastPartialDebatePrompts 暂存的同一轮辩论 prompt
//   重新 inject（保证 prompt 与该轮其他 AI 收到的语义一致），并启动 polling 让回答能进 popup。
async function retryDebateInjectForParticipant(pid, opts = {}) {
  const p = StateMachine.getParticipant(pid);
  if (!p?.tabId) return { ok: false, error: "参与者无效或 tab 失联" };
  let prompt = StateMachine._lastPartialDebatePrompts?.[pid];
  if (!prompt) return { ok: false, error: "无可补发的辩论 prompt（已过期或已被重置）" };

  // v5.0.19: "压缩后补发" — 用暂存的本轮构建上下文重建压缩版 prompt（含队长装饰），
  //   应对超长 prompt 触发公司网关上传限额导致的发送失败
  if (opts.compress) {
    const ctx = StateMachine._lastPartialDebateContext;
    if (ctx?.responses) {
      try {
        let rebuilt = DebateEngine.buildDebatePrompt(pid, ctx.responses, ctx.style, ctx.roundNum, ctx.guidance, ctx.concise, true);
        const captainEnabled = await self.ArenaCaptainMode.isEnabled();
        rebuilt = self.ArenaCaptainMode.decoratePrompt(rebuilt, p, StateMachine.participants || [], captainEnabled);
        notifyStatus(`🗜 压缩后补发给 ${p.name}：${prompt.length} → ${rebuilt.length} 字`);
        prompt = rebuilt;
      } catch (e) {
        console.warn("[bg] 压缩重建失败，退回原 prompt:", e?.message);
      }
    }
  }

  const pendingMsgId = `m${Date.now()}_partial_retry_${pid}`;
  // 推 popup 占位气泡
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: p.service,
      text: "", isDone: false,
    }).catch(() => {});
  } catch (_) {}

  // inject 带 1 次重试 + 15s 超时
  let injectResult = null;
  let lastError = "未知错误";
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      const ready = await waitForContentScript(p.tabId);
      if (!ready) {
        lastError = "页面未就绪 (content script 失联)";
      } else {
        StateMachine.setLastSent(p.id, prompt);
        const result = await sendMessageWithTimeout(p.tabId, { action: "inject", text: prompt }, 15000);
        if (result?.status === "sent" || result?.status === "inputted") {
          injectResult = result;
          break;
        }
        lastError = result?.error || `inject 异常状态: ${result?.status}`;
      }
    } catch (e) {
      lastError = e?.message || lastError;
    }
    if (attempt === 0) await new Promise(r => setTimeout(r, 1500));
  }

  if (injectResult) {
    // 启动 polling 让该 AI 的辩论回答能同步进 popup
    try {
      ChatBus.notifyRoundStart(`🔄 补发辩论给 ${p.name}`, [p.service], pendingMsgId);
    } catch (e) { console.warn("[bg] notifyRoundStart fail (partial retry):", e?.message); }
    // 一次性使用，清除暂存
    if (StateMachine._lastPartialDebatePrompts) delete StateMachine._lastPartialDebatePrompts[pid];
    // v5.0.19: 全部补发完 → 一并清重建上下文，防陈旧 ctx 被未来路径误用
    if (Object.keys(StateMachine._lastPartialDebatePrompts || {}).length === 0) {
      StateMachine._lastPartialDebateContext = null;
    }
    notifyStatus(`已补发辩论给 ${p.name}`);
    return { ok: true, result: injectResult };
  }

  // 失败 — 用同 msgId 更新气泡告知用户
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: p.service,
      text: `⚠ 补发失败：${lastError}\n\n请检查 ${p.name} 页面是否可用`,
      isDone: true, injectError: true,   // v5.0.26: 与 _endDebateLoadingBubble 对齐，防 progressive 误判为"首答"
    }).catch(() => {});
  } catch (_) {}
  notifyStatus(`⚠ 补发给 ${p.name} 失败: ${lastError}`);
  return { ok: false, error: lastError };
}

// ── 辩论总结 ──

async function handleSummary(judgeId, customInstruction = "", format = "html") {
  if (StateMachine.participants.length < 2) { notifyStatus("至少需要 2 个参与者"); return { ok: false, error: "参与者不足" }; }
  const judge = StateMachine.getParticipant(judgeId);
  if (!judge?.tabId) { notifyStatus("裁判未打开"); return { ok: false, error: "裁判未打开" }; }

  const responses = {};
  for (const p of StateMachine.participants) {
    if (p.response) {
      responses[p.id] = { name: p.name, text: p.response };
    }
  }
  if (Object.keys(responses).length < 2) { notifyStatus("回答不足"); return { ok: false, error: "回答不足" }; }

  // v4.4.1: format 决定走 JSON→HTML 还是老的 markdown 散文
  const useJsonHtml = format !== "text";
  const prompt = useJsonHtml
    ? DebateEngine.buildSummaryPrompt(
        StateMachine.debateSession.originalQuestion,
        StateMachine.debateSession.rounds,
        responses,
        customInstruction
      )
    : DebateEngine.buildSummaryPromptText(
        StateMachine.debateSession.originalQuestion,
        StateMachine.debateSession.rounds,
        responses,
        customInstruction
      );

  StateMachine.setFlowState(FlowState.SUMMARY);
  // v4.4.0: 仅 html 模式设置 pendingSummary（text 模式走老路径，气泡显示散文即可）
  // v4.5.5 F6: 用 setPendingSummary 触发 save，SW 重启时可恢复
  // v4.6.14 F21: 占位气泡 msgId 提前生成 — v5.0.17 把它一并存进 pendingSummary，
  //   SW 重启恢复 polling 时复用原 msgId，完成气泡落回原占位而非新增孤立气泡
  const displayText = `📋 裁判总结请求 → ${judge.name}${customInstruction ? '：' + customInstruction : ''}`;
  const pendingMsgId = `m${Date.now()}_s${judge.id}`;

  StateMachine.setPendingSummary(useJsonHtml ? {
    judgeId: judge.id,
    judgeName: judge.name,
    judgeService: judge.service,
    customInstruction,
    topic: StateMachine.debateSession.originalQuestion || "",
    rounds: StateMachine.debateSession.rounds.length || 0,
    participants: StateMachine.participants.map(p => p.name),
    ts: Date.now(),
    msgId: pendingMsgId,  // v5.0.17 P0-4
  } : null);
  notifyStatus(`正在由 ${judge.name} 总结...`);

  // v4.6.14 F21: 立刻推 pending 占位气泡 — 同 F20 模式，inject 1-3s 等待前先反馈
  // 避免用户按下"裁判总结"后觉得卡住。inject 完成后用同 msgId 替换为正式 displayText。
  try {
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "user",
      msgId: pendingMsgId,
      text: `${displayText} · 正在发起...`,
    }).catch(() => {});
    chrome.runtime.sendMessage({
      type: "chatStreamUpdate", role: "ai",
      msgId: pendingMsgId, participantId: judge.service,
      text: "", isDone: false,
    }).catch(() => {});
  } catch (_) {}

  try {
    StateMachine.setLastSent(judge.id, prompt);
    // v5.0.17: 对齐辩论路径 — 15s 超时防 tab 冻结挂死总结流程
    const result = await sendMessageWithTimeout(judge.tabId, { action: "inject", text: prompt }, 15000);
    if (result?.status === "error") {
      const error = result.error || "注入失败";
      notifyStatus(`总结失败: ${error}`);
      StateMachine.setPendingSummary(null);
      return { ok: false, error };
    }
    // v4.8.13 F28: 不再强制把裁判 tab 切到前台
    // 历史代码因为 background tab 提取失败才切，F27 CDP 已解决 throttle 问题
    // 用户反馈："使用总结功能时会突然强迫我跳转到最前方" — 删掉这两行
    notifyStatus(`总结已发送给 ${judge.name}`);
    try {
      // v4.6.14 F21: 复用 pendingMsgId 让 popup 更新原占位气泡（不新增）
      ChatBus.notifyRoundStart(displayText, [judge.service], pendingMsgId);
    } catch (e) { console.warn("[chat-bus] notifyRoundStart failed:", e.message); }
    return { ok: true, result };
  } catch (e) {
    // v5.0.17 P1-4: 改用 setPendingSummary(null)（saveSync 落盘）— 旧版直接赋值只清内存，
    //   storage 残留过期 pendingSummary，SW 重启恢复后下一个完成的回答会被误判成裁判总结，
    //   弹出内容驴唇不对马嘴的"幽灵报告"
    StateMachine.setPendingSummary(null);
    notifyStatus(`总结失败: ${e.message}`);
    return { ok: false, error: e.message };
  }
}

// v4.4.0: 解析 AI 输出的 JSON → 渲染 arXiv 风格 HTML → 写文件 + 推送 popup
async function finalizeDebateSummary(rawText, pending) {
  try {
    const data = self.DebateSummaryTemplate?.parse(rawText);
    if (!data) {
      // v4.5.4 F9: parse 失败 → 降级为普通气泡显示原文，不让用户面对"按了没反应"
      console.warn("[summary] JSON 解析失败，原文回退展示");
      try {
        chrome.runtime.sendMessage({
          type: "chatStreamUpdate", role: "ai",
          msgId: `summary_fallback_${pending?.ts || Date.now()}`,
          participantId: pending?.judgeService || "summary",
          text: `⚠ 裁判总结 JSON 解析失败，以下为原文：\n\n${rawText || "(空)"}`,
          isDone: true,
        }).catch(() => {});
      } catch (_) {}
      notifyStatus("总结 JSON 解析失败，已显示原文");
      try {
        if (StateMachine.flowState === FlowState.SUMMARY) {
          StateMachine.setFlowState(FlowState.IDLE);
        }
      } catch (_) {}
      return { ok: false, error: "parse_failed" };
    }
    const date = new Date(pending.ts).toISOString().slice(0, 10);
    const duration_min = Math.max(1, Math.round((Date.now() - pending.ts) / 60000));
    const html = self.DebateSummaryTemplate.render(data, {
      topic: pending.topic,
      date,
      participants: pending.participants,
      rounds: pending.rounds,
      duration_min,
    });
    if (!html) return { ok: false, error: "render_failed" };

    // 写文件（chrome.downloads → 下载目录）
    let downloadId = null;
    try {
      const fileName = `debate-summary-${date}-${pending.ts}.html`;
      const blob = new Blob([html], { type: "text/html;charset=utf-8" });
      // SW 中无法 URL.createObjectURL Blob（MV3 限制），用 data URL
      const reader = new FileReader();
      const dataUrl = await new Promise((res, rej) => {
        reader.onload = () => res(reader.result);
        reader.onerror = rej;
        reader.readAsDataURL(blob);
      });
      downloadId = await chrome.downloads.download({
        url: dataUrl,
        filename: `ai-arena/${fileName}`,
        saveAs: false,
      });
    } catch (e) {
      console.warn("[summary] 写文件失败:", e?.message);
    }

    // 推送 popup 渲染 iframe 预览
    chrome.runtime.sendMessage({
      type: "debateSummaryReady",
      html,
      data,
      meta: { ...pending, date, duration_min },
      downloadId,
    }).catch(() => {});

    notifyStatus(`📋 辩论总结 HTML 已生成${downloadId ? `（已保存到下载目录）` : ""}`);
    return { ok: true, downloadId };
  } catch (e) {
    console.warn("[summary] finalize fail:", e);
    return { ok: false, error: e.message };
  }
}

// ── 无标记完成检测（文本稳定 + stop button 消失） ──

async function checkAllCompletion() {
  const statuses = {};
  await Promise.all(StateMachine.participants.map(async (p) => {
    if (!p.tabId) { statuses[p.id] = { name: p.name, status: "offline", textLength: 0, isStreaming: false }; return; }
    try {
      const r = await chrome.tabs.sendMessage(p.tabId, { action: "checkCompletion" });
      statuses[p.id] = { name: p.name, textLength: r.textLength || 0, isStreaming: !!r.isStreaming };
    } catch { statuses[p.id] = { name: p.name, status: "offline", textLength: 0, isStreaming: false }; }
  }));
  return statuses;
}

// ── 读取单个回答 ──

async function readOneResponse(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p?.tabId) return { ok: false, text: "" };
  try {
      const r = await sendMessageWithTimeout(p.tabId, { action: "readResponse" }, 30000);
      if (r?.error) {
        return { ok: false, text: "", error: r.error };
      }
      const text = r?.text || "";
      if (!text.trim()) {
        return { ok: false, text: "", error: "未读到有效回复" };
      }
      if (isInvalidAiResponse(text)) {
        return { ok: false, text: "", error: "读到平台错误或登录提示，不作为有效回复" };
      }
      if (text) {
      // sanity check：拒绝读到用户刚发的 prompt 或上轮残留
      const sent = StateMachine.lastSentByPid?.[participantId] || "";
      const prevResp = StateMachine.lastAcceptedByPid?.[participantId] || p.response || "";
      const head = (s) => (s || "").trim().slice(0, 100);
      if (sent && text === sent) {
        return { ok: false, text: "", error: "疑似读到用户消息（与 prompt 完全相同），请手动提取" };
      }
      if (sent && head(text).length >= 50 && head(text) === head(sent)) {
        return { ok: false, text: "", error: "疑似读到用户消息（前100字与 prompt 相同），请手动提取" };
      }
      if (prevResp && text === prevResp) {
        return { ok: false, text: "", error: "疑似读到上一轮残留回复，请等待新回复或手动提取" };
      }
      StateMachine.setParticipantResponse(p.id, text);
    }
    return { ok: true, text, isStreaming: !!r.isStreaming };   // v5.0.43: 透传 streaming，PPT-SUPER grab 据此判「生成是否真的结束」
  } catch (e) {
    return { ok: false, text: "", error: e.message };
  }
}

// v5.0.38 PPT-SUPER: 抓取 AI 标签页里最新生成的图（DALL-E 等），转 dataURL 回 popup
async function readLastImage(participantId) {
  const p = StateMachine.getParticipant(participantId);
  if (!p?.tabId) return { ok: false, error: "无标签页" };
  try {
    const [res] = await chrome.scripting.executeScript({
      target: { tabId: p.tabId },
      func: async () => {
        const imgs = Array.from(document.querySelectorAll("img"))
          .filter(im => im.naturalWidth >= 256 && im.naturalHeight >= 256
            && !/avatar|favicon|icon|logo|sprite/i.test((im.src || "") + " " + (im.className || "")));
        const last = imgs[imgs.length - 1];
        if (!last) return null;
        try {
          const r = await fetch(last.src);
          const b = await r.blob();
          if (b.size < 2000) return null;
          return await new Promise(resolve => {
            const fr = new FileReader();
            fr.onload = () => resolve(fr.result);
            fr.onerror = () => resolve(null);
            fr.readAsDataURL(b);
          });
        } catch (e) { return null; }
      }
    });
    return res && res.result ? { ok: true, dataUrl: res.result } : { ok: false, error: "未找到生成图" };
  } catch (e) {
    return { ok: false, error: e.message };
  }
}

function isInvalidAiResponse(text) {
  return /Something went wrong while generating the response|please contact us through our help center|感谢你试用 ChatGPT|登录或注册，以获取更智能的回复|需要登录|Sign in to continue/i.test(text || "");
}

// ── Tab 切换 ──

async function handleFocusTab(id) {
  const p = StateMachine.getParticipant(id);
  if (!p?.tabId) return { ok: false };
  try {
    const tab = await chrome.tabs.get(p.tabId);
    await chrome.tabs.update(p.tabId, { active: true });
    await chrome.windows.update(tab.windowId, { focused: true });
    return { ok: true };
  } catch { p.tabId = null; StateMachine.save(); return { ok: false }; }
}

// v5.0.28: "唤起 AI" = 对每个参与者执行与"点 AI logo 跳原页"(jumpToOrigin) 完全相同的动作，
//   遍历一遍即把所有 AI 窗口/标签页弹到前台。去掉旧的 windowMode 守卫 —— 并列模式（每个 AI
//   独立窗口）才是最需要"一键把它们都唤到前面"的场景；Tab 模式也照常可用。
async function focusAllAiTabs() {
  const parts = (StateMachine.participants || []).filter(p => p.tabId);
  if (!parts.length) return { ok: false, error: "没有可唤起的 AI Tab" };
  let focused = 0;
  const errors = [];
  for (const p of parts) {
    try {
      const tab = await chrome.tabs.get(p.tabId);
      await chrome.windows.update(tab.windowId, { focused: true });
      await chrome.tabs.update(p.tabId, { active: true });
      focused++;
      await new Promise(r => setTimeout(r, 150));
    } catch (e) {
      errors.push({ service: p.service, error: e?.message || String(e) });
    }
  }
  // v5.0.29 需求1：所有 AI 窗口唤到前台后，把圆桌主界面拉回最前，方便用户继续操作
  //   （AI 窗口已都提到前台，popup 叠在最顶层可立即操作）
  try { await ChatBus.focusPopup(); } catch (_) {}
  return { ok: focused > 0, focused, total: parts.length, errors };
}

// PPT-SUPER 专用：发送/生成时把目标 AI tab 弹到前台（用户看到 AI 在工作），短暂后把圆桌主界面拉回最前可操作。
//   复用现成的 handleFocusTab（按 id 激活 tab+窗口）+ ChatBus.focusPopup（圆桌回前）；popup 模式拉回、sidePanel 模式本就常驻。
async function focusServiceTabThenPopup(participantId) {
  try { await handleFocusTab(participantId); } catch (_) {}
  setTimeout(() => { try { ChatBus.focusPopup(); } catch (_) {} }, 350);
  return { ok: true };
}

// ── 导出 ──

function exportSession() {
  let md = `# AI圆桌派 辩论记录\n\n`;
  md += `**时间**: ${new Date().toLocaleString("zh-CN")}\n`;
  md += `**参与者**: ${StateMachine.participants.map(p => p.name).join(", ")}\n\n`;
  if (StateMachine.debateSession.originalQuestion) {
    md += `## 原始问题\n\n${StateMachine.debateSession.originalQuestion}\n\n`;
  }
  const initialResponses = StateMachine.participants.filter(p => p.response);
  if (initialResponses.length > 0) {
    md += `## 各 AI 回答\n\n`;
    for (const p of initialResponses) {
      md += `### ${p.name}\n\n${p.response}\n\n`;
    }
  }
  for (const round of StateMachine.debateSession.rounds) {
    const styleName = DEBATE_STYLES[round.style]?.name || round.style;
    md += `## 第${round.roundNum}轮 (${styleName})\n\n`;
    if (round.guidance) md += `> 用户引导：${round.guidance}\n\n`;
    for (const [pId, data] of Object.entries(round.responses)) {
      const name = data.name || (StateMachine.getParticipant(pId)?.name || pId);
      md += `### ${name}\n\n${data.text}\n\n`;
    }
    md += `---\n\n`;
  }
  return { ok: true, markdown: md };
}

// ── 并列模式：排列窗口 ──
async function arrangeWindows(screen = lastKnownScreen) {
  if (windowMode !== "tiled") return { ok: false, error: "非并列模式" };
  const parts = StateMachine.participants.filter(p => p.tabId);
  if (parts.length === 0) return { ok: false, error: "无参与者" };

  const targetLayout = await getAiTargetLayout(screen);
  const targetScreen = targetLayout.screen;
  const screenW = targetScreen.width;
  const screenH = targetScreen.height;
  const screenLeft = targetScreen.left;
  const screenTop = targetScreen.top;

  // 反转顺序：第一个添加的参与者放最右边（带侧边栏）
  const ordered = [...parts].reverse();
  const n = ordered.length;
  // v5.0.29: 精确平均分，互不重叠。旧版 +7px 隐形边框补偿（left-7 / width+14）会让相邻窗口
  //   重叠 14px、最右窗口右边缘超出屏幕 7px → 靠右 AI 的最大化/关闭按钮被上层窗口盖住或推出
  //   屏幕。改为每个窗口严格占 [屏宽/N]，宁留极细的隐形边框缝（透明、几乎不可见），也要保证
  //   每个窗口标题栏按钮完整可见可操作。
  const perW = Math.floor(screenW / n);
  console.log(`[Arena/arrange] screen=${screenW}x${screenH} @(${screenLeft},${screenTop}) n=${n} perW=${perW}`);

  for (let i = 0; i < n; i++) {
    const tab = await chrome.tabs.get(ordered[i].tabId).catch(() => null);
    if (!tab) continue;
    const winId = tab.windowId;
    const isLast = i === n - 1;
    const baseLeft = screenLeft + i * perW;
    const baseW = isLast ? screenW - i * perW : perW;   // 最后一个吃掉取整余数，右边缘正好贴屏幕右沿
    console.log(`[Arena/arrange] win#${i}${isLast ? "(last)" : ""} left=${baseLeft} width=${baseW} right=${baseLeft + baseW}`);
    await chrome.windows.update(winId, {
      left: baseLeft,
      top: screenTop,
      width: baseW,
      height: screenH,
      state: "normal",
      focused: true
    });
  }

  // 最右侧窗口（第一个添加的参与者）打开侧边栏
  const lastTab = await chrome.tabs.get(ordered[n - 1].tabId).catch(() => null);
  if (lastTab && !targetLayout.isDifferentDisplay) {
    await chrome.sidePanel.open({ windowId: lastTab.windowId }).catch(() => {});
  }

  return { ok: true, screen: targetScreen, displayId: targetLayout.displayId, isDifferentDisplay: targetLayout.isDifferentDisplay };
}

// v4.8.33: 删除 AI_HOSTS — 副屏判定不再依赖 hasUserWindow，AI 域名过滤无用

async function getAiTargetLayout(sidepanelScreen = lastKnownScreen) {
  const fallback = normalizeScreen(sidepanelScreen);
  try {
    if (!chrome.system?.display?.getInfo) {
      console.log("[Arena/layout] chrome.system.display unavailable, using sidepanel screen");
      return { screen: fallback, displayId: null, isDifferentDisplay: false };
    }
    const displays = await chrome.system.display.getInfo();
    const normalized = displays
      .map(d => ({ id: d.id, screen: normalizeScreen(d.workArea || d.bounds), isPrimary: !!d.isPrimary }))
      .filter(d => d.screen.width > 0 && d.screen.height > 0);

    console.log("[Arena/layout] displays:", normalized.map(d => ({ id: d.id.slice(-6), ...d.screen, primary: d.isPrimary })));
    console.log("[Arena/layout] sidepanel reports:", fallback);

    // 找到 sidepanel 所在屏（current display）
    const current = findDisplayForScreen(fallback, normalized) || normalized.find(d => d.isPrimary) || normalized[0];
    // v5.0.40: 定位用 fallback（sidepanel 窗口中心点匹配物理屏），但「铺窗口的尺寸 + 原点」改用
    //   匹配到的那块屏的 workArea（current.screen）。旧版 currentScreen=fallback 把传入的
    //   窗口宽 / desktop-union 宽 / 缩放后宽度当成屏宽来均分，导致最右 AI 窗口连同关闭按钮 ✕
    //   溢出屏外。workArea 是 chrome.system.display 报告的单块屏真实可用区（已扣任务栏），逻辑
    //   像素与 chrome.windows.update 一致，最稳；display 不可用时仍回退 fallback。
    const currentScreen = current?.screen || fallback;
    console.log("[Arena/layout] current display picked:", current?.id?.slice(-6), "currentScreen:", currentScreen, "(fallback:", fallback, ")");

    // 自动：副屏优先 + 不存在真副屏则回退同屏
    if (normalized.length < 2) {
      console.log("[Arena/layout] single display detected, using current");
      return { screen: currentScreen, displayId: current?.id || null, isDifferentDisplay: false };
    }

    // v4.8.33: 副屏判定放宽 — 默认 AI 窗口去和群聊窗口"不同屏"
    //   旧规则要求副屏上有【非 AI 平台】的用户 chrome window（防虚拟副屏自污染），
    //   代价：副屏空白时识别失败 → 用户实际是双屏却 AI 仍弹同屏。
    //   新规则：只看 chrome.system.display 报告的物理不重叠副屏，去掉 hasUserWindow 门。
    const others = normalized.filter(d => {
      if (d.id === current.id) return false;
      const notOverlap = !overlapsDisplay(d.screen, currentScreen);
      console.log("[Arena/layout] other display", d.id.slice(-6), "notOverlap:", notOverlap);
      return notOverlap;
    });

    if (others.length === 0) {
      console.log("[Arena/layout] no real secondary, using current screen");
      return { screen: currentScreen, displayId: current?.id || null, isDifferentDisplay: false };
    }
    const currentCenter = centerOf(currentScreen);
    const target = others.sort((a, b) => distance(centerOf(a.screen), currentCenter) - distance(centerOf(b.screen), currentCenter))[0];
    console.log("[Arena/layout] using secondary display", target.id.slice(-6), target.screen);
    return { screen: target.screen, displayId: target.id, isDifferentDisplay: true };
  } catch (e) {
    console.warn("[Arena/layout] error, falling back to sidepanel screen:", e?.message || e);
    return { screen: fallback, displayId: null, isDifferentDisplay: false };
  }
}

// 判断两个 screen rect 是否物理重叠（用于过滤虚假副屏）
// 真副屏 = 完全不重叠（物理上水平/垂直分离）。任何重叠（哪怕 1 像素）都视为虚假副屏。
function overlapsDisplay(a, b) {
  const ax2 = a.left + a.width, ay2 = a.top + a.height;
  const bx2 = b.left + b.width, by2 = b.top + b.height;
  const overlapW = Math.max(0, Math.min(ax2, bx2) - Math.max(a.left, b.left));
  const overlapH = Math.max(0, Math.min(ay2, by2) - Math.max(a.top, b.top));
  return overlapW > 0 && overlapH > 0;
}

function normalizeScreen(screen = {}) {
  const width = Math.max(300, Math.round(screen.width || screen.availWidth || 1920));
  const height = Math.max(300, Math.round(screen.height || screen.availHeight || 1080));
  return {
    left: Math.round(screen.left ?? screen.availLeft ?? 0),
    top: Math.round(screen.top ?? screen.availTop ?? 0),
    width,
    height
  };
}

function windowBoundsForCreate(screen) {
  const s = normalizeScreen(screen);
  return {
    left: s.left,
    top: s.top,
    width: Math.max(500, Math.min(s.width, 1200)),
    height: Math.max(500, s.height)
  };
}

function findDisplayForScreen(screen, displays) {
  const point = centerOf(screen);
  const contains = displays.find(d => (
    point.x >= d.screen.left &&
    point.x < d.screen.left + d.screen.width &&
    point.y >= d.screen.top &&
    point.y < d.screen.top + d.screen.height
  ));
  if (contains) return contains;

  return displays
    .map(d => ({ display: d, overlap: overlapArea(screen, d.screen) }))
    .sort((a, b) => b.overlap - a.overlap)[0]?.display || null;
}

function centerOf(screen) {
  return { x: screen.left + screen.width / 2, y: screen.top + screen.height / 2 };
}

function distance(a, b) {
  return Math.hypot(a.x - b.x, a.y - b.y);
}

function overlapArea(a, b) {
  const left = Math.max(a.left, b.left);
  const right = Math.min(a.left + a.width, b.left + b.width);
  const top = Math.max(a.top, b.top);
  const bottom = Math.min(a.top + a.height, b.top + b.height);
  return Math.max(0, right - left) * Math.max(0, bottom - top);
}

// ── 工具函数 ──

async function waitForContentScript(tabId, maxRetries = 12) {
  // v4.8.46: ping 失败一次后主动 ensureContentScriptInjected（reload 扩展后 manifest
  //   不会自动重注 content-{service}.js → "Receiving end does not exist"）
  // v4.8.47: ensureContentScriptInjected 现在区分 justInjected vs listenerNotReady（race）
  let triedReinject = false;
  for (let i = 0; i < maxRetries; i++) {
    try {
      await chrome.tabs.sendMessage(tabId, { action: "ping" });
      return true;
    } catch (e) {
      if (e.message && (e.message.includes("No tab") || e.message.includes("removed"))) return false;
      // 第一次失败立刻尝试主动注入；之后只等待
      if (!triedReinject) {
        triedReinject = true;
        try {
          const tab = await chrome.tabs.get(tabId);
          if (tab?.url) {
            const r = await ensureContentScriptInjected(tabId, tab.url);
            if (r.justInjected || r.listenerNotReady) {
              await new Promise(r2 => setTimeout(r2, 500));  // 等 listener 注册或就绪
              continue;  // 立即重试 ping
            }
          }
        } catch (_) {}
      }
      await new Promise(r => setTimeout(r, 1000));
    }
  }
  return false;
}

async function sendMessageWithTimeout(tabId, msg, timeoutMs = 90000) {
  return Promise.race([
    chrome.tabs.sendMessage(tabId, msg),
    new Promise((_, reject) => setTimeout(() => reject(new Error("消息超时")), timeoutMs))
  ]);
}

function notifyStatus(message) { chrome.runtime.sendMessage({ type: "status", message }).catch(() => {}); }

// v4.9.0: 敏感信息守门员 wrapper — 所有发送 handler 走这里扫一次
//   { text, handler, msg } → 命中则返回 { ok:false, reason:"sensitive_blocked", hits, masked, original }
//   不命中或 msg.skipGatekeeper === true → 直接调 handler() return 结果
//   handler 是不带参数的函数（闭包捕获 msg.* 等）
async function guardedSend({ text, handler, msg }) {
  try {
    if (msg?.skipGatekeeper) return await handler();
    const Store = self.GatekeeperStore;
    const Engine = self.GatekeeperEngine;
    if (!Store || !Engine) return await handler();   // 守门员未加载，降级放行
    if (!(await Store.isEnabled())) return await handler();
    if (typeof text !== "string" || !text.trim()) return await handler();

    const hits = await Engine.scan(text);
    if (!hits.length) return await handler();
    // v4.9.0.2 fix: warn-only 命中不阻断（spec §4.2 战略词软提醒），只 block 级才弹窗
    if (typeof Engine.hasBlocking === "function" && !Engine.hasBlocking(hits)) return await handler();

    // 命中 → 不走 handler，return reason 给 popup
    const masked = Engine.maskText(text, hits);
    try { await Store.bumpStat("hits"); } catch (_) {}
    return { ok: false, reason: "sensitive_blocked", hits, masked, original: text };
  } catch (e) {
    console.warn("[Gatekeeper] guardedSend error, falling back to handler:", e);
    return await handler();
  }
}
