// AI Arena — 群聊业务总线（背景脚本子模块）
// 由 background.js 通过 importScripts 加载，挂在 self.ChatBus

const ChatBus = (() => {
  // ── 状态 ──
  let popupWindowId = null;        // 当前 popup window id，null 表示未开
  let popupBounds = null;          // full 模式下的位置/尺寸（用户拖动后记忆）
  let popupMiniBounds = null;      // v4.8.15 F30: mini 模式下的位置/尺寸（独立记忆）
  let popupMode = "full";          // v4.8.15 F30: "full" | "mini"
  const chatLog = [];              // 最近 100 条消息
  const MAX_LOG = 100;
  const MAX_LOG_TEXT_CHARS = 750000;
  const MAX_LOG_ENTRY_TEXT_CHARS = 120000;
  const STORAGE_KEYS = {
    log: "chatLog",
    bounds: "chatPopupBounds",
    miniBounds: "chatPopupMiniBounds",  // F30
    mode: "popupMode",                  // F30
  };

  // ── 初始化：读 storage ──
  async function init() {
    const data = await chrome.storage.local.get([
      STORAGE_KEYS.log, STORAGE_KEYS.bounds,
      STORAGE_KEYS.miniBounds, STORAGE_KEYS.mode,
    ]);
    if (Array.isArray(data[STORAGE_KEYS.log])) {
      chatLog.push(...data[STORAGE_KEYS.log].slice(-MAX_LOG).map(normalizeLogEntry));
      trimLogBudget();
    }
    // v4.8.37: sanity check — 清理 toggleMiniMode race 留下的污染数据
    //   popupBounds.height 应为 full 模式合理值；< 400 视为被 mini 大小污染 → 丢弃
    //   （v4.8.57: 阈值从 200 升到 400 — 因 mini default 升到 200，旧 >= 200 已无法区分污染）
    if (data[STORAGE_KEYS.bounds] && data[STORAGE_KEYS.bounds].height >= 400) {
      popupBounds = data[STORAGE_KEYS.bounds];
    } else if (data[STORAGE_KEYS.bounds]) {
      console.log("[chat-bus] v4.8.57 sanity: discard polluted popupBounds height=", data[STORAGE_KEYS.bounds].height);
      await chrome.storage.local.remove(STORAGE_KEYS.bounds).catch(() => {});
    }
    // v5.0.5: miniBounds 加 sanity check 对称 popupBounds — height >= 600 视为被 full 模式
    //   污染（mini 默认 200，用户拉到 600+ 已不合理）→ 丢弃，下次走 defaultMiniBounds。
    //   修"折叠到顶但窗口大小不变"：miniBounds 存了大值导致折叠时窗口不缩小。
    if (data[STORAGE_KEYS.miniBounds] && data[STORAGE_KEYS.miniBounds].height < 600) {
      popupMiniBounds = data[STORAGE_KEYS.miniBounds];
    } else if (data[STORAGE_KEYS.miniBounds]) {
      console.log("[chat-bus] v5.0.5 sanity: discard polluted popupMiniBounds height=", data[STORAGE_KEYS.miniBounds].height);
      await chrome.storage.local.remove(STORAGE_KEYS.miniBounds).catch(() => {});
    }
    if (data[STORAGE_KEYS.mode] === "mini" || data[STORAGE_KEYS.mode] === "full") {
      popupMode = data[STORAGE_KEYS.mode];
    }
  }

  // ── popup 生命周期 ──
  async function openChatPopup() {
    if (popupWindowId != null) {
      try {
        await chrome.windows.update(popupWindowId, { focused: true });
        return { ok: true, reused: true, windowId: popupWindowId };
      } catch {
        popupWindowId = null;  // window 已被关
      }
    }
    const bounds = popupBounds || await defaultBounds();
    const w = await chrome.windows.create({
      url: chrome.runtime.getURL("popup.html"),
      type: "popup",
      ...bounds,
    });
    popupWindowId = w.id;
    // v4.8.63: popup 重新打开后，若 Tab 模式则重新 attach 所有 AI tabs（恢复反节流）
    //   v4.8.63 改动：popup 关闭时 detach 所有 debugger（黄条消失），重开时按需 re-attach
    //   场景：用户关掉群聊歇一会 → 重新打开 → 自动恢复 Tab 模式的后台 AI 反节流
    if (windowMode === "tab" && self.CDPExtractor && StateMachine?.participants?.length) {
      // fire-and-forget — popup 已 ready 返回，不阻塞用户感知
      (async () => {
        let attached = 0;
        for (const p of StateMachine.participants) {
          if (p.tabId) {
            const r = await self.CDPExtractor.attachAndWake(p.tabId).catch(() => null);
            if (r?.ok) attached++;
          }
        }
        console.log(`[F63] popup reopened in Tab mode → re-attached ${attached}/${StateMachine.participants.length} AI tabs`);
      })();
    }
    return { ok: true, reused: false, windowId: w.id };
  }

  async function defaultBounds() {
    // v4.8.33: 首次打开（无记忆 bounds）= 屏幕 80%×80% 居中，去掉 1100×720 封顶
    //          只影响 defaultBounds，用户拖动后仍走 popupBounds 记忆
    try {
      const displays = await chrome.system.display.getInfo();
      const primary = displays.find(d => d.isPrimary) || displays[0];
      const w = Math.round(primary.workArea.width * 0.8);
      const h = Math.round(primary.workArea.height * 0.8);
      return {
        left: primary.workArea.left + Math.round((primary.workArea.width - w) / 2),
        top: primary.workArea.top + Math.round((primary.workArea.height - h) / 2),
        width: w,
        height: h,
      };
    } catch {
      return { left: 100, top: 100, width: 1280, height: 800 };
    }
  }

  // v4.8.15 F30: mini 模式默认 bounds — 顶部居中，900x60
  async function defaultMiniBounds() {
    try {
      // 优先用 popup 当前所在屏，没有就主屏
      let display = null;
      if (popupWindowId != null) {
        try {
          const w = await chrome.windows.get(popupWindowId);
          const displays = await chrome.system.display.getInfo();
          display = displays.find(d =>
            w.left >= d.workArea.left && w.left < d.workArea.left + d.workArea.width
          ) || null;
        } catch {}
      }
      if (!display) {
        const displays = await chrome.system.display.getInfo();
        display = displays.find(d => d.isPrimary) || displays[0];
      }
      const width = Math.min(900, Math.round(display.workArea.width * 0.7));
      // v4.8.27: 60→78 / v4.8.30: 78→86（padding 16→24 + 控件底部不贴边 + AI logos）
      // v4.8.57: 86→200 — mini 改成多行（顶栏 + roster pill 2 行 + input-bar），需要更高
      const height = 200;
      return {
        left: display.workArea.left + Math.round((display.workArea.width - width) / 2),
        top: display.workArea.top,
        width,
        height,
      };
    } catch {
      return { left: 200, top: 0, width: 900, height: 60 };
    }
  }

  // v4.8.15 F30: mini 模式 toggle — 由 popup-mini-mode.js 通过 miniModeToggle 消息触发
  // v4.8.37: 加 _modeSwitching flag 防 race —
  //   chrome.windows.update 触发的 onBoundsChanged 会异步调 rememberBounds，
  //   它读 popupMode 决定存哪个字段。如果切换中 popupMode 还是旧值，新 bounds
  //   会错存到旧 mode 字段（如 mini 大小 86 被记成 full bounds → 下次展开窗口变 86 高）。
  //   修复：切换期间 rememberBounds 跳过；切换结束 500ms 后才允许记录。
  let _modeSwitching = false;
  async function toggleMiniMode(mode) {
    const next = mode === "mini" ? "mini" : "full";
    if (popupWindowId == null) return { ok: false, error: "popup not open" };
    _modeSwitching = true;
    try {
      // 切换前先把当前模式的 bounds 记下来
      try {
        const w = await chrome.windows.get(popupWindowId);
        const curBounds = { left: w.left, top: w.top, width: w.width, height: w.height };
        if (popupMode === "full") {
          popupBounds = curBounds;
          await chrome.storage.local.set({ [STORAGE_KEYS.bounds]: popupBounds });
        } else {
          popupMiniBounds = curBounds;
          await chrome.storage.local.set({ [STORAGE_KEYS.miniBounds]: popupMiniBounds });
        }
      } catch {}
      // 切到目标 bounds
      let target;
      if (next === "mini") {
        // v4.8.27: 旧 mini 默认 60；v4.8.57: 多行设计后默认 200。
        //   stale 阈值 > 400 — 用户曾把 mini 拉到 400+ 视为脏数据（不该比预期 default 高那么多），回退默认。
        //   也兼容 v4.8.56 之前老用户存的 86/100 等小值（< 400 都视为有效，但实际 chrome 会自动撑到能装下内容）
        const stale = popupMiniBounds && popupMiniBounds.height > 400;
        target = (!stale && popupMiniBounds) || await defaultMiniBounds();
      } else {
        target = popupBounds || await defaultBounds();
      }
      try {
        // v5.0.5: 分两步 update — chrome 把 state+size 合一时，maximized→normal 异步
        //   过渡期内 size 可能被吞，导致窗口"切到 mini 模式但物理尺寸不变"。
        //   先 state 改 normal 等 80ms，再 update bounds，保证 size 真生效。
        await chrome.windows.update(popupWindowId, { state: "normal" });
        await new Promise(r => setTimeout(r, 80));
        await chrome.windows.update(popupWindowId, {
          focused: true,
          left: target.left, top: target.top,
          width: target.width, height: target.height,
        });
      } catch (e) {
        return { ok: false, error: e?.message || String(e) };
      }
      popupMode = next;
      await chrome.storage.local.set({ [STORAGE_KEYS.mode]: popupMode });
      // v4.8.19 F32: 已无 CDP attach，无需 detachAll
      return { ok: true, mode: popupMode, bounds: target };
    } finally {
      // v4.8.37: 延迟 500ms 释放，确保 chrome update 触发的 onBoundsChanged 已派发完毕
      setTimeout(() => { _modeSwitching = false; }, 500);
    }
  }

  function getPopupMode() { return popupMode; }

  // v4.8.31: mini 模式下 always-on-top — 监听其他窗口获焦时把 popup 拉回前台
  //   Chrome MV3 无原生 always-on-top API，只能用 windows.update({focused:true}) 模拟
  //   tradeoff: 会"瞬间抢焦点 → 浮到前台 → 立刻被用户实际操作窗口夺回"
  //   排除场景：用户主动 minimize popup → 尊重，不拉前
  let _refocusTimer = null;
  try {
    chrome.windows.onFocusChanged.addListener(async (newWinId) => {
      if (popupMode !== "mini") return;                              // 仅 mini 生效
      if (popupWindowId == null) return;
      if (newWinId === popupWindowId) return;                        // 自己获焦 → ignore
      if (newWinId === chrome.windows.WINDOW_ID_NONE) return;        // 失焦但无新窗口（桌面）

      // 检查 popup 状态：minimized → 用户主动收起，尊重；否则拉前
      try {
        const w = await chrome.windows.get(popupWindowId);
        if (w.state === "minimized") return;
      } catch { return; }

      // 防抖 250ms — 避免快速切换窗口时频繁抢焦点
      if (_refocusTimer) return;
      _refocusTimer = setTimeout(async () => {
        _refocusTimer = null;
        try {
          // 再次确认 popup 仍 normal（用户可能刚 minimize）
          const w2 = await chrome.windows.get(popupWindowId);
          if (w2.state === "minimized") return;
          await chrome.windows.update(popupWindowId, { focused: true });
        } catch {}
      }, 250);
    });
  } catch (_) {}

  // v4.8.28: mini 模式下 task-menu 打开时临时把窗口高度撑到 ~320 让菜单可见，关 menu 时还原
  // 注意：临时撑大期间不写 storage（保持 popupMiniBounds 是用户最终选定的 height）
  let _miniMenuPrevHeight = null;
  // v4.8.31: 删除 _miniSkippedServices 整套（v4.8.30 引入的 broadcast 过滤）
  //   原因：用户反馈"灰掉后消息仍发送"—— popup 端 broadcast 通常显式列 targets，过滤被绕过
  //   新方案：mini 头像点击 = removeParticipant（彻底退出 group），跟右栏 hero-slot ⋯→移除 共享逻辑
  async function miniMenuExpand(expand) {
    if (popupMode !== "mini" || popupWindowId == null) return { ok: false, error: "not in mini" };
    try {
      const w = await chrome.windows.get(popupWindowId);
      if (expand) {
        if (_miniMenuPrevHeight != null) return { ok: true, alreadyExpanded: true };
        _miniMenuPrevHeight = w.height;
        await chrome.windows.update(popupWindowId, {
          state: "normal",
          height: 340,                  // 撑高到容纳 task-menu（约 6-7 个 item × 32 + 子菜单空间）
        });
      } else {
        const restore = _miniMenuPrevHeight ?? 78;
        _miniMenuPrevHeight = null;
        await chrome.windows.update(popupWindowId, { state: "normal", height: restore });
      }
      return { ok: true };
    } catch (e) {
      _miniMenuPrevHeight = null;
      return { ok: false, error: e?.message || String(e) };
    }
  }

  function onWindowRemoved(windowId) {
    if (windowId === popupWindowId) popupWindowId = null;
  }

  // v4.8.15 F30: rememberBounds 按当前 mode 存到对应字段（不污染另一套）
  async function rememberBounds(windowId) {
    if (windowId !== popupWindowId) return;
    // v4.8.37: mode 切换期间跳过 — 防 chrome.windows.update 触发的 onBoundsChanged
    //   在 popupMode 还是旧值时记录新 bounds，导致 mini 大小被存到 full bounds（或反之）
    if (_modeSwitching) return;
    // v4.8.28: mini menu 撑高期间不写 mini bounds（撑高的 340 不是用户想要的）
    if (popupMode === "mini" && _miniMenuPrevHeight != null) return;
    try {
      const w = await chrome.windows.get(popupWindowId);
      const b = { left: w.left, top: w.top, width: w.width, height: w.height };
      if (popupMode === "mini") {
        popupMiniBounds = b;
        await chrome.storage.local.set({ [STORAGE_KEYS.miniBounds]: popupMiniBounds });
      } else {
        popupBounds = b;
        await chrome.storage.local.set({ [STORAGE_KEYS.bounds]: popupBounds });
      }
    } catch {}
  }

  // ── polling 调度器 ──
  // pollers: Map<participantId, { intervalId, lastText, sameCount, msgId }>
  const pollers = new Map();
  const POLL_INTERVAL_MS = 1500;
  const STREAM_DONE_THRESHOLD = 3;  // 连续 N 次相同 + !isStreaming 视为完成（streaming 信号可信路径）
  // v5.2.18: streaming 信号误报兜底阈值 — 文本连续 N 次（8×1.5s=12s）不变则无视 isStreaming 强制完成
  //   根因：完成判定 `!isStreaming` 过度依赖 streaming selector。第二轮起 DOM 变脏
  //   （第一轮残留 stop 按钮 / loading 占位 / 千问 qk-markdown complete 标记滞后 / 多轮 DOM 复杂），
  //   streaming selector 误命中 → isStreaming 卡 true → 完成判定永远差 `!isStreaming`
  //   → 拖到 MAX_POLL_TICKS(5min) 超时才放行。用户现象："页面早回答完了，扩展过很久才提取"。
  //   12s 文本不变 = AI 几乎不可能还在生成（停顿规划下一段也不会停 12s），强制完成安全。
  // v5.2.21: 12s→9s（B 方案）— 后台 tab 节流时 streaming 误报兜底更快。
  //   配合 A 方案（移除千问/元宝反向标记依赖）：A 减少误报源头，B 缩短兜底上限。
  const STREAM_DONE_THRESHOLD_FORCE = 6;
  // v5.0.46: isStreaming=true（Claude/ChatGPT 高级模型「先答一两句 → 长思考 → 真答案」）时，
  //   不能 9s 文本不变就强制完成（会漏掉思考后的真答案）。给到 ~105s 再兜底；
  //   streaming 已结束（isStreaming=false）则 doneFast 早已在 4.5s 完成，不受此影响。
  const STREAM_DONE_THRESHOLD_FORCE_THINKING = 70;
  // v4.5.5 F5: 全局 polling tick 上限，~5 分钟兜底防 imagesPending 抖动让 stableKey 永不稳定
  // 实测场景：mock readResponse 返回 text 不变但 imagesPending 0/1 抖动 → polling 跑 12s
  // 仍未完成，理论可无限跑。到达上限按当前文本强制 isDone:true 完成。
  const MAX_POLL_TICKS = 200;

  // v4.6.9 F19 / v4.6.10 调优: 兜底 watcher 机制 — polling 判完成后单 slot 监听最新一轮
  // 防 F18 streaming selector 失效 / AI 无 indicator / 完成后异步追加等场景
  // 发现 text 比 finalText 更长 → 用同 msgId 推 popup updateAIBubble 直接追加更新气泡
  // v4.6.10: 去掉 "15s 文本稳定 + non-streaming 提前停" 条件 — 用户反馈不需要省 CPU，
  // 让 watcher 跑满 60s 确保覆盖审核延迟 / 工具调用结果回传等晚到追加场景
  const watchers = new Map();  // service → { intervalId, msgId, lastText, startTs }
  const WATCH_INTERVAL_MS = 3000;

  // v5.0.60 顺序接力：单 AI 完成的 await 钩子。handleDebateRoundSequential 逐个 AI
  //   inject + startPolling 后，await waitForOneParticipant(service) 等该 AI 答完再发下一个。
  //   pollOnce 完成分支 resolve、失联/超时分支 reject；超时（默认 180s）自动 reject('timeout')
  //   让上游"跳过"该 AI 继续接力（迟到完成时 _oneWaiters 已无该 service，pollOnce get→undefined 安全）。
  const _oneWaiters = new Map();  // service → { resolve, reject, timeoutId }
  function waitForOneParticipant(service, timeoutMs = 180000) {
    // 同 service 重复等待（不应发生，单 AI 一棒）→ 先 reject 旧的防泄漏
    const prev = _oneWaiters.get(service);
    if (prev) {
      _oneWaiters.delete(service);
      try { clearTimeout(prev.timeoutId); } catch (_) {}
      try { prev.reject(new Error("superseded")); } catch (_) {}
    }
    return new Promise((resolve, reject) => {
      const timeoutId = setTimeout(() => {
        if (_oneWaiters.get(service)?.timeoutId === timeoutId) _oneWaiters.delete(service);
        reject(new Error("timeout"));
      }, timeoutMs);
      _oneWaiters.set(service, { resolve, reject, timeoutId });
    });
  }
  function _resolveOneWaiter(service, payload) {
    const w = _oneWaiters.get(service);
    if (!w) return;            // 坑2：超时跳过后迟到完成 → 已无 waiter，安全 no-op
    _oneWaiters.delete(service);
    try { clearTimeout(w.timeoutId); } catch (_) {}
    try { w.resolve(payload); } catch (_) {}
  }
  function _rejectOneWaiter(service, err) {
    const w = _oneWaiters.get(service);
    if (!w) return;
    _oneWaiters.delete(service);
    try { clearTimeout(w.timeoutId); } catch (_) {}
    try { w.reject(err || new Error("disconnect")); } catch (_) {}
  }
  // v4.8.40: 拉到 600s — ChatGPT Pro 深度推理可能 3-5 分钟，watcher 必须覆盖到追加阶段
  //   副作用：watcher 期间 SW 保活（每 3s 一次 setInterval 触发，避免 30s idle 回收 → 反而是正面）
  //   单 slot 限制 + 下一轮启动时清旧 watcher 保证不累积
  const WATCH_MAX_DURATION_MS = 600000;  // 600s 总兜底（v4.8.40 从 120s 拉到 600s）
  const WATCH_MAX_SLOTS = 1;  // 只保留最新一轮防累积

  // v4.6.7 F17: 不再依赖 popupWindowId 做 silent return — MV3 SW 30s 空闲被回收时
  // ChatBus IIFE 销毁、popupWindowId 重建为 null，但 popup 窗口仍开着；
  // 老逻辑 `if (popupWindowId == null) return` 让 SW 重启后所有 chatStreamUpdate
  // 静默丢失 → popup 永远收不到消息。改为始终 broadcast，popup 开着自然收到。
  async function sendToPopup(payload) {
    try {
      await chrome.runtime.sendMessage(payload);
    } catch (e) {
      // v5.0.18 P2-3: popup 关闭时的预期错误继续静默（F17 设计）；其他系统级错误
      //   （消息格式异常 / 通道损坏）低频 warn —— 这是每 1.5s 的高频路径，出问题时
      //   旧版完全无日志不可诊断（"气泡停更"无从排查）。30s 限频防刷屏。
      const msg = e?.message || "";
      if (/Receiving end does not exist|message port closed|Could not establish connection/i.test(msg)) return;
      const now = Date.now();
      if (!sendToPopup._lastWarnTs || now - sendToPopup._lastWarnTs > 30000) {
        sendToPopup._lastWarnTs = now;
        console.warn("[chat-bus] sendToPopup 非预期异常:", msg);
      }
    }
  }

  // v4.6.7 F17: popup 启动时主动告知 SW 自己的 windowId
  // 让 focusPopup / openChatPopup 等依赖 popupWindowId 的功能在 SW 重启后仍可用
  function setPopupWindowId(id) {
    if (typeof id === "number" && id > 0) popupWindowId = id;
  }

  function newMsgId() {
    return `m${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
  }

  // v5.0.8 perf: pushLog 节流写盘 — 单轮辩论 6+ 次 pushLog（1 user + 3 ai final +
  //   watcher 追加 N 次），每次都把 100 条 chatLog 整数组序列化。500ms 窗口合并：
  //   chatLog 内存先 push（popup 实时刷新不受影响），storage 写入合并到尾部一次。
  //   onSuspend 兜底 flush 防 SW 30s idle 回收丢未刷数据。
  let _logFlushTimer = null;
  let _logFlushPending = false;
  function _flushLogToStorage() {
    _logFlushPending = false;
    chrome.storage.local.set({ [STORAGE_KEYS.log]: chatLog }).catch(() => {});
  }
  function textSizeOfLogEntry(entry) {
    if (!entry || typeof entry !== "object") return 0;
    return (entry.text ? String(entry.text).length : 0)
      + (entry.msgId ? String(entry.msgId).length : 0)
      + (entry.participantId ? String(entry.participantId).length : 0);
  }
  function normalizeLogEntry(entry) {
    if (!entry || typeof entry !== "object") return entry;
    const out = { ...entry };
    if (typeof out.text === "string" && out.text.length > MAX_LOG_ENTRY_TEXT_CHARS) {
      // v5.0.16: Math.max 防御常量改小后 slice(0, 负数) 截空全文；标记改中文（用户在 popup 历史可见）
      const head = out.text.slice(0, Math.max(0, MAX_LOG_ENTRY_TEXT_CHARS - 80));
      out.text = `${head}\n\n……（聊天记录过长，已截断显示：原文 ${out.text.length} 字符）`;
      out.truncatedForLog = true;
      out.originalTextLength = entry.text.length;
    }
    return out;
  }
  function trimLogBudget() {
    while (chatLog.length > MAX_LOG) chatLog.shift();
    let total = chatLog.reduce((sum, item) => sum + textSizeOfLogEntry(item), 0);
    while (chatLog.length > 1 && total > MAX_LOG_TEXT_CHARS) {
      const removed = chatLog.shift();
      total -= textSizeOfLogEntry(removed);
    }
  }
  function pushLog(entry) {
    chatLog.push(normalizeLogEntry(entry));
    trimLogBudget();
    _logFlushPending = true;
    if (_logFlushTimer != null) return;
    _logFlushTimer = setTimeout(() => {
      _logFlushTimer = null;
      if (_logFlushPending) _flushLogToStorage();
    }, 500);
  }
  function flushLogSync() {
    if (_logFlushTimer != null) { clearTimeout(_logFlushTimer); _logFlushTimer = null; }
    if (_logFlushPending) _flushLogToStorage();
  }

  // v4.8.36: 用户期望的 service 列表 → 实际可用列表 + 丢失列表
  //   targets 为空 → 默认全员，无丢失；
  //   targets 非空 → filter 后比较，丢失的 service 用于警告气泡（silent failure → fail loud）
  //   场景：移除 AI 后立刻发消息时 popup-roster.selected 尚未刷新；或 roster 选中了已离开的 service
  function _resolveTargetsWithSkipped(targets) {
    const allParticipants = StateMachine.participants || [];
    if (!targets?.length) {
      return { targetList: allParticipants, skippedServices: [] };
    }
    const availableServices = new Set(allParticipants.map(p => p.service));
    const targetList = allParticipants.filter(p => targets.includes(p.service));
    const skippedServices = targets.filter(s => !availableServices.has(s));
    return { targetList, skippedServices };
  }

  // v4.8.36: 9 个 AI 的展示名（用于警告气泡 — 该 service 已不在 StateMachine.participants）
  const SERVICE_DISPLAY_NAME = {
    claude: "Claude", gemini: "Gemini", chatgpt: "ChatGPT",
    deepseek: "DeepSeek", doubao: "豆包", qwen: "通义千问",
    kimi: "Kimi", yuanbao: "元宝", grok: "Grok",
  };

  function _emitSkippedWarning(msgId, skippedServices) {
    for (const svc of skippedServices) {
      const name = SERVICE_DISPLAY_NAME[svc] || svc;
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId,
        participantId: svc,
        text: `⚠ ${name} 已不在会话，本轮跳过 — 请检查参与者列表`,
        isDone: true,
        skipped: true,
      });
    }
  }

  async function broadcast(text, targets, images) {
    if (!text?.trim()) return { ok: false, error: "empty text" };

    // 决定目标参与者 + skipped 列表（v4.8.36 fail loud）
    const { targetList, skippedServices } = _resolveTargetsWithSkipped(targets);

    if (!targetList.length) {
      return { ok: false, error: "无可用参与者", skippedTargets: skippedServices };
    }

    // v4.5.4 F3: 同步 StateMachine — 否则 originalQuestion/p.response/lastSent/flowState 全是旧值，
    // popup "同时提问" 后立刻发起辩论会基于上一题的上下文，导致"回答不足"或上下文混乱
    try {
      if (StateMachine.debateSession) {
        StateMachine.debateSession.originalQuestion = text;
        StateMachine.debateSession.rounds = [];
        StateMachine.debateSession.summaryText = "";
      }
      targetList.forEach(p => {
        p.response = null;
        p.responsePreview = null;
      });
      if (typeof StateMachine.setFlowState === "function" && typeof FlowState !== "undefined") {
        StateMachine.setFlowState(FlowState.BROADCASTING);
      }
      StateMachine.save?.();
      StateMachine._broadcastStateUpdate?.();
    } catch (e) {
      console.warn("[chat-bus] broadcast SM sync fail:", e?.message);
    }

    const msgId = newMsgId();
    const userEntry = { role: "user", msgId, text, ts: Date.now() };
    pushLog(userEntry);
    sendToPopup({ type: "chatStreamUpdate", role: "user", msgId, text });

    // 对每个目标 AI: 注入 + 启动 polling
    const captainEnabled = await self.ArenaCaptainMode.isEnabled();
    const allParticipants = StateMachine.participants || [];
    for (const p of targetList) {
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId,
        participantId: p.service, text: "", isDone: false,
      });
      const prompt = self.ArenaCaptainMode.decoratePrompt(text, p, allParticipants, captainEnabled);
      if (typeof StateMachine.setLastSent === "function") {
        StateMachine.setLastSent(p.id, prompt);
      }
      injectAndPoll(p, msgId, prompt);
    }
    // v4.8.36: 对已离开的 service 创建警告气泡（fail loud）
    _emitSkippedWarning(msgId, skippedServices);
    return {
      ok: true, msgId,
      targets: targetList.map(p => p.service),
      skippedTargets: skippedServices,
    };
  }

  // v5.0.17 P0-3: 从 notifyRoundStart / injectAndPoll 抽出的单 AI polling 启动器。
  //   辩论解耦的核心：每个 AI inject 成功即调它，不等其他 AI（旧版统一启动被
  //   Promise.all 屏障阻塞，千问慢会拖累 gemini/豆包的回答提取）。
  //   pollers.has 清旧防重复启动。
  function startPollingForService(participant, msgId) {
    if (pollers.has(participant.service)) {
      const old = pollers.get(participant.service);
      clearInterval(old.intervalId);
      releaseCDPFor(old, participant.tabId);
    }
    // v4.5.6 F11: 记录上一轮已采纳回答 → pollOnce 拒绝残留
    const state = {
      lastText: "", sameCount: 0, msgId,
      prevAccepted: StateMachine.lastAcceptedByPid?.[participant.id] || "",
      cdpAttached: false,
    };
    tryAttachCDPForPolling(state, participant.tabId);
    state.intervalId = setInterval(() => pollOnce(participant, state), POLL_INTERVAL_MS);
    pollers.set(participant.service, state);
    return state;
  }

  // 外部触发的轮次（辩论 / 总结 / 手动发送）：不再 inject（外部已 inject），只显示用户气泡+启动 polling
  // displayText = popup 用户气泡显示文本（如"⚔️ 第1轮辩论·自由"）
  // participantServices = 受影响的参与者 service id 列表（如 ["claude","gemini","chatgpt"]）
  // presetMsgId = v4.6.13 F20: 外部预生成 msgId（用于辩论 pending 占位 → 正式状态复用同气泡）
  // opts.skipPolling = v5.0.17 P0-3: 辩论路径已在 inject 成功时逐 AI 启动 polling，
  //   这里只更新气泡/日志，不再统一启动（避免重启 polling 重置 state 计数）
  function notifyRoundStart(displayText, participantServices, presetMsgId, opts = {}) {
    // v4.8.36: 复用 _resolveTargetsWithSkipped，对辩论/总结路径同样 fail loud
    const { targetList, skippedServices } = _resolveTargetsWithSkipped(participantServices);
    if (!targetList.length) return { ok: false, error: "无目标参与者", skippedTargets: skippedServices };

    const msgId = presetMsgId || newMsgId();
    pushLog({ role: "user", msgId, text: displayText, ts: Date.now() });
    sendToPopup({ type: "chatStreamUpdate", role: "user", msgId, text: displayText });

    for (const p of targetList) {
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId,
        participantId: p.service, text: "", isDone: false,
      });
      // 启动该 participant 的 polling（不 inject）
      if (!opts.skipPolling) startPollingForService(p, msgId);
    }
    // v4.8.36: 对已离开的 service 创建警告气泡
    _emitSkippedWarning(msgId, skippedServices);
    return { ok: true, msgId, skippedTargets: skippedServices };
  }

  // v5.0.17 P0-4: SW 被回收后 pollers/watchers（内存 setInterval）全部蒸发，StateMachine
  //   只把状态读回内存，没人重启 polling → 用户感知"AI 在网页里答完了，扩展气泡永远转圈"。
  //   SW 重启 init 完成后扫描：流程在等待回答中 + 参与者还没拿到回答 + tab 活着 → 重建 polling。
  //   时间窗 10 分钟：超窗视为陈旧会话（隔天重开浏览器等），不自动恢复避免提取过期内容。
  const RESUME_POLL_WINDOW_MS = 10 * 60 * 1000;
  async function resumePollingForPending() {
    try {
      const fs = StateMachine.flowState;
      const waiting = (typeof FlowState !== "undefined")
        ? [FlowState.BROADCASTING, FlowState.AWAITING_RESPONSES, FlowState.DEBATING, FlowState.SUMMARY].includes(fs)
        : ["broadcasting", "awaiting_responses", "debating", "summary"].includes(fs);
      if (!waiting) return { resumed: 0 };
      const lastTs = StateMachine.lastSentTs || 0;
      if (!lastTs || Date.now() - lastTs > RESUME_POLL_WINDOW_MS) return { resumed: 0, reason: "stale" };

      const pending = [];
      for (const p of StateMachine.participants || []) {
        if (!p?.tabId || !p.service) continue;
        if (p.response) continue;             // 已有回答的不用恢复
        if (pollers.has(p.service)) continue; // 已在轮询（同生命周期内重复调用防御）
        try { await chrome.tabs.get(p.tabId); } catch (_) { continue; } // tab 已关闭
        pending.push(p);
      }
      if (!pending.length) return { resumed: 0 };

      const msgId = newMsgId();
      const names = pending.map(p => p.name).join("、");
      const tip = `🔄 后台连接已恢复，继续接收 ${names} 的回答`;
      pushLog({ role: "user", msgId, text: tip, ts: Date.now() });
      sendToPopup({ type: "chatStreamUpdate", role: "user", msgId, text: tip });
      for (const p of pending) {
        // v5.0.17: 裁判总结恢复时复用 pendingSummary 存的原 msgId — 完成气泡落回原占位
        //   气泡而非新增孤立气泡（finalize 触发链依赖 pollOnce 完成，msgId 一致 UX 才连贯）
        const ps = StateMachine.pendingSummary;
        const useMsgId = (ps && ps.judgeId === p.id && ps.msgId) ? ps.msgId : msgId;
        sendToPopup({
          type: "chatStreamUpdate", role: "ai", msgId: useMsgId,
          participantId: p.service, text: "", isDone: false,
        });
        startPollingForService(p, useMsgId);
      }
      console.log(`[chat-bus] v5.0.17 SW 重启恢复 polling × ${pending.length}: ${names}`);
      return { resumed: pending.length };
    } catch (e) {
      console.warn("[chat-bus] resumePollingForPending fail:", e?.message);
      return { resumed: 0, error: e?.message };
    }
  }

  // v4.8.19 F32: 完全废弃 chrome.debugger 路线
  // bootstrap-main-world.js 在每个 AI tab 的 document_start 注入 visibility patch
  // SPA 永远以为自己在前台，DOM 树持续更新，content script 读取无障碍
  // 两个函数保留 no-op 以兼容 polling 里仍存在的调用位置
  function releaseCDPFor(_state, _tabId) { /* no-op (F32) */ }
  async function tryAttachCDPForPolling(_state, _tabId) { /* no-op (F32) */ }

  async function injectAndPoll(participant, msgId, text) {
    const { tabId, service } = participant;
    // v4.8.50: 必须检查 content script 返回的 status — 旧逻辑只接 throw 异常，
    //   但 content script 在 "穷尽 retry 后" 仍可能 return { status: "sent" } 谎报，
    //   导致上层无感、polling 一直读到旧/错位内容（用户场景：Claude ProseMirror 注入失败
    //   但 status=sent → polling 误读输入框 "你好" 为回答）。现在 status==='error' 也走错误路径。
    let injectResp;
    try {
      injectResp = await chrome.tabs.sendMessage(tabId, { action: "inject", text });
    } catch (e) {
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId,
        participantId: service, text: `⚠ ${participant.name} 注入失败: ${e.message}`,
        isDone: true,
      });
      return;
    }
    if (injectResp?.status === "error") {
      const errMsg = injectResp.error || "未知错误";
      const fullText = `⚠ ${participant.name} 注入失败: ${errMsg}（请手动在 ${participant.name} 页面发送或点击 🔄 重试）`;
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId,
        participantId: service, text: fullText, isDone: true, injectError: true,
      });
      pushLog({
        role: "ai", msgId, participantId: service,
        text: fullText, ts: Date.now(), injectError: true,
      });
      return;
    }
    // v4.8.61: 防御 — injectResp 为 undefined / null（content script 没 sendResponse 或返回非对象）
    //   不当作成功也不当作 error，记 warn 后继续 polling 兜底（empty timeout 45s 兜底真错误）
    if (!injectResp || typeof injectResp !== "object") {
      console.warn(`[chat-bus] v4.8.61 injectResp invalid for ${service}:`, injectResp, "— continuing with polling fallback");
    }
    // v4.8.60: 处理 inject_warning（fail-soft）—— inject 完成但 button 状态可疑，仍启动 polling 兜底验证
    //   场景：Enter 已 dispatch 但 React state 同步慢，button 检测显示 disabled。Enter 可能已触发 AI 发送。
    //   行为：发一个非阻断的 status 提示（不 isDone，让 polling 接管，empty timeout 45s 才真正报错）
    if (injectResp?.inject_warning) {
      console.warn(`[chat-bus] v4.8.60 inject_warning for ${service}: ${injectResp.inject_warning}`);
      // 不发 popup notification（避免提早惊扰用户，让 polling 安静兜底）
      // 仅记日志方便后续 diagnose
    }
    // 启动 polling（v5.0.17: 复用 startPollingForService）
    startPollingForService(participant, msgId);
  }

  // v5.0.19: 失败气泡的超长 prompt 提示 — 用户场景：多轮辩论后队友回答全文转发让单次
  //   上传到 1 万+ 字，偶发触发公司网关上传限额，表现为发送失败/掉线/提取不到。
  //   本轮发给该 AI 的内容超阈值时在失败气泡里给出压缩指引（开了压缩后自然不会超）。
  const LONG_PROMPT_HINT_CHARS = 8000;
  function _longPromptHintFor(participant) {
    try {
      const sentLen = (StateMachine.lastSentByPid?.[participant.id] || "").length;
      if (sentLen <= LONG_PROMPT_HINT_CHARS) return "";
      return `\n💡 本轮发给它的内容有 ${sentLen} 字，可能被公司网关/站点上传限额拦截 — 可在设置开启「上下文压缩」，或在辩论补发弹窗选「🗜 压缩后补发」。`;
    } catch (_) { return ""; }
  }

  async function pollOnce(participant, state) {
    // v5.2.22 Bug A 根治：防重入堆积 — 上一次轮询（含 await sendMessage）未返回时跳过本 tick。
    //   千问回答变长致单次往返 >POLL_INTERVAL_MS 时，setInterval 会无脑积压多个并发 pollOnce，
    //   它们共享同一 state → 并发污染 sameCount/stableKey + 每个独立判完成 → 重复完成 N 次，
    //   N 倍下游任务（readOneResponse/startWatch/sendToPopup）挤爆单线程 SW → 拖累所有 AI。
    //   守卫后同一时刻只有 1 个 pollOnce 实质执行，雪崩根除。
    if (state.inFlight) return;
    state.inFlight = true;
    const { tabId, service } = participant;
    try {
      // v4.5.5 F5: 全局 tick 上限兜底
      state.totalTicks = (state.totalTicks || 0) + 1;
      if (state.totalTicks >= MAX_POLL_TICKS) {
        clearInterval(state.intervalId);
        pollers.delete(service);
        releaseCDPFor(state, tabId);
        const finalText = state.lastText || "⚠ 超时 5 分钟未完成，已按当前内容强制结束";
        pushLog({
          role: "ai", msgId: state.msgId, participantId: service,
          text: finalText, ts: Date.now(), forcedTimeout: true,
        });
        sendToPopup({
          type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
          participantId: service, text: finalText, isDone: true,
          forcedTimeout: true,
        });
        return;
      }

      // v5.0.20 同文本省略：带上次文本哈希，content 端一致时只回 sameText 不传全文 —
      //   长回答（1-2 万字）在"已稳定等完成判定"的 3-12s 窗口里每 1.5s 整段跨进程序列化
      //   是消息层最大浪费，省略后该窗口传输量降 ~95%
      const r = await chrome.tabs.sendMessage(tabId, {
        action: "readResponse",
        knownHash: (state.lastText && state.lastTextHash) || null,
      });
      let text;
      if (r?.sameText && state.lastText != null) {
        text = state.lastText;
      } else {
        text = (r?.text || "").trim();
        state.lastTextHash = r?.textHash || null;
      }
      const hasRich = !!r?.hasRichContent;
      const richTypes = r?.richTypes || [];

      // v4.3.2: 空文本超时保护——某些 AI（ChatGPT 生图等）readResponse 持续返回 "",
      // 旧逻辑 `text.length > 0` 永远不真 → polling 卡死。
      // 现在：空文本累计 EMPTY_TIMEOUT_TICKS 次（每 tick 1.5s）后放弃并标记"未提取到内容"
      // v4.3.3: 但如果有图还在加载（imagesPending>0），不算 empty timeout
      const EMPTY_TIMEOUT_TICKS = 30; // ~45 秒
      if (text === "" && !(r?.imagesPending > 0)) {
        state.emptyCount = (state.emptyCount || 0) + 1;
        if (state.emptyCount >= EMPTY_TIMEOUT_TICKS) {
          clearInterval(state.intervalId);
          pollers.delete(service);
          releaseCDPFor(state, tabId);
          const fallbackText = "⚠ 未提取到内容，请点击气泡的 🔄 重新提取或 ⏭ 跳过本轮。" + _longPromptHintFor(participant);
          pushLog({
            role: "ai", msgId: state.msgId, participantId: service,
            text: fallbackText, ts: Date.now(), emptyTimeout: true,
          });
          sendToPopup({
            type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
            participantId: service, text: fallbackText, isDone: true,
            emptyTimeout: true,
          });
          // v5.0.60 顺序接力：空文本超时 → reject 让接力循环立即跳过它（不必等 180s waiter 超时）
          _rejectOneWaiter(service, new Error("empty_timeout"));
          return;
        }
      } else {
        state.emptyCount = 0;
      }

      // v4.5.6 F11: 拒绝读到上一轮残留 — Kimi/Gemini 等 DOM 更新慢的平台，新对话发出后
      // 旧 assistant 气泡仍是 last DOM，前几 tick readResponse 抓到上一轮文本；老逻辑会
      // 连续 3 次相同→判完成→把残留推给 popup（截图证据：Kimi 第二轮气泡和第一轮一字不差）
      const prevAccepted = state.prevAccepted || "";
      const head100 = s => (s || "").trim().slice(0, 100);
      const isResidue = text && prevAccepted && (
        text === prevAccepted ||
        (head100(text).length >= 50 && head100(text) === head100(prevAccepted))
      );
      if (isResidue) {
        // 视为"新回答还没出现"，重置稳定计数但不算 empty（不超时）；
        // 也不 sendToPopup，避免气泡闪现旧内容
        state.lastStableKey = null;
        state.sameCount = 0;
        return;
      }

      // v5.0.3: prompt-echo 自愈 — Claude (ProseMirror) 偶发 Enter 不识别 submit，导致 inject 假
      //   成功但 Claude 网页没真发送，polling 提取到的"回答"就是用户问题本身。检测到 echo 时一次
      //   补发兜底（让 content script 找 send button click），最多 1 次防循环。
      // v5.0.4: 守卫只对 Claude 生效 — 其他 AI 未观察到此模式且无 resubmit handler，避免误触。
      if (service === "claude") {
        const lastSent = (StateMachine.lastSentByPid?.[participant.id] || "").trim();
        const t = text;
        const isPromptEcho = !state.resubmitTried && lastSent && t && t.length >= 5 && (
          t === lastSent ||
          head100(t) === head100(lastSent)
        );
        if (isPromptEcho) {
          state.resubmitTried = true;
          state.lastStableKey = null;
          state.sameCount = 0;
          try { chrome.tabs.sendMessage(tabId, { action: "resubmit" }).catch(() => {}); } catch (_) {}
          return;
        }
      }

      // v4.3.3: stableKey 把 imagesPending 计入稳定性判定
      // → 文本停止变化但图还在加载时，stableKey 仍会变 → 不算 stable
      const imagesPending = r?.imagesPending || 0;
      const stableKey = `${text}|imgPending:${imagesPending}`;
      if (stableKey === state.lastStableKey) {
        state.sameCount++;
        // v4.6.8 F18: 完成判定加 !r.isStreaming — 让 polling 不再仅靠 4.5s 文本不变就判完成
        // 防 ChatGPT/Claude 等模型"输出第一段后停顿规划下一段"时被早判完成（截图证据：
        // 复杂技术问题 ChatGPT 输出 "我" 后停顿 4.5s → polling 误判 → 气泡卡在"我"）
        // streaming selector 失效时 isStreaming 退化为 false，行为同老版本，向后兼容
        //
        // v5.2.18 双阈值：
        //   - 快路径 sameCount>=3(4.5s) + !isStreaming：streaming 信号可信时快速完成（原逻辑）
        //   - 兜底 sameCount>=8(12s)：无视 isStreaming 强制完成，解决 streaming selector 误报
        //     导致第二轮起拖到 5min 超时的问题（详见 STREAM_DONE_THRESHOLD_FORCE 注释）
        const doneFast = state.sameCount >= STREAM_DONE_THRESHOLD && !r?.isStreaming;
        // v5.0.46: 仍在生成/思考（isStreaming=true）→ 用长思考兜底阈值（~105s），不被 9s 误判完成；
        //   否则（信号显示已停）沿用原 9s 兜底
        const forceTicks = r?.isStreaming ? STREAM_DONE_THRESHOLD_FORCE_THINKING : STREAM_DONE_THRESHOLD_FORCE;
        const doneForce = state.sameCount >= forceTicks;
        if ((doneFast || doneForce) && text.length > 0) {
          // 完成
          clearInterval(state.intervalId);
          pollers.delete(service);
          pushLog({
            role: "ai", msgId: state.msgId, participantId: service,
            text, ts: Date.now(), hasRichContent: hasRich, richTypes,
          });
          sendToPopup({
            type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
            participantId: service, text, isDone: true,
            hasRichContent: hasRich, richTypes,
          });
          // v5.0.60 顺序接力：该 AI 答完 → resolve 等待它的 waitForOneParticipant，
          //   让 handleDebateRoundSequential 把回答纳入上下文后发下一个。无 waiter 时安全 no-op。
          _resolveOneWaiter(service, { service, text });
          // v4.8.10 F27-bugfix: 必须等 readOneResponse 写入 p.response 再 detach CDP
          // 之前立即 releaseCDPFor → readOneResponse 在 background throttle 下读到旧/空 DOM
          // → sanity check 拒绝 → setParticipantResponse 不被调 → p.response 仍为空
          // → handleDebateRound 检查 `if (p.response)` 全空 → 报"回答不足"
          // 截图证据：DeepSeek/千问 popup 显示"已完成"+ 文本（sendToPopup 路径），
          //          但 p.response 空（readOneResponse 失败），用户辩论时报错
          if (typeof readOneResponse === "function") {
            // v5.0.17 P1-5: 落库失败 fail-loud — 旧版静默吞掉，用户看到气泡有完整回答，
            //   但 p.response 为空，点"开始辩论"报"回答不足"完全无从排查。
            //   现在把原因追加到气泡 + 写进 chatLog（popup 重开后历史里警告也在）。
            const _storeFailLoud = (reason) => {
              const warnText = `${text}\n\n⚠ 该回答未能落库（${reason || "未知原因"}），发起辩论前请点 🔄 重新提取`;
              pushLog({
                role: "ai", msgId: state.msgId, participantId: service,
                text: warnText, ts: Date.now(), hasRichContent: hasRich, richTypes,
                responseStoreError: true,
              });
              sendToPopup({
                type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
                participantId: service, text: warnText, isDone: true,
                hasRichContent: hasRich, richTypes, responseStoreError: true,
              });
            };
            readOneResponse(participant.id)
              .then((rr) => { if (rr && rr.ok === false) _storeFailLoud(rr.error); })
              .catch((e) => {
                // v5.0.17: reject 路径同样可见（旧版 catch(()=>{}) 全吞）
                console.warn("[chat-bus] readOneResponse reject:", service, e?.message);
                _storeFailLoud(e?.message);
              })
              .finally(() => releaseCDPFor(state, tabId));
          } else {
            releaseCDPFor(state, tabId);
          }
          // v4.4.0: 如果当前 polling 完成的 AI 是 pendingSummary 的裁判，触发 finalize
          try {
            const ps = StateMachine.pendingSummary;
            if (ps && ps.judgeId === participant.id && text && typeof finalizeDebateSummary === "function") {
              StateMachine.setPendingSummary?.(null) ?? (StateMachine.pendingSummary = null);
              finalizeDebateSummary(text, ps).catch(e => console.warn("[chat-bus] finalize summary fail:", e?.message));
            }
          } catch (e) { console.warn("[chat-bus] pendingSummary check fail:", e?.message); }
          // v4.6.9 F19: 启动兜底 watcher 监听 60s，发现 text 追加用同 msgId 覆盖气泡
          // 防 F18 streaming selector 失效 / 完成后异步追加场景
          // v5.0.20: 传 lastTextHash 让 watcher 也走同文本省略（无追加时不整段回传）
          try { startWatch(participant, state.msgId, text, state.lastTextHash); }
          catch (e) { console.warn("[chat-bus] startWatch fail:", e?.message); }
        }
      } else {
        // v5.0.8 perf: 文本与上次完全一致时跳过 sendToPopup（流式 90% tick 文本未增长，
        //   popup updateAIBubble + renderMarkdown 整段重渲是主线程大头）。
        //   sameCount 已在 stableKey 分支累加，这里 fallthrough 到广播：仅当文本真变化才推。
        const textChanged = text !== state.lastText;
        state.lastStableKey = stableKey;
        state.lastText = text;
        state.sameCount = 0;
        if (textChanged) {
          sendToPopup({
            type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
            participantId: service, text, isDone: false,
          });
        }
      }
    } catch (e) {
      // tab 关闭或 content script 失联
      clearInterval(state.intervalId);
      // v5.0.8 perf: pollers.delete 加身份校验 — 防 removeParticipant 后同 service 立即
      //   re-add 时新 poller 已占用 service slot，旧 catch 误删新 poller 让新 AI 气泡不更新
      if (pollers.get(service) === state) pollers.delete(service);
      releaseCDPFor(state, tabId);
      // v5.0.18 P2-2: 同步清该 service 的兜底 watcher — 失联后让它再空 tick 一次没有意义
      try { stopWatch(service); } catch (_) {}
      // v5.0.60 顺序接力：失联 → reject 等待它的 waiter，让接力循环跳过它继续下一个
      _rejectOneWaiter(service, new Error("disconnect"));
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
        participantId: service, text: `⚠ ${participant.name} 已断开${_longPromptHintFor(participant)}`,
        isDone: true,
      });
    } finally {
      // v5.2.22 Bug A：本次轮询结束（含完成/超时/断开 return），释放重入锁允许下一 tick
      state.inFlight = false;
    }
  }

  function getLog() { return chatLog.slice(); }
  function clearLog() { chatLog.length = 0; chrome.storage.local.remove(STORAGE_KEYS.log); }

  // v4.6.6 F13: hardReset 时清所有 polling — 防旧 polling 下个 tick 调失效 tabId
  // 走 catch 分支推 "⚠ XX 已断开" 残留消息到 popup，造成"重置后不再同步"假象
  function clearAllPollers() {
    for (const [, state] of pollers) {
      try { clearInterval(state.intervalId); } catch (_) {}
    }
    pollers.clear();
    // v4.6.9 F19: 一并清 watchers 防失效 tabId 调用
    // v5.0.8: watcher 改用 setTimeout 链式（state.timerId），兼容老 intervalId 字段双清
    for (const [, state] of watchers) {
      try { clearTimeout(state.timerId); } catch (_) {}
      try { clearInterval(state.intervalId); } catch (_) {}
    }
    watchers.clear();
    // v5.0.60 顺序接力：reject 所有挂起的 waiter — hardReset 中途接力时让循环跳出，不永挂
    for (const [, w] of _oneWaiters) {
      try { clearTimeout(w.timeoutId); } catch (_) {}
      try { w.reject(new Error("cleared")); } catch (_) {}
    }
    _oneWaiters.clear();
    // v4.8.19 F32: 已无 CDP attach 需要 detach
  }

  // v5.0.18 P2-2: 显式停掉某 service 的 watcher（pollOnce 失联清理用）
  function stopWatch(service) {
    const st = watchers.get(service);
    if (!st) return;
    try { clearTimeout(st.timerId); } catch (_) {}
    try { clearInterval(st.intervalId); } catch (_) {}
    watchers.delete(service);
  }

  // v4.6.9 F19: polling 判完成后启动兜底 watcher
  // 单 slot 限制：启动新 watch 时清掉旧 service 的 watcher 防累积
  // v5.0.8 perf: setInterval → setTimeout 链式，文本稳定 30s 后 tick 间隔 3s→8s
  //   减少长时间无追加场景的 SW 唤醒频率（旧版 watcher 跑满 600s = 200 次空 tick）
  //   原 WATCH_INTERVAL_MS = 3000 仍是初始/有追加时的 tick 间隔
  const WATCH_INTERVAL_IDLE_MS = 8000;
  const WATCH_STABLE_DOWNGRADE_MS = 30000;
  function startWatch(participant, msgId, finalText, finalTextHash) {
    const { service, tabId } = participant;
    // 单 slot：清掉所有现有 watchers
    if (watchers.size >= WATCH_MAX_SLOTS) {
      for (const [, st] of watchers) {
        try { clearTimeout(st.timerId); } catch (_) {}
        try { clearInterval(st.intervalId); } catch (_) {}  // 兼容老字段
      }
      watchers.clear();
    }
    const state = {
      msgId,
      lastText: finalText || "",
      lastTextHash: finalTextHash || null,  // v5.0.20: 同文本省略
      startTs: Date.now(),
      lastChangeTs: Date.now(),
    };
    const scheduleNext = (delay) => {
      state.timerId = setTimeout(tickFn, delay);
    };
    const tickFn = async () => {
      // 600s 总兜底（v4.8.40）
      if (Date.now() - state.startTs > WATCH_MAX_DURATION_MS) {
        if (watchers.get(service) === state) watchers.delete(service);
        return;
      }
      // v5.0.8: 身份校验 — 如果本 state 已被 startWatch 替换，停止本 tick 链
      if (watchers.get(service) !== state) return;
      try {
        // v5.0.20: watcher 也走同文本省略 — 600s 兜底期每 3-8s 一次的全文回传是大头
        const r = await chrome.tabs.sendMessage(tabId, {
          action: "readResponse",
          knownHash: (state.lastText && state.lastTextHash) || null,
        });
        const text = r?.sameText ? state.lastText : (r?.text || "").trim();
        // 文本比上次长且非残留 → 追加更新（用同 msgId 让 popup updateAIBubble 直接覆盖气泡）
        if (text && text.length > state.lastText.length && text !== state.lastText) {
          state.lastText = text;
          state.lastTextHash = r?.textHash || null;  // v5.0.20: 增长必然带新哈希
          state.lastChangeTs = Date.now();
          // v4.8.40 核心修复：watcher 必须写 p.response，否则下一轮辩论 handleDebateRound
          //   读 p.response 还是 polling 完成时的初始文本（ChatGPT Pro 思考片段），
          //   导致辩论用旧的思考做上下文。setParticipantResponse 同时更新
          //   lastAcceptedByPid（下次 polling sanity check 基准）。
          try { StateMachine.setParticipantResponse(participant.id, text); } catch (_) {}
          sendToPopup({
            type: "chatStreamUpdate", role: "ai", msgId: state.msgId,
            participantId: service, text, isDone: true,
            hasRichContent: !!r?.hasRichContent, richTypes: r?.richTypes || [],
            watcherUpdate: true,
          });
          pushLog({
            role: "ai", msgId: state.msgId, participantId: service,
            text, ts: Date.now(), watcherUpdate: true,
          });
        }
        // v4.6.10: 文本不变也不停 watcher，继续跑满总兜底时长覆盖晚到追加
        // v5.0.8: 文本稳定 30s+ 后 tick 间隔 3s→8s
        const stableMs = Date.now() - state.lastChangeTs;
        scheduleNext(stableMs > WATCH_STABLE_DOWNGRADE_MS ? WATCH_INTERVAL_IDLE_MS : WATCH_INTERVAL_MS);
      } catch (_) {
        // tab 失效 / content script 失联 → 停 watcher
        if (watchers.get(service) === state) watchers.delete(service);
      }
    };
    scheduleNext(WATCH_INTERVAL_MS);
    watchers.set(service, state);
  }
  async function jumpToOrigin(participantId) {
    const p = (StateMachine.participants || []).find(x => x.service === participantId);
    if (!p || !p.tabId) return { ok: false, error: "未找到参与者标签页" };
    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 });
      return { ok: true };
    } catch (e) {
      return { ok: false, error: e.message };
    }
  }

  // v4.3.0：跳过本轮某 AI — 停 polling，标记为"已跳过"，让"等全部完成"判定不卡住
  function skipParticipant(participantId, msgId) {
    const service = participantId;
    if (pollers.has(service)) {
      const state = pollers.get(service);
      try { clearInterval(state.intervalId); } catch (_) {}
      pollers.delete(service);
      // v4.8.9 F27: 跳过本轮也要 detach CDP
      const p = (StateMachine.participants || []).find(x => x.service === service);
      releaseCDPFor(state, p?.tabId);
    }
    // 通知 popup（保险：popup 主动改 UI 已经做过，这里是兜底广播）
    sendToPopup({
      type: "chatStreamUpdate", role: "ai",
      msgId: msgId || `skip_${Date.now()}`,
      participantId, text: "⏭ 已跳过本轮", isDone: true,
      skipped: true,
    });
    // 写入 log 让历史目录看得到
    pushLog({
      role: "ai", msgId: msgId || `skip_${Date.now()}`,
      participantId, text: "⏭ 已跳过本轮",
      ts: Date.now(), skipped: true,
    });
    return { ok: true };
  }

  // v4.8.4 F24: 手动重新提取鲁棒化 — 复用 background.readOneResponse 的 5 道 sanity 保险
  // （v3.0 同款逻辑：超时保护 / 用户消息回显拒绝 / 上轮残留拒绝 / 平台错误识别 / 空文本告警）
  // + 5 次重试覆盖现代 SPA AI 网页 DOM 异步渲染（v3.0 当年单次足够，现在不够）
  // + 立刻推 loading 占位 (msgId 复用) 给用户瞬时反馈
  // + 失败时推明确错误气泡（不再静默推空气泡覆盖原内容）
  async function reextractOne(participantId) {
    // v4.3.13: participantId 既可能是 service 名（来自气泡 dataset），也可能是
    // participant.id（来自成员卡 ⋯ 菜单），两种都要支持
    const list = StateMachine.participants || [];
    const p = list.find(x => x.id === participantId)
           || list.find(x => x.service === participantId);
    if (!p || !p.tabId) return { ok: false, error: "未找到参与者" };
    // v4.8.43: 用户主动点重新提取 → 清除 userEdited 让新 AI 内容能被写入
    try { StateMachine.clearUserEdited?.(p.id); } catch (_) {}

    const msgId = `manual_${Date.now()}`;
    // 立刻推 loading 占位（用同 msgId，成功 / 失败时覆盖更新）
    sendToPopup({
      type: "chatStreamUpdate", role: "ai", msgId,
      participantId: p.service, text: "🔄 正在重新提取…", isDone: false,
    });

    const MAX_RETRIES = 5;
    const RETRY_DELAY_MS = 1000;
    let lastError = "未读到内容";
    let succeeded = null;

    for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
      try {
        // 复用 background.readOneResponse — v3.0 同款 sanity check + setParticipantResponse
        const r = (typeof readOneResponse === "function")
          ? await readOneResponse(p.id)
          : { ok: false, error: "readOneResponse 不可用" };
        if (r?.ok && r?.text) { succeeded = r; break; }
        if (r?.error) lastError = r.error;
      } catch (e) {
        lastError = e?.message || lastError;
      }
      if (attempt < MAX_RETRIES - 1) {
        await new Promise(res => setTimeout(res, RETRY_DELAY_MS));
      }
    }

    if (succeeded) {
      // 成功后再读一次 chrome.tabs.sendMessage 拿富文本字段（readOneResponse 只返回 text）
      let richMeta = { hasRichContent: false, richTypes: [] };
      try {
        const r2 = await chrome.tabs.sendMessage(p.tabId, { action: "readResponse" });
        richMeta = { hasRichContent: !!r2?.hasRichContent, richTypes: r2?.richTypes || [] };
      } catch (_) {}
      sendToPopup({
        type: "chatStreamUpdate", role: "ai", msgId,
        participantId: p.service, text: succeeded.text, isDone: true,
        ...richMeta,
      });
      pushLog({
        role: "ai", msgId, participantId: p.service,
        text: succeeded.text, ts: Date.now(),
        ...richMeta,
      });
      try { StateMachine._broadcastStateUpdate?.(); } catch (_) {}
      return { ok: true, text: succeeded.text };
    }

    // 5 次都失败 → 推明确错误气泡告诉用户具体原因（不再静默推空气泡）
    sendToPopup({
      type: "chatStreamUpdate", role: "ai", msgId,
      participantId: p.service,
      text: `⚠ 重新提取失败：${lastError}\n\n请确认 ${p.name} 页面已出现 AI 回答，必要时刷新页面后再试。`,
      isDone: true,
    });
    return { ok: false, error: lastError };
  }

  // v4.3.0：把 popup 窗口拉回前端（添加 AI 窗口/排列窗口后调用，防止 popup 失焦）
  // v4.6.6 F15: drawAttention:true 强提示 — Chrome 88+ 收紧 SW 内 focused:true 政策
  // 没有强用户手势会被静默拒绝，drawAttention:true 至少让 taskbar 闪烁提示用户切回
  async function focusPopup() {
    if (popupWindowId == null) return { ok: false, error: "popup not open" };
    try {
      await chrome.windows.update(popupWindowId, { focused: true, drawAttention: true });
      return { ok: true };
    } catch (e) {
      popupWindowId = null;
      return { ok: false, error: e.message };
    }
  }

  // v4.8.38: 暴露当前正在 polling 的 service 列表（handleDebateRound 用来检测
  //   "有 AI 正在回答中"，避免用旧 p.response 启动下一轮辩论）
  function getActivePollingServices() {
    return [...pollers.keys()];
  }

  return {
    init,
    openChatPopup,
    focusPopup,
    onWindowRemoved,
    rememberBounds,
    // v4.8.63: 暴露 popupWindowId 给 background onRemoved listener 判断是否是 popup
    getPopupWindowId: () => popupWindowId,
    // v4.8.64: 暴露 pollers Map size 给 background 判断是否延迟 detach（polling 在跑时不立即解 attach）
    getActivePollerCount: () => pollers.size,
    broadcast,
    notifyRoundStart,
    startPollingForService,    // v5.0.17 P0-3: 辩论路径逐 AI 启动 polling
    waitForOneParticipant,     // v5.0.60: 顺序接力 — 等单 AI 答完的 await 钩子
    resumePollingForPending,   // v5.0.17 P0-4: SW 重启后恢复进行中的轮询
    getLog,
    clearLog,
    clearAllPollers,
    setPopupWindowId,
    jumpToOrigin,
    reextractOne,
    skipParticipant,
    toggleMiniMode,  // v4.8.15 F30
    getPopupMode,    // v4.8.15 F30
    miniMenuExpand,  // v4.8.28
    getActivePollingServices,  // v4.8.38
    // setMiniSkippedServices 在 v4.8.31 删除（mini 点击改 removeParticipant）
    _flushLogSync: flushLogSync,  // v5.0.8: onSuspend 兜底
  };
})();

self.ChatBus = ChatBus;

// v5.0.8 perf: SW 30s idle 回收前 flush chatLog 节流写盘，防最后几条聊天记录丢失
try {
  chrome.runtime.onSuspend.addListener(() => {
    try { ChatBus._flushLogSync?.(); } catch (_) {}
  });
} catch (_) {}
