/**
 * Bilibili 视频总结 - 后台服务
 * 处理 API 调用、Tavily 事实核查、标签页音频捕获
 */

// 服务端代理配置（登录后启用，自动走后端代理 + 扣配额）
const SERVER_CONFIG = {
  baseUrl: '',        // 如 http://localhost:3010 或 https://your-server.com
  authToken: '',      // JWT token
  user: null,         // 当前登录用户信息
};
const DEFAULT_SERVER_BASE_URL = 'https://api.cetle.cn';
// 官网主站从 api.cetle.cn 迁到 cetle.cn：接口仍走 DEFAULT_SERVER_BASE_URL，但登录、
// 学习等静态页面改由 cetle.cn 提供，页面发来的会话消息必须同时信任两个来源。
const DEFAULT_SITE_BASE_URL = 'https://cetle.cn';

// 自建部署（如 localhost 或自定义域名）时主站与接口同源，直接返回接口源即可。
function siteOriginFor(baseUrl) {
  const apiOrigin = new URL(baseUrl || DEFAULT_SERVER_BASE_URL).origin;
  return apiOrigin === new URL(DEFAULT_SERVER_BASE_URL).origin
    ? new URL(DEFAULT_SITE_BASE_URL).origin
    : apiOrigin;
}

// 页面消息（官网页面经 externally_connectable 或内容脚本发出）允许的来源：
// 接口域名 + 主站域名，两者都匹配 sender.url 与 sender.origin。
function trustedSenderOrigins(baseUrl) {
  return [...new Set([new URL(baseUrl || DEFAULT_SERVER_BASE_URL).origin, siteOriginFor(baseUrl)])];
}

function isTrustedSender(sender, baseUrl) {
  const senderUrl = String(sender?.url || '');
  const senderOrigin = String(sender?.origin || '');
  return trustedSenderOrigins(baseUrl).some((origin) =>
    senderOrigin === origin || senderUrl === origin || senderUrl.startsWith(origin + '/'));
}
const DEVICE_ID_STORAGE_KEY = 'installationDeviceId';
const BILIBILI_UID_STORAGE_KEY = 'biliSummaryBilibiliUid';
const BILIBILI_UID_TTL_MS = 6 * 60 * 60 * 1000;
// B 站 UID（mid）缓存：已知时随每个服务端请求通过 X-BiliSummary-Uid 上报，
// 便于服务端把插件账号与 B 站账号关联；0 表示未登录 B 站或尚未获取。
// disabled 表示用户主动取消关联，期间不再自动上报，直到侧栏重新关联。
const BILIBILI_UID = { mid: 0, fetchedAt: 0, disabled: false };
const CREDIT_INSUFFICIENT_MESSAGE = '糟糕，学分不足，得升级会员（点击上方我的）或等待每日赠送学分刷新';
const PROVIDER_CREDIT_INSUFFICIENT_MESSAGE = '上游模型服务暂时不可用，请联系管理员';
const PROVIDER_CONFIG_ERROR_MESSAGE = '模型上游地址配置错误：请在服务端管理后台把 Base URL 设置为 OpenAI-compatible API 地址，例如 https://api.8612306.xyz/v1';
const MAX_OUTPUT_TOKENS_DEFAULT = 3000;
const MAX_OUTPUT_TOKENS_MIN = 100;
const MAX_OUTPUT_TOKENS_MAX = 12000;

function extensionRequestHeaders(headers = {}) {
  const userAgent = navigator.userAgent || '';
  const result = {
    ...headers,
    'X-BiliSummary-Extension': '1',
    'X-BiliSummary-Version': chrome.runtime.getManifest().version,
    'X-BiliSummary-Platform': /Edg\//i.test(userAgent) ? 'edge' : 'chrome',
    'X-BiliSummary-Extension-Id': chrome.runtime.id,
  };
  if (BILIBILI_UID.mid > 0 && !BILIBILI_UID.disabled) result['X-BiliSummary-Uid'] = String(BILIBILI_UID.mid);
  return result;
}

async function refreshBilibiliMid() {
  if (BILIBILI_UID.disabled) return;
  if (Date.now() - BILIBILI_UID.fetchedAt < BILIBILI_UID_TTL_MS) return;
  try {
    const response = await fetch('https://api.bilibili.com/x/web-interface/nav', {
      credentials: 'include',
      cache: 'no-store',
    });
    const data = await response.json();
    const mid = data?.code === 0 && data?.data?.isLogin ? Number(data.data?.mid || 0) : 0;
    if (Number.isFinite(mid) && mid >= 0) cacheBilibiliMid(mid);
  } catch (e) {
    // 网络失败不影响主流程，下次请求再试
  }
}

function cacheBilibiliMid(mid) {
  const value = Math.max(0, Number(mid) || 0);
  if (!value) {
    BILIBILI_UID.mid = 0;
    BILIBILI_UID.fetchedAt = 0;
  } else {
    BILIBILI_UID.mid = value;
    BILIBILI_UID.fetchedAt = Date.now();
  }
  chrome.storage.local.set({
    [BILIBILI_UID_STORAGE_KEY]: {
      mid: BILIBILI_UID.mid,
      fetchedAt: BILIBILI_UID.fetchedAt,
      disabled: BILIBILI_UID.disabled || false,
    },
  }).catch(() => {});
}

function normalizeLanguage(value) {
  return ['zh-CN', 'zh-TW', 'en'].includes(String(value || '').trim()) ? String(value).trim() : 'zh-CN';
}

function normalizeMaxOutputTokens(value) {
  const tokens = Math.round(Number(value));
  if (!Number.isFinite(tokens)) return MAX_OUTPUT_TOKENS_DEFAULT;
  return Math.min(MAX_OUTPUT_TOKENS_MAX, Math.max(MAX_OUTPUT_TOKENS_MIN, tokens));
}

function promptLocale(language) {
  const lang = normalizeLanguage(language);
  if (lang === 'en') return {
    videoTitle: 'Video title', creator: 'Creator', duration: 'Duration', content: 'Video transcript',
    factCheck: 'Fact-check results', verified: 'verified', caution: 'needs context', incorrect: 'incorrect',
    followUpSystem: [
      'You answer follow-up questions about a video.',
      'Use the supplied summary and transcript as your primary evidence.',
      'If the video does not contain enough information, say so plainly; never invent details.',
      'Write clear, concise English. Use headings, lists, or tables only when they improve readability.'
    ].join('\n'),
    summary: 'Create a thorough, accurate English summary of the following video.',
    summaryNoMetadata: 'Do not begin the summary by repeating the video title, creator, or duration. Start directly with the substantive content in a formal tone, without conversational lead-ins such as “Sure” or “Here is”. Correct any typos in the original transcript based on context.',
    mathFormatting: 'When an important mathematical expression appears, write valid TeX using \\( ... \\) inline or \\[ ... \\] on its own line. Do not put TeX inside code fences.',
    generatedSummary: 'Generated summary', transcript: 'Video transcript', conversation: 'Previous follow-up questions', question: 'Question'
  };
  if (lang === 'zh-TW') return {
    videoTitle: '影片標題', creator: '創作者', duration: '長度', content: '影片逐字稿',
    factCheck: '事實查核結果', verified: '已驗證', caution: '需留意', incorrect: '有誤',
    followUpSystem: '你是影片內容追問助手。請優先依據提供的影片摘要與逐字稿回答；若內容不足，請直接說明「影片內容沒有足夠資訊」，不要杜撰。請使用自然、清晰的繁體中文（臺灣用語）回答，可視需要使用小標、清單或表格。',
    summary: '請以自然、清晰的繁體中文（臺灣用語）完整整理下列影片內容，涵蓋主要觀點、重要資訊與結論。',
    summaryNoMetadata: '不要在摘要開頭重複影片標題、創作者或影片長度，請直接以正式陳述語氣從實質內容開始，不要使用「好的」、「以下是」等回覆式銜接。原字幕中如有錯別字，務必根據上下文改正。',
    mathFormatting: '遇到重要數學式時，請輸出有效 TeX：行內使用 \\( ... \\)，獨立公式使用 \\[ ... \\] 並單獨成行；不要把 TeX 放進程式碼區塊。',
    generatedSummary: '已產生的摘要', transcript: '影片逐字稿', conversation: '先前追問紀錄', question: '本次追問'
  };
  return {
    videoTitle: '视频标题', creator: 'UP主', duration: '时长', content: '视频内容',
    factCheck: '事实核查结果', verified: '已验证', caution: '需注意', incorrect: '有误',
    followUpSystem: '你是一个视频内容追问助手。请优先依据提供的视频总结和视频原文回答；如果信息不足，请明确说明“视频内容中没有足够信息”，不要编造。回答应清晰、具体、简洁，可以使用小标题、列表或表格。',
    summary: '请对以下视频内容进行全面总结，涵盖主要观点、关键信息和结论。保持客观准确，使用清晰的结构化格式。',
    summaryNoMetadata: '不要在总结开头复述视频标题、UP主或视频时长，请直接以正式陈述语气从实质内容开始，不要使用“好的”“以下是”等回复式衔接。原字幕中如有错别字，务必根据上下文改正。',
    mathFormatting: '遇到重要数学公式时，请输出有效 TeX：行内使用 \\( ... \\)，独立公式使用 \\[ ... \\] 并单独成行；不要把 TeX 放进代码块。',
    generatedSummary: '已生成总结', transcript: '视频原文/字幕', conversation: '已有追问记录', question: '本次追问'
  };
}

function applyServerConfig(cfg = {}) {
  if (cfg.serverBaseUrl !== undefined) SERVER_CONFIG.baseUrl = normalizeServerBaseUrl(cfg.serverBaseUrl);
  if (cfg.authToken !== undefined) SERVER_CONFIG.authToken = (cfg.authToken || '').trim();
  if (cfg.user !== undefined) SERVER_CONFIG.user = cfg.user;
}

async function setAuthenticatedSession(serverBaseUrl, token, user) {
  const normalizedServerUrl = normalizeServerBaseUrl(serverBaseUrl);
  SERVER_CONFIG.baseUrl = normalizedServerUrl;
  SERVER_CONFIG.authToken = (token || '').trim();
  SERVER_CONFIG.user = user || null;
  await chrome.storage.local.set({
    serverBaseUrl: normalizedServerUrl,
    authToken: SERVER_CONFIG.authToken,
    user: SERVER_CONFIG.user,
    authUserId: user?.id || null,
    authSavedAt: Date.now(),
    lastQuotaRefresh: Date.now(),
  });
}

async function clearAuthenticatedSession() {
  SERVER_CONFIG.authToken = '';
  SERVER_CONFIG.user = null;
  await chrome.storage.local.remove(['authToken', 'user', 'authUserId', 'authSavedAt', 'lastQuotaRefresh']);
}

function createDeviceId() {
  if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID().replace(/-/g, '');
  const bytes = new Uint8Array(24);
  globalThis.crypto.getRandomValues(bytes);
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}

async function getInstallationDeviceId() {
  const stored = await chrome.storage.local.get({ [DEVICE_ID_STORAGE_KEY]: '' });
  const current = String(stored[DEVICE_ID_STORAGE_KEY] || '');
  if (/^[a-zA-Z0-9_-]{16,128}$/.test(current)) return current;
  const deviceId = createDeviceId();
  await chrome.storage.local.set({ [DEVICE_ID_STORAGE_KEY]: deviceId });
  return deviceId;
}

function normalizeServerBaseUrl(url) {
  const value = String(url || '').trim().replace(/\/+$/, '');
  if (/^https?:\/\/(?:localhost|127\.0\.0\.1):3010$/i.test(value)) {
    return DEFAULT_SERVER_BASE_URL;
  }
  return value;
}

function logConfig(label) {
  console.log('[BiliSummary] ' + label, {
    serverBaseUrl: SERVER_CONFIG.baseUrl,
    loggedIn: !!SERVER_CONFIG.authToken,
  });
}

// 启动时从 storage 加载完整配置。所有 API 调用都必须等待它完成，避免 service worker 冷启动时用默认配置。
const configReady = new Promise((resolve) => {
  chrome.storage.local.get(
    ['serverBaseUrl', 'authToken', 'user', 'authUserId', BILIBILI_UID_STORAGE_KEY],
    (r) => {
      if (r.authUserId && r.user?.id && Number(r.authUserId) !== Number(r.user.id)) {
        r.user = null;
      }
      applyServerConfig(r);
      const storedUid = r[BILIBILI_UID_STORAGE_KEY];
      if (storedUid) {
        if (Number(storedUid.mid) > 0) {
          BILIBILI_UID.mid = Number(storedUid.mid);
          BILIBILI_UID.fetchedAt = Number(storedUid.fetchedAt) || 0;
        }
        BILIBILI_UID.disabled = storedUid.disabled === true;
      }
      logConfig('配置已加载');
      resolve();
    }
  );
});

async function ensureConfigLoaded() {
  await configReady;
}

async function ensureServerSessionLoaded() {
  await ensureConfigLoaded();
  refreshBilibiliMid().catch(() => {});
  if (SERVER_CONFIG.baseUrl && SERVER_CONFIG.authToken) return;
  const stored = await chrome.storage.local.get(['serverBaseUrl', 'authToken', 'user', 'authUserId']);
  if (stored.authUserId && stored.user?.id && Number(stored.authUserId) !== Number(stored.user.id)) {
    stored.user = null;
  }
  applyServerConfig(stored);
}

async function readResponseData(resp) {
  const text = await resp.text();
  if (!text) return {};
  try {
    return JSON.parse(text);
  } catch (e) {
    return { error: sanitizeServerError(text) };
  }
}

// 服务端请求统一超时：服务器无响应时按超时时间返回明确错误，
// 而不是让侧栏「我的」等按钮和总结请求无限期挂起。
const SERVER_REQUEST_TIMEOUT_MS = 15000;
const DASHBOARD_PULL_TAB_CAP = 10;
const DASHBOARD_PULL_FETCH_TIMEOUT_MS = 4000;
const DASHBOARD_PULL_RETRY_COOLDOWN_MS = 30000;
const STREAM_FIRST_BYTE_TIMEOUT_MS = 120000;
const STREAM_IDLE_TIMEOUT_MS = 120000;
let lastFailedDashboardPullAt = 0;

async function fetchWithTimeout(url, options = {}, timeoutMs = SERVER_REQUEST_TIMEOUT_MS) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } catch (err) {
    if (err?.name === 'AbortError') {
      const timeoutError = new Error('服务器响应超时，请稍后重试');
      timeoutError.code = 'SERVER_TIMEOUT';
      throw timeoutError;
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

function sanitizeServerError(value) {
  const text = String(value || '').trim();
  if (!text) return '服务异常，请稍后重试';
  if (text.includes('api.8612306.xyz')) {
    return '服务配置异常，请稍后重试';
  }
  if (/<!doctype|<html|<head|<body|<title|<script/i.test(text)) {
    return '服务配置异常，请稍后重试';
  }
  return text.length > 80 ? text.slice(0, 80) + '…' : text;
}

function looksLikeHtmlText(value) {
  return /^\s*(?:<!doctype\s+html|<html|<head|<body|<title|<script)\b/i.test(String(value || ''));
}

function isProviderApiUrl(url) {
  return /api\.8612306\.xyz|xiaomimimo|openai|chatgpt|librechat/i.test(String(url || ''));
}

function isAppCreditError(data, status) {
  return data?.code === 'APP_CREDIT_INSUFFICIENT';
}

function isProviderCreditError(data) {
  const message = String(data?.error || '');
  return data?.code === 'PROVIDER_CREDIT_INSUFFICIENT' || /学分不足|余额不足|insufficient/i.test(message);
}

function createServerApiError(data, fallbackText, prefix = '') {
  if (isAppCreditError(data)) {
    const err = new Error(CREDIT_INSUFFICIENT_MESSAGE);
    err.code = 'APP_CREDIT_INSUFFICIENT';
    return err;
  }
  if (isProviderCreditError(data)) {
    const err = new Error(PROVIDER_CREDIT_INSUFFICIENT_MESSAGE);
    err.code = 'PROVIDER_CREDIT_INSUFFICIENT';
    return err;
  }
  const message = data?.error || fallbackText || '调用失败';
  const err = new Error(prefix ? `${prefix}: ${message}` : message);
  if (data?.code) err.code = data.code;
  return err;
}

chrome.storage.onChanged.addListener((changes, areaName) => {
  if (areaName !== 'local') return;
  const serverCfg = {};
  for (const key of ['serverBaseUrl', 'authToken', 'user']) {
    if (changes[key]) serverCfg[key] = changes[key].newValue;
  }
  if (Object.keys(serverCfg).length > 0) {
    applyServerConfig(serverCfg);
    logConfig('配置已同步');
  }
});

// ============ 侧边栏 ============
try {
  const panelBehaviorResult = chrome.sidePanel?.setPanelBehavior?.({ openPanelOnActionClick: true });
  panelBehaviorResult?.catch?.(() => {
    // 浏览器拒绝设置侧栏行为时不影响 service worker 启动
  });
} catch {
  // 旧版浏览器可能缺少 API 或同步抛错，不能因此让 service worker 注册失败
}

const sidePanelPorts = new Set();
const BILIBILI_VIDEO_TAB_URLS = ['https://www.bilibili.com/video/*', 'https://www.bilibili.com/bangumi/play/*', 'https://www.bilibili.com/cheese/play/*', 'https://www.bilibili.com/list/watchlater/*'];
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== 'bili-summary-side-panel') return;
  sidePanelPorts.add(port);
  port.onDisconnect.addListener(() => {
    sidePanelPorts.delete(port);
    if (sidePanelPorts.size > 0) return;
    chrome.tabs.query({ url: BILIBILI_VIDEO_TAB_URLS }).then((tabs) =>
      Promise.all(tabs.filter((tab) => tab.id).map((tab) =>
        chrome.tabs.sendMessage(tab.id, { action: 'setSidePanelOpenState', open: false }).catch(() => {})
      ))
    ).catch(() => {});
  });
});

async function forceReinjectBilibiliContentScripts() {
  const tabs = await chrome.tabs.query({ url: BILIBILI_VIDEO_TAB_URLS });
  const nonce = 'bili-summary-' + Date.now() + '-' + Math.random().toString(36).slice(2);
  await Promise.all(tabs.filter((tab) => tab.id).map(async (tab) => {
    try {
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        func: (injectionNonce) => { window.__biliSummaryForceInjectionNonce = injectionNonce; },
        args: [nonce],
      });
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        world: 'MAIN',
        files: ['content/playback-rate-main.js'],
      });
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        files: ['content/bilibili.js'],
      });
    } catch (err) {
      // 页面刚跳转或已关闭时无需重试；下次完成加载会自动注入内容脚本。
    }
  }));
}

chrome.runtime.onInstalled.addListener(() => {
  forceReinjectBilibiliContentScripts().catch(() => {});
});

chrome.runtime.onStartup.addListener(() => {
  forceReinjectBilibiliContentScripts().catch(() => {});
});

// ============ 消息路由 ============
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  switch (message.action) {
    case 'summarize':
      handleSummarize(message, sendResponse);
      return true;

    case 'assistStyle':
      handleAssistStyle(message, sendResponse);
      return true;

    case 'followUp':
      handleFollowUp(message, sendResponse);
      return true;

    case 'factCheck':
      handleFactCheck(message, sendResponse);
      return true;

    case 'injectContentScript':
      handleInjectContentScript(message, sendResponse);
      return true;

    case 'ensurePlaybackRateMainWorld':
      handleEnsurePlaybackRateMainWorld(message, sender, sendResponse);
      return true;

    case 'setPlaybackRateMainWorld':
      handleSetPlaybackRateMainWorld(message, sender, sendResponse);
      return true;

    case 'fetchSubtitle':
      handleFetchSubtitle(message, sendResponse);
      return true;

    case 'fetchJsonInPage':
      handleFetchJsonInPage(message, sender, sendResponse);
      return true;

    case 'fetchClassroomSubtitleViewInPage':
      handleFetchClassroomSubtitleViewInPage(message, sender, sendResponse);
      return true;

    case 'getBilibiliPageState':
      handleGetBilibiliPageState(message, sender, sendResponse);
      return true;

    case 'getBilibiliToolboxData':
      handleGetBilibiliToolboxData(message, sendResponse);
      return true;

    case 'fetchBilibiliMediaChunk':
      handleFetchBilibiliMediaChunk(message, sendResponse);
      return true;

    case 'openSidePanel':
      handleOpenSidePanel(message, sender, sendResponse);
      return true;

    case 'closeSidePanel':
      handleCloseSidePanel(message, sender, sendResponse);
      return true;

    case 'setFloatingButtonEnabled':
      handleSetFloatingButtonEnabled(message, sendResponse);
      return true;

    case 'fetchBilibiliJson':
      handleFetchBilibiliJson(message, sendResponse);
      return true;

    case 'saveToCollection':
      handleSaveToCollection(message, sendResponse);
      return true;

    case 'openCollection':
      handleOpenCollection(message, sendResponse);
      return true;

    case 'serverUpdateLanguage':
      handleServerUpdateLanguage(message, sendResponse);
      return true;

    case 'getInstallationDeviceId':
      getInstallationDeviceId().then((deviceId) => sendResponse({ success: true, deviceId }))
        .catch(() => sendResponse({ success: false, error: 'device id unavailable' }));
      return true;

    case 'serverQuota':
      handleServerQuota(sendResponse);
      return true;

    case 'serverMe':
      handleServerMe(sendResponse);
      return true;

    case 'syncDashboardSessionFromPage':
      handleDashboardPageSession(message, sender, sendResponse);
      return true;

    case 'serverNotifications':
      handleServerNotifications(sendResponse);
      return true;

    case 'serverAdvancedNotice':
      handleServerAdvancedNotice(sendResponse);
      return true;

    case 'setMobileBilibiliUid':
      handleSetMobileBilibiliUid(message, sendResponse);
      return true;

    case 'clearMobileBilibiliUid':
      handleClearMobileBilibiliUid(message, sendResponse);
      return true;

    case 'transcribeVideoAudio':
      handleTranscribeVideoAudio(message, sendResponse);
      return true;

    case 'uploadTranscribedAudioChunk':
      handleUploadTranscribedAudioChunk(message, sendResponse);
      return true;

    case 'ackAudioSummaryJob':
      acknowledgeStoredAudioSummaryJob(String(message.jobId || ''))
        .then(() => sendResponse({ success: true }))
        .catch((error) => sendResponse({ success: false, error: error.message }));
      return true;

    case 'getAudioSummaryJobs':
      storedAudioSummaryJobs()
        .then((jobs) => sendResponse({ success: true, jobs }))
        .catch(() => sendResponse({ success: false, error: '读取音频总结任务失败' }));
      return true;

    case 'getAudioSummaryJob':
      handleGetAudioSummaryJob(message, sendResponse);
      return true;

    case 'prepareKimiAudio':
      handlePrepareKimiAudio(message, sendResponse);
      return true;

    case 'fetchPreparedKimiAudioChunk':
      handleFetchPreparedKimiAudioChunk(message, sendResponse);
      return true;

    case 'deletePreparedKimiAudio':
      handleDeletePreparedKimiAudio(message, sendResponse);
      return true;

    case 'cancelVideoAudioTranscription':
      cancelVideoAudioTranscription(message, sendResponse);
      return true;

    case 'reportVideoWatchStats':
      handleReportVideoWatchStats(message, sendResponse);
      return true;
  }
});

chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
  if (message?.action === 'dashboardLogout') {
    handleServerLogout(sendResponse);
    return true;
  }
  if (message?.action === 'dashboardSession') {
    handleDashboardSession(message, sender, sendResponse);
    return true;
  }
  if (message?.action === 'openStudyAdjacent') {
    handleOpenStudyAdjacent(message, sender, sendResponse);
    return true;
  }
  if (message?.action === 'getBilibiliBotCookies') {
    handleGetBilibiliBotCookies(message, sender, sendResponse);
    return true;
  }
  if (message?.action === 'notificationsRead') {
    chrome.runtime.sendMessage({ type: 'notificationsRead', unreadCount: Math.max(0, Number(message.unreadCount || 0)) }).catch(() => {});
    sendResponse({ success: true });
    return false;
  }
  sendResponse({ success: false, error: 'unsupported external message' });
  return false;
});

// 评论总结管理页「一键从浏览器检测」：只响应官网页面，且要求页面携带与扩展相同的登录令牌。
// 本扩展不申请 cookies 权限（避免商店审核与用户端权限提示），SESSDATA 是 HttpOnly 读不到，
// 这里把请求转发给任意已打开的 B 站页面内容脚本，用 document.cookie 读取其余 Cookie 与账号信息；
// SESSDATA 由管理页「扫码登录机器人账号」补齐。

async function handleGetBilibiliBotCookies(message, sender, sendResponse) {
  try {
    if (!isTrustedSender(sender, SERVER_CONFIG.baseUrl || DEFAULT_SERVER_BASE_URL)) {
      sendResponse({ success: false, error: '请求来源不受信任' });
      return;
    }
    await ensureConfigLoaded();
    const stored = await chrome.storage.local.get(['authToken']);
    const token = String(message?.token || '');
    if (!token || !stored.authToken || token !== stored.authToken) {
      sendResponse({ success: false, error: '令牌不匹配：请先在本浏览器登录插件账号，并刷新管理页面后重试' });
      return;
    }
    const tabs = await chrome.tabs.query({ url: BILIBILI_VIDEO_TAB_URLS });
    const target = tabs.find((tab) => tab.id && tab.status === 'complete') || null;
    if (!target?.id) {
      sendResponse({ success: false, error: '未找到已打开的 B 站页面：请先打开任意 B 站视频页再重试' });
      return;
    }
    let response = null;
    try {
      response = await chrome.tabs.sendMessage(target.id, { action: 'getBilibiliBotCookies' });
    } catch (_) {
      // 商店升级或扩展重新加载后，已打开的视频页可能还没有新版内容脚本。
      await chrome.scripting.executeScript({ target: { tabId: target.id }, files: ['content/bilibili.js'] });
      response = await chrome.tabs.sendMessage(target.id, { action: 'getBilibiliBotCookies' });
    }
    if (!response?.success) {
      sendResponse({ success: false, error: response?.error || 'B 站页面未响应，请刷新 B 站页面后重试' });
      return;
    }
    sendResponse({ success: true, data: response.data });
  } catch (err) {
    sendResponse({ success: false, error: err.message || '检测失败' });
  }
}

async function handleOpenStudyAdjacent(message, sender, sendResponse) {
  try {
    if (!sender?.url) throw new Error('无法识别当前学习页');
    await ensureConfigLoaded();
    const configuredBaseUrl = SERVER_CONFIG.baseUrl || DEFAULT_SERVER_BASE_URL;
    if (!isTrustedSender(sender, configuredBaseUrl)) throw new Error('不受信任的学习页');
    const siteOrigin = siteOriginFor(configuredBaseUrl);
    const target = new URL(String(message?.url || ''), siteOrigin);
    if (!trustedSenderOrigins(configuredBaseUrl).includes(target.origin) || target.pathname !== '/study.html') throw new Error('学习页地址无效');
    const sourceTab = sender.tab?.id
      ? sender.tab
      : (await chrome.tabs.query({ active: true, lastFocusedWindow: true }))[0];
    if (!sourceTab?.id || !trustedSenderOrigins(configuredBaseUrl).includes(new URL(sourceTab.url || '').origin)) throw new Error('无法识别当前学习页');
    const createOptions = { url: target.href, active: true };
    if (sourceTab.index !== undefined) createOptions.index = sourceTab.index + 1;
    if (sourceTab.windowId !== undefined) createOptions.windowId = sourceTab.windowId;
    await chrome.tabs.create(createOptions);
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err.message || '打开学习页失败' });
  }
}

async function handleOpenSidePanel(message, sender, sendResponse) {
  try {
    if (!chrome.sidePanel?.open) {
      return sendResponse({ success: false, error: '当前浏览器不支持侧栏 API' });
    }
    const tabId = sender?.tab?.id || message.tabId;
    if (tabId) {
      await chrome.sidePanel.open({ tabId });
    } else if (sender?.tab?.windowId || message.windowId) {
      await chrome.sidePanel.open({ windowId: sender?.tab?.windowId || message.windowId });
    } else {
      return sendResponse({ success: false, error: '无法识别当前标签页' });
    }
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err?.message || '打开侧栏失败' });
  }
}

async function handleSetFloatingButtonEnabled(message, sendResponse) {
  const enabled = message?.enabled !== false;
  try {
    await chrome.storage.local.set({ biliSummaryFloatingButtonEnabled: enabled });
    const tabs = await chrome.tabs.query({ url: BILIBILI_VIDEO_TAB_URLS });
    await Promise.all(tabs.filter((tab) => tab.id).map((tab) =>
      chrome.tabs.sendMessage(tab.id, { action: 'setFloatingButtonEnabled', enabled }).catch(() => {})
    ));
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err?.message || 'failed to update floating button setting' });
  }
}

async function handleServerUpdateLanguage(message, sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) return sendResponse({ success: false, error: '未登录' });
  try {
    const resp = await fetch(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/config`, {
      method: 'PUT',
      headers: extensionRequestHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
      body: JSON.stringify({ language: normalizeLanguage(message.language) }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok || !data.user) return sendResponse({ success: false, error: data.error || '语言保存失败' });
    await setAuthenticatedSession(SERVER_CONFIG.baseUrl, data.token || SERVER_CONFIG.authToken, data.user);
    sendResponse({ success: true, user: data.user });
  } catch (err) {
    sendResponse({ success: false, error: err.message || '语言保存失败' });
  }
}

async function handleServerLogout(sendResponse) {
  await ensureServerSessionLoaded();
  await clearAuthenticatedSession();
  sendResponse({ success: true });
}

// 侧栏检测到未登录时，从已打开且已登录的官网标签页直接读取登录会话，
// 避免用户先登录官网再打开侧栏时还要走一遍「前往登录」流程。
async function pullDashboardSession(force = false) {
  await ensureConfigLoaded();
  if (SERVER_CONFIG.authToken && !force) return { success: true, from: 'storage' };
  // 扫描失败后进入冷却：后台只在真正恢复（页面 token 变化触发同步）或冷却结束后重扫，
  // 避免多个消息同时触发对大量官网标签页的逐页注入，把服务 worker 卡住几分钟。
  if (Date.now() - lastFailedDashboardPullAt < DASHBOARD_PULL_RETRY_COOLDOWN_MS) {
    return { success: false, reason: 'cooldown' };
  }
  const baseUrls = [...new Set([SERVER_CONFIG.baseUrl, DEFAULT_SERVER_BASE_URL]
    .map((url) => normalizeServerBaseUrl(url))
    .filter(Boolean))];
  for (const baseUrl of baseUrls) {
    let origins = [];
    try {
      origins = [...new Set([new URL(baseUrl).origin, siteOriginFor(baseUrl)])];
    } catch (_) {
      continue;
    }
    let tabs = [];
    for (const origin of origins) {
      tabs = [...tabs, ...(await chrome.tabs.query({ url: [origin + '/*'] }).catch(() => []))];
    }
    // 并行扫描最多 10 个标签页，优先问页面里已注入的 dashboard 内容脚本拿 token
    // （消息直达、秒回），只有扩展更新前打开的旧标签页才退回 executeScript 注入。
    const candidates = tabs
      .filter((tab) => tab?.id && tab.status === 'complete')
      .slice(0, DASHBOARD_PULL_TAB_CAP);
    const results = await Promise.all(candidates.map(async (tab) => {
      let token = '';
      try {
        const response = await chrome.tabs.sendMessage(tab.id, { action: 'getDashboardSessionToken' });
        token = String(response?.token || '').trim();
      } catch (_) {
        try {
          const [injection] = await chrome.scripting.executeScript({
            target: { tabId: tab.id },
            func: () => String(localStorage.getItem('token') || '').trim(),
          });
          token = String(injection?.result || '').trim();
        } catch (_) {
          return null;
        }
      }
      if (!token) return null;
      try {
        const resp = await fetchWithTimeout(`${baseUrl}/api/me`, {
          headers: extensionRequestHeaders({ Authorization: `Bearer ${token}` }),
        }, DASHBOARD_PULL_FETCH_TIMEOUT_MS);
        const data = await readResponseData(resp);
        if (!resp.ok || !data.user) return null;
        return { token, user: data.user };
      } catch (_) {
        return null;
      }
    }));
    const hit = results.find(Boolean);
    if (hit) {
      await setAuthenticatedSession(baseUrl, hit.token, hit.user);
      return { success: true, user: hit.user };
    }
  }
  lastFailedDashboardPullAt = Date.now();
  return { success: false };
}

async function handleDashboardSession(message, sender, sendResponse) {
  const token = String(message?.token || '').trim();
  if (!token || !sender?.url) return sendResponse({ success: false, error: 'invalid dashboard session' });
  try {
    const senderOrigin = new URL(sender.url).origin;
    await ensureConfigLoaded();
    const configuredBaseUrl = SERVER_CONFIG.baseUrl || DEFAULT_SERVER_BASE_URL;
    if (!trustedSenderOrigins(configuredBaseUrl).includes(senderOrigin)) {
      return sendResponse({ success: false, error: 'untrusted dashboard origin' });
    }
    const resp = await fetch(`${configuredBaseUrl.replace(/\/+$/, '')}/api/me`, {
      headers: extensionRequestHeaders({ Authorization: `Bearer ${token}` }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok || !data.user) return sendResponse({ success: false, error: 'invalid dashboard token' });
    await setAuthenticatedSession(configuredBaseUrl, token, data.user);
    chrome.runtime.sendMessage({
      type: 'dashboardSessionChanged',
      language: normalizeLanguage(data.user.language),
      user: data.user,
    }).catch(() => {});
    sendResponse({ success: true, user: data.user });
  } catch (err) {
    sendResponse({ success: false, error: 'dashboard session sync failed' });
  }
}

// admin 页面由内容脚本主动同步已验证的 localStorage.token，
// 这样用户直接打开官网登录（而不是从插件跳转）也能让插件进入登录态。
async function handleDashboardPageSession(message, sender, sendResponse) {
  const token = String(message?.token || '').trim();
  if (!token || !sender?.url) return sendResponse({ success: false, error: 'invalid dashboard session' });
  try {
    const senderOrigin = new URL(sender.url).origin;
    await ensureConfigLoaded();
    const candidateBaseUrls = [...new Set([SERVER_CONFIG.baseUrl, DEFAULT_SERVER_BASE_URL]
      .map((url) => normalizeServerBaseUrl(url))
      .filter(Boolean))];
    const baseUrl = candidateBaseUrls.find((url) => trustedSenderOrigins(url).includes(senderOrigin));
    if (!baseUrl) return sendResponse({ success: false, error: 'untrusted dashboard origin' });
    const resp = await fetch(`${baseUrl}/api/me`, {
      headers: extensionRequestHeaders({ Authorization: `Bearer ${token}` }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok || !data.user) return sendResponse({ success: false, error: 'invalid dashboard token' });
    await setAuthenticatedSession(baseUrl, token, data.user);
    chrome.runtime.sendMessage({
      type: 'dashboardSessionChanged',
      language: normalizeLanguage(data.user.language),
      user: data.user,
    }).catch(() => {});
    sendResponse({ success: true, user: data.user });
  } catch (err) {
    sendResponse({ success: false, error: 'dashboard session sync failed' });
  }
}

async function handleCloseSidePanel(message, sender, sendResponse) {
  try {
    if (typeof chrome.sidePanel?.close === 'function') {
      const windowId = sender?.tab?.windowId || message.windowId;
      await chrome.sidePanel.close(windowId ? { windowId } : {});
    } else {
      await chrome.runtime.sendMessage({ type: 'closeSidePanel' });
    }
    if (sender?.tab?.id) {
      await chrome.tabs.sendMessage(sender.tab.id, { action: 'setSidePanelOpenState', open: false }).catch(() => {});
    }
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err?.message || '收起侧栏失败' });
  }
}

async function handleServerQuota(sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    const pulled = await pullDashboardSession();
    if (!pulled?.success) return sendResponse({ success: false, error: '未登录' });
  }
  try {
    let resp = await fetchWithTimeout(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/quota`, {
      headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
    });
    let data = await resp.json();
    if (!resp.ok && resp.status === 401) {
      await clearAuthenticatedSession();
      const pulled = await pullDashboardSession(true);
      if (pulled?.success) {
        resp = await fetchWithTimeout(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/quota`, {
          headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
        });
        data = await resp.json();
      }
    }
    if (!resp.ok) {
      return sendResponse({ success: false, error: data.error });
    }
    await chrome.storage.local.set({ lastQuotaRefresh: Date.now() });
    return sendResponse({ success: true, quota: data.quota });
  } catch (err) {
    return sendResponse({ success: false, error: err.message });
  }
}

async function handleServerMe(sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    const pulled = await pullDashboardSession();
    if (!pulled?.success) return sendResponse({ success: false, error: '未登录' });
  }
  try {
    let resp = await fetchWithTimeout(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/me`, {
      headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
    });
    let data = await resp.json();
    if (!resp.ok && resp.status === 401) {
      await clearAuthenticatedSession();
      const pulled = await pullDashboardSession(true);
      if (pulled?.success) {
        resp = await fetchWithTimeout(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/me`, {
          headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
        });
        data = await resp.json();
      }
    }
    if (!resp.ok) {
      return sendResponse({ success: false, error: data.error });
    }
    SERVER_CONFIG.user = data.user;
    await chrome.storage.local.set({ user: data.user, authUserId: data.user?.id || null });
    return sendResponse({
      success: true,
      user: data.user,
      token: SERVER_CONFIG.authToken,
      serverBaseUrl: SERVER_CONFIG.baseUrl,
    });
  } catch (err) {
    return sendResponse({ success: false, error: err.message });
  }
}

async function handleSetMobileBilibiliUid(message, sendResponse) {
  const mid = Number(message?.mid);
  if (!Number.isSafeInteger(mid) || mid <= 0) {
    return sendResponse({ success: false, error: 'B 站 uid 无效' });
  }
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    return sendResponse({ success: false, error: '请先登录插件账号' });
  }
  try {
    BILIBILI_UID.disabled = false;
    cacheBilibiliMid(mid);
    // 显式授权：一个 B 站账号只能绑定一个本站账号；服务器校验冲突后才会写入。
    const resp = await fetch(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/bilibili-uid/authorize`, {
      method: 'POST',
      headers: extensionRequestHeaders({ 'Content-Type': 'application/json', Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
      body: JSON.stringify({ uid: String(mid) }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok || !data.user) throw new Error(data.error || '账号关联授权失败');
    SERVER_CONFIG.user = data.user;
    await chrome.storage.local.set({ user: data.user, authUserId: data.user.id || null });
    return sendResponse({ success: true, user: data.user });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '账号关联授权失败' });
  }
}

async function handleClearMobileBilibiliUid(message, sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    return sendResponse({ success: false, error: '请先登录插件账号' });
  }
  BILIBILI_UID.disabled = true;
  BILIBILI_UID.mid = 0;
  BILIBILI_UID.fetchedAt = 0;
  cacheBilibiliMid(0);
  try {
    const resp = await fetch(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/mobile-summary/unlink`, {
      method: 'POST',
      headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok || !data.user) throw new Error(data.error || '取消关联失败');
    SERVER_CONFIG.user = data.user;
    await chrome.storage.local.set({ user: data.user, authUserId: data.user.id || null });
    return sendResponse({ success: true, user: data.user });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '取消关联失败' });
  }
}

async function handleServerNotifications(sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    return sendResponse({ success: false, error: '未登录' });
  }
  try {
    const resp = await fetch(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/notifications`, {
      headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok) {
      if (resp.status === 401) await clearAuthenticatedSession();
      return sendResponse({ success: false, error: data.error || '通知加载失败' });
    }
    return sendResponse({ success: true, unreadCount: Number(data.unreadCount || 0) });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '通知加载失败' });
  }
}

async function handleServerAdvancedNotice(sendResponse) {
  await ensureConfigLoaded();
  const baseUrl = (SERVER_CONFIG.baseUrl || DEFAULT_SERVER_BASE_URL).replace(/\/+$/, '');
  try {
    const resp = await fetch(`${baseUrl}/api/extension/advanced-notice`, {
      cache: 'no-store',
      headers: extensionRequestHeaders(
        SERVER_CONFIG.authToken ? { Authorization: `Bearer ${SERVER_CONFIG.authToken}` } : {},
      ),
    });
    const data = await readResponseData(resp);
    if (!resp.ok) return sendResponse({ success: false, error: data.error || '高级通知加载失败' });
    return sendResponse({ success: true, notice: data.notice || null, baseUrl });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '高级通知加载失败' });
  }
}

function isAllowedBilibiliAudioUrl(value) {
  return /^https:\/\/[^/]*\.(?:hdslb\.com|bilivideo\.com|bilivideo\.cn)(?::\d+)?\//i.test(String(value || ''));
}

function isSupportedBilibiliMediaId(value) {
  return /^(?:BV[a-zA-Z0-9]+|ep\d+)$/.test(String(value || ''));
}

async function isCurrentBilibiliClassroomMedia(tabId, expectedBvid, expectedCid) {
  const [injection] = await chrome.scripting.executeScript({
    target: { tabId },
    world: 'MAIN',
    func: async (mediaId, cid) => {
      if (!/^ep\d+$/.test(mediaId) || !/\/cheese\/play\//.test(location.pathname)) return false;
      let cheeseState = {};
      try {
        cheeseState = typeof window.__EduPlayPiniaState__ === 'string'
          ? JSON.parse(window.__EduPlayPiniaState__)
          : (window.__EduPlayPiniaState__ || {});
      } catch {}
      const cheeseIndex = cheeseState.index || {};
      const episodes = Array.isArray(cheeseIndex.epList)
        ? cheeseIndex.epList
        : (Array.isArray(cheeseIndex.epPlayList) ? cheeseIndex.epPlayList : []);
      let manifest = {};
      try {
        if (window.playerRaw && typeof window.playerRaw.getManifest === 'function') {
          manifest = await Promise.resolve(window.playerRaw.getManifest()) || {};
        }
      } catch {}
      const routeEpisodeId = location.pathname.match(/\/cheese\/play\/ep(\d+)/)?.[1] || '';
      const episodeId = String(manifest.episodeId || manifest.epId || routeEpisodeId);
      if (!episodeId) return false;
      const episode = episodes.find((item) => String(item.id || item.episodeId) === episodeId) || null;
      const currentCid = String(episode?.cid || manifest.cid || '');
      return `ep${episodeId}` === mediaId && Boolean(currentCid) && currentCid === cid;
    },
    args: [String(expectedBvid || ''), String(expectedCid || '')],
  });
  return injection?.result === true;
}

function decodeBase64Bytes(value) {
  const binary = atob(String(value || ''));
  const bytes = new Uint8Array(binary.length);
  for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
  return bytes;
}

function encodeBase64Bytes(bytes) {
  let binary = '';
  for (let offset = 0; offset < bytes.length; offset += 0x8000) {
    binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
  }
  return btoa(binary);
}

async function fetchBilibiliAudioChunkInPage(tabId, audioUrl, bvid, start, end, cid = '', epId = '') {
  const [injection] = await chrome.scripting.executeScript({
    target: { tabId },
    world: 'MAIN',
    func: async (requestUrl, expectedBvid, rangeStart, rangeEnd, expectedCid, expectedEpId) => {
      let currentBvid = (location.pathname.match(/\/video\/(BV[\w]+)/) || [])[1]
        || (/\/list\/watchlater\//.test(location.pathname) ? (new URL(location.href).searchParams.get('bvid') || '') : '');
      let currentCid = '';
      if (/\/cheese\/play\//.test(location.pathname)) {
        let cheeseState = {};
        try {
          cheeseState = typeof window.__EduPlayPiniaState__ === 'string'
            ? JSON.parse(window.__EduPlayPiniaState__)
            : (window.__EduPlayPiniaState__ || {});
        } catch {}
        const cheeseIndex = cheeseState.index || {};
        const episodes = Array.isArray(cheeseIndex.epList)
          ? cheeseIndex.epList
          : (Array.isArray(cheeseIndex.epPlayList) ? cheeseIndex.epPlayList : []);
        let manifest = {};
        try {
          if (window.playerRaw && typeof window.playerRaw.getManifest === 'function') {
            manifest = await Promise.resolve(window.playerRaw.getManifest()) || {};
          }
        } catch {}
        const route = location.pathname.match(/\/cheese\/play\/ep(\d+)/);
        const episodeId = String(manifest.episodeId || manifest.epId || route?.[1] || '');
        const episode = episodes.find((item) => String(item.id || item.episodeId) === episodeId) || cheeseIndex.currentEp || null;
        currentBvid = episodeId ? `ep${episodeId}` : '';
        currentCid = String(episode?.cid || manifest.cid || '');
      } else if (/\/bangumi\/play\//.test(location.pathname)) {
        const state = window.__INITIAL_STATE__ || {};
        const playInfo = window.__playinfo__ || {};
        const playData = playInfo.result || playInfo.data || playInfo;
        let playerInfo = {};
        try {
          if (window.player && typeof window.player.getVideoInfo === 'function') playerInfo = window.player.getVideoInfo() || {};
        } catch {}
        const routeEpisodeId = location.pathname.match(/\/bangumi\/play\/ep(\d+)/)?.[1] || '';
        const stateEpisode = state.epInfo || state.ep_info || {};
        currentBvid = String(playerInfo.bvid || playData.bvid || stateEpisode.bvid || state.bvid || '');
        currentCid = String(playerInfo.cid || playData.cid || stateEpisode.cid || '');
        if (routeEpisodeId) {
          try {
            const seasonUrl = new URL('https://api.bilibili.com/pgc/view/web/season');
            seasonUrl.searchParams.set('ep_id', routeEpisodeId);
            const seasonResponse = await fetch(seasonUrl.href, { credentials: 'include', cache: 'no-store' });
            const seasonJson = await seasonResponse.json();
            const currentEpisode = seasonJson?.code === 0
              ? seasonJson.result?.episodes?.find((episode) => String(episode.ep_id || episode.id) === routeEpisodeId)
              : null;
            if (currentEpisode) {
              currentBvid = String(currentEpisode.bvid || '');
              currentCid = String(currentEpisode.cid || '');
            }
          } catch {}
        }
        if (expectedEpId && routeEpisodeId && routeEpisodeId !== expectedEpId) {
          return { ok: false, error: 'VIDEO_CHANGED' };
        }
      }
      const episodicPage = /^ep\d+$/.test(expectedBvid) || /\/bangumi\/play\//.test(location.pathname);
      if (!currentBvid || currentBvid !== expectedBvid || (episodicPage && expectedCid && currentCid !== expectedCid)) {
        return { ok: false, error: 'VIDEO_CHANGED' };
      }
      try {
        let total = 0;
        if (rangeStart === 0) {
          try {
            const headResponse = await fetch(requestUrl, {
              method: 'HEAD',
              credentials: 'omit',
              cache: 'no-store',
              referrer: location.href,
              referrerPolicy: 'strict-origin-when-cross-origin',
            });
            if (headResponse.ok) total = Number(headResponse.headers.get('content-length') || 0);
          } catch {}
        }
        const response = await fetch(requestUrl, {
          method: 'GET',
          // DASH URLs are already signed. Sending credentials turns the request
          // into credentialed CORS, which is incompatible with the CDN's
          // Access-Control-Allow-Origin: * response and becomes Failed to fetch.
          credentials: 'omit',
          cache: 'no-store',
          referrer: location.href,
          referrerPolicy: 'strict-origin-when-cross-origin',
          headers: { Range: `bytes=${rangeStart}-${rangeEnd}` },
        });
        if (!response.ok) return { ok: false, status: response.status };
        if (response.status !== 206) {
          return { ok: false, status: response.status, error: 'RANGE_UNSUPPORTED' };
        }
        const contentRange = response.headers.get('content-range') || '';
        const totalMatch = contentRange.match(/\/(\d+)$/);
        if (totalMatch) total = Number(totalMatch[1]);
        const bytes = new Uint8Array(await response.arrayBuffer());
        let binary = '';
        for (let offset = 0; offset < bytes.length; offset += 0x8000) {
          binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
        }
        return {
          ok: true,
          status: response.status,
          total,
          contentType: String(response.headers.get('content-type') || '').split(';')[0],
          base64: btoa(binary),
        };
      } catch (error) {
        return { ok: false, error: String(error?.message || error || 'FETCH_FAILED') };
      }
    },
    args: [audioUrl, bvid, start, end, String(cid || ''), String(epId || '')],
  });
  return injection?.result || { ok: false, error: 'PAGE_CONTEXT_UNAVAILABLE' };
}

async function fetchBilibiliMediaChunkDirect(mediaUrl, start, end) {
  try {
    let total = 0;
    if (start === 0) {
      try {
        const head = await fetch(mediaUrl, { method: 'HEAD', credentials: 'omit', cache: 'no-store' });
        if (head.ok) total = Number(head.headers.get('content-length') || 0);
      } catch {}
    }
    const response = await fetch(mediaUrl, {
      method: 'GET', credentials: 'omit', cache: 'no-store', headers: { Range: `bytes=${start}-${end}` },
    });
    if (!response.ok || response.status !== 206) return { ok: false, status: response.status, error: 'RANGE_UNSUPPORTED' };
    const totalMatch = String(response.headers.get('content-range') || '').match(/\/(\d+)$/);
    if (totalMatch) total = Number(totalMatch[1]);
    const bytes = new Uint8Array(await response.arrayBuffer());
    let binary = '';
    for (let offset = 0; offset < bytes.length; offset += 0x8000) {
      binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
    }
    return {
      ok: true,
      status: response.status,
      total,
      contentType: String(response.headers.get('content-type') || '').split(';')[0],
      base64: btoa(binary),
    };
  } catch (error) {
    return { ok: false, error: String(error?.message || error || 'FETCH_FAILED') };
  }
}

const audioTranscriptionControllers = new Map();
const AUDIO_SUMMARY_JOBS_STORAGE_KEY = 'biliSummaryPendingAudioJobs';
const AUDIO_SUMMARY_ALARM = 'bili-summary-audio-jobs';

async function storedAudioSummaryJobs() {
  const stored = await chrome.storage.local.get({ [AUDIO_SUMMARY_JOBS_STORAGE_KEY]: [] });
  return Array.isArray(stored[AUDIO_SUMMARY_JOBS_STORAGE_KEY]) ? stored[AUDIO_SUMMARY_JOBS_STORAGE_KEY] : [];
}

async function saveStoredAudioSummaryJobs(jobs) {
  const trimmed = jobs.slice(-30);
  await chrome.storage.local.set({ [AUDIO_SUMMARY_JOBS_STORAGE_KEY]: trimmed });
  const completed = trimmed.filter((job) => job.status === 'completed' && !job.acknowledged).length;
  await chrome.action.setBadgeBackgroundColor({ color: '#00aeec' }).catch(() => {});
  await chrome.action.setBadgeText({ text: completed ? String(Math.min(99, completed)) : '' }).catch(() => {});
}

async function rememberAudioSummaryJob(job) {
  const jobs = await storedAudioSummaryJobs();
  const index = jobs.findIndex((item) => item.id === job.id);
  if (index >= 0) jobs[index] = { ...jobs[index], ...job };
  else jobs.push(job);
  await saveStoredAudioSummaryJobs(jobs);
  await ensureAudioSummaryAlarm();
}

async function acknowledgeStoredAudioSummaryJob(jobId) {
  const jobs = await storedAudioSummaryJobs();
  const job = jobs.find((item) => item.id === jobId);
  if (job) job.acknowledged = true;
  await saveStoredAudioSummaryJobs(jobs.filter((item) => !item.acknowledged || !['completed', 'failed', 'cancelled'].includes(item.status)));
  await chrome.notifications?.clear?.(`bili-summary:${jobId}`)?.catch?.(() => {});
}

async function ensureAudioSummaryAlarm() {
  const jobs = await storedAudioSummaryJobs();
  const pending = jobs.some((job) => !['completed', 'failed', 'cancelled'].includes(job.status));
  const existing = await chrome.alarms.get(AUDIO_SUMMARY_ALARM);
  if (pending && !existing) {
    await chrome.alarms.create(AUDIO_SUMMARY_ALARM, { delayInMinutes: 0.5, periodInMinutes: 0.5 });
  } else if (!pending && existing) {
    await chrome.alarms.clear(AUDIO_SUMMARY_ALARM);
  }
}

async function fetchAudioSummaryJob(jobId, signal) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) throw new Error('登录状态已失效');
  const response = await fetch(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/audio-summary/jobs/${encodeURIComponent(jobId)}`, {
    headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
    signal,
  });
  const data = await readResponseData(response);
  if (!response.ok || data?.error) throw createServerApiError(data, JSON.stringify(data), '查询音频总结任务失败');
  return data;
}

async function handleGetAudioSummaryJob(message, sendResponse) {
  const jobId = String(message?.jobId || '').trim();
  if (!jobId) return sendResponse({ success: false, error: '缺少音频总结任务标识' });
  try {
    const status = await fetchAudioSummaryJob(jobId);
    await rememberAudioSummaryJob({
      id: jobId,
      status: status.status,
      ahead: Number(status.ahead || 0),
      error: status.error || '',
      code: status.code || '',
    });
    return sendResponse({ success: true, ...status, text: status.transcript || '' });
  } catch (error) {
    return sendResponse({ success: false, error: error.message || '查询音频总结任务失败', code: error.code });
  }
}

async function pollStoredAudioSummaryJobs() {
  const jobs = await storedAudioSummaryJobs();
  let changed = false;
  for (const job of jobs) {
    if (['failed', 'cancelled'].includes(job.status) || (job.status === 'completed' && job.notified)) continue;
    try {
      const status = await fetchAudioSummaryJob(job.id);
      Object.assign(job, { status: status.status, ahead: Number(status.ahead || 0), error: status.error || '' });
      changed = true;
      if (status.status === 'completed' && !job.notified) {
        job.notified = true;
        await saveStoredAudioSummaryJobs(jobs);
        const canNotify = Boolean(chrome.notifications?.create)
          && await chrome.permissions.contains({ permissions: ['notifications'] }).catch(() => false);
        if (canNotify) {
          await chrome.notifications.create(`bili-summary:${job.id}`, {
            type: 'basic', iconUrl: 'icons/icon128.png', title: '音频总结已完成',
            message: `《${job.title || job.bvid}》已完成，点击即可回到视频查看。`,
            contextMessage: 'B站视频总结',
          });
        }
        void chrome.runtime.sendMessage({ type: 'audioSummaryJobCompleted', jobId: job.id, bvid: job.bvid, cid: job.cid }).catch(() => {});
      }
    } catch (_) {
      // Transient network/auth failures are retried by the next alarm. The server owns the job.
    }
  }
  if (changed) await saveStoredAudioSummaryJobs(jobs);
  await ensureAudioSummaryAlarm();
}

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === AUDIO_SUMMARY_ALARM) void pollStoredAudioSummaryJobs();
});

chrome.notifications?.onClicked?.addListener((notificationId) => {
  if (!notificationId.startsWith('bili-summary:')) return;
  const jobId = notificationId.slice('bili-summary:'.length);
  void (async () => {
    const jobs = await storedAudioSummaryJobs();
    const job = jobs.find((item) => item.id === jobId);
    if (!job) return;
    const tab = await chrome.tabs.create({ url: job.url || `https://www.bilibili.com/video/${job.bvid}` });
    if (tab?.id && chrome.sidePanel?.open) {
      await chrome.sidePanel.open({ tabId: tab.id }).catch(() => {});
    }
  })();
});

void ensureAudioSummaryAlarm().catch(() => {});

function audioTranscriptionKey(message) {
  if (message?.requestId) return String(message.requestId);
  return `${Number(message?.tabId || 0)}:${String(message?.bvid || '')}:${String(message?.cid || '')}`;
}

async function cancelVideoAudioTranscription(message, sendResponse) {
  const controller = audioTranscriptionControllers.get(audioTranscriptionKey(message));
  if (controller) controller.abort();
  const jobId = String(message?.jobId || '').trim();
  if (!jobId) return sendResponse({ success: Boolean(controller) });
  try {
    await ensureServerSessionLoaded();
    if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) throw new Error('登录状态已失效');
    const response = await fetch(`${SERVER_CONFIG.baseUrl.replace(/\/+$/, '')}/api/audio-summary/jobs/${encodeURIComponent(jobId)}`, {
      method: 'DELETE',
      headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
    });
    const data = await readResponseData(response);
    if (!response.ok || data?.error) throw createServerApiError(data, JSON.stringify(data), '取消音频总结失败');
    await rememberAudioSummaryJob({ id: jobId, status: 'cancelled', acknowledged: true });
    return sendResponse({ success: true });
  } catch (error) {
    return sendResponse({ success: false, error: error.message || '取消音频总结失败', code: error.code });
  }
}

async function handleTranscribeVideoAudio(message, sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    return sendResponse({ success: false, error: '登录账户即可使用总结能力哟~' });
  }
  const tabId = Number(message?.tabId || 0);
  const bvid = String(message?.bvid || '');
  const cid = /^\d+$/.test(String(message?.cid || '')) ? String(message.cid) : '';
  const epId = /^\d+$/.test(String(message?.epId || '')) ? String(message.epId) : '';
  const requestId = String(message?.requestId || '');
  const audioUrls = [...new Set([...(Array.isArray(message?.audioUrls) ? message.audioUrls : []), message?.audioUrl]
    .map((url) => String(url || '').trim()).filter(isAllowedBilibiliAudioUrl))];
  if (!tabId || !isSupportedBilibiliMediaId(bvid) || (/^ep\d+$/.test(bvid) && !cid) || audioUrls.length === 0) {
    return sendResponse({ success: false, error: '音频地址无效，请刷新视频页面后重试' });
  }
  const transcriptionKey = audioTranscriptionKey(message);
  const controller = new AbortController();
  audioTranscriptionControllers.set(transcriptionKey, controller);
  let streamingProgressPolling = false;
  try {
    const requestStartedAt = Date.now();
    await chrome.tabs.get(tabId);
    if (/^ep\d+$/.test(bvid) && !(await isCurrentBilibiliClassroomMedia(tabId, bvid, cid))) {
      throw new Error('课堂课时已切换，请重新检测后重试');
    }
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const asrMode = message.asrMode === 'fast' ? 'fast' : 'slow';
    const cacheResponse = await fetch(`${serverUrl}/api/asr/transcriptions/status?bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(cid)}&mode=${asrMode}`, {
      headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }), signal: controller.signal,
    });
    const cached = await cacheResponse.json().catch(() => ({}));
    if (cacheResponse.ok && cached.status === 'completed' && cached.cached && cached.text) {
      return sendResponse({ success: true, requestId, bvid, cid, text: cached.text,
        segments: Array.isArray(cached.segments) ? cached.segments : [], cached: true, credits: 0 });
    }
    const chunkSize = 512 * 1024;
    const extractionStartedAt = Date.now();
    const uploadId = globalThis.crypto.randomUUID().replace(/-/g, '');
    let selectedAudioUrl = '';
    let firstChunk = null;
    let lastAudioStatus = 0;
    for (const candidateUrl of audioUrls) {
      let candidate = await fetchBilibiliMediaChunkDirect(candidateUrl, 0, chunkSize - 1);
      if (!candidate?.ok) candidate = await fetchBilibiliAudioChunkInPage(tabId, candidateUrl, bvid, 0, chunkSize - 1, cid, epId);
      if (candidate.ok && candidate.total > 0) {
        selectedAudioUrl = candidateUrl;
        firstChunk = candidate;
        break;
      }
      lastAudioStatus = Number(candidate.status || lastAudioStatus || 0);
      if (candidate.error === 'VIDEO_CHANGED') throw new Error('视频页面已切换，请返回原视频后重试');
    }
    if (!selectedAudioUrl || !firstChunk) {
      throw new Error(lastAudioStatus ? `音频提取失败（HTTP ${lastAudioStatus}）` : '音频提取失败，请刷新视频页面后重试');
    }
    const audioByteLength = Number(firstChunk.total || 0);
    if (!audioByteLength) throw new Error('提取到的音频为空');
    if (audioByteLength > 100 * 1024 * 1024) throw new Error('音频文件超过 100MB，暂时无法上传');
    const chunkTotal = Math.ceil(audioByteLength / chunkSize);
    const mimeType = String(message.audioMimeType || firstChunk.contentType || 'audio/mp4').split(';')[0];
    let extractionCompletedAt = 0;
    let data = null;
    let uploadedCount = 0;
    const UPLOAD_CONCURRENCY = 4;
    const uploadChunkHeaders = (chunkIndex, offset) => extensionRequestHeaders({
      Authorization: `Bearer ${SERVER_CONFIG.authToken}`,
      'Content-Type': mimeType,
      'X-Audio-Duration': String(Math.max(0, Number(message.duration || 0))),
      'X-ASR-Mode': asrMode,
      'X-ASR-Async': '1',
      'X-Video-Bvid': bvid,
      'X-Video-Cid': cid,
      'X-Video-Title': encodeURIComponent(String(message.title || '').slice(0, 200)),
      'X-Upload-Id': uploadId,
      'X-Chunk-Index': String(chunkIndex),
      'X-Chunk-Total': String(chunkTotal),
      'X-Chunk-Offset': String(offset),
    });
    const uploadChunkWithRetry = async (chunkIndex, offset, audioChunk) => {
      let lastError = null;
      for (let attempt = 0; attempt < 2; attempt += 1) {
        try {
          const response = await fetch(`${serverUrl}/api/asr/transcriptions`, {
            method: 'POST',
            signal: controller.signal,
            headers: uploadChunkHeaders(chunkIndex, offset),
            body: audioChunk,
          });
          const responseData = await readResponseData(response);
          if (responseData?.error) throw createServerApiError(responseData, JSON.stringify(responseData), '音频转写失败');
          if (!response.ok) throw createServerApiError(responseData, JSON.stringify(responseData), '音频转写失败');
          return responseData;
        } catch (error) {
          lastError = error;
          if (error?.name === 'AbortError') throw error;
          await new Promise((resolve) => setTimeout(resolve, 400));
        }
      }
      throw lastError;
    };
    void chrome.runtime.sendMessage({
      type: 'audioTranscriptionProgress', bvid, cid, phase: 'extracting', percent: 0,
      requestId,
    }).catch(() => {});
    if (asrMode === 'fast') {
      // 边传边转：上传期间轮询服务端段进度，展示「转写中 x/y」。
      streamingProgressPolling = true;
      void (async () => {
        while (streamingProgressPolling && !controller.signal.aborted) {
          await new Promise((resolve) => setTimeout(resolve, 2000));
          if (!streamingProgressPolling || controller.signal.aborted) return;
          try {
            const statusResponse = await fetch(`${serverUrl}/api/asr/transcriptions/status?bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(cid)}&mode=fast`, {
              headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
              signal: controller.signal,
            });
            const status = await statusResponse.json();
            if (status.status === 'transcribing' && Number.isFinite(status.transcribed) && Number.isFinite(status.total) && Number(status.total) > 0) {
              const total = Math.max(1, Number(status.total));
              void chrome.runtime.sendMessage({
                type: 'audioTranscriptionProgress', bvid, cid, phase: 'transcribing',
                transcribed: Math.max(0, Math.min(total, Number(status.transcribed))),
                total,
                requestId,
              }).catch(() => {});
            }
          } catch (_) {
            return;
          }
        }
      })();
    }
    // 并发上传：固定窗口 UPLOAD_CONCURRENCY 路，下载预取与上传流水线并行；
    // 末块以外的全部分块确认后才发末块（末块触发服务端转写/边传边转收尾）。
    const pendingFetches = [];
    let nextFetchIndex = 0;
    const prefetchChunks = () => {
      while (pendingFetches.length < UPLOAD_CONCURRENCY && nextFetchIndex < chunkTotal - 1) {
        const index = nextFetchIndex;
        nextFetchIndex += 1;
        const start = index * chunkSize;
        const end = Math.min(audioByteLength - 1, (index + 1) * chunkSize - 1);
        pendingFetches.push((async () => {
          let chunk = await fetchBilibiliMediaChunkDirect(selectedAudioUrl, start, end);
          if (!chunk?.ok) chunk = await fetchBilibiliAudioChunkInPage(tabId, selectedAudioUrl, bvid, start, end, cid, epId);
          return { index, chunk };
        })());
      }
    };
    prefetchChunks();
    const uploadWorker = async () => {
      while (pendingFetches.length) {
        const { index, chunk } = await pendingFetches.shift();
        if (!chunk?.ok) {
          if (chunk?.error === 'VIDEO_CHANGED') throw new Error('视频页面已切换，请返回原视频后重试');
          throw new Error(chunk?.status ? `音频提取失败（HTTP ${chunk.status}）` : '音频提取中断，请保持视频页面打开后重试');
        }
        const audioChunk = decodeBase64Bytes(chunk.base64);
        if (!audioChunk.byteLength) throw new Error('音频提取中断，请刷新视频页面后重试');
        await uploadChunkWithRetry(index, index * chunkSize, audioChunk);
        uploadedCount += 1;
        void chrome.runtime.sendMessage({
          type: 'audioTranscriptionProgress', bvid, cid, phase: 'extracting', requestId,
          percent: Math.round((uploadedCount / chunkTotal) * 100),
        }).catch(() => {});
        prefetchChunks();
      }
    };
    await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, Math.max(0, chunkTotal - 1)) }, () => uploadWorker()));
    const finalChunkIndex = chunkTotal - 1;
    const finalOffset = finalChunkIndex * chunkSize;
    const finalEnd = Math.min(audioByteLength - 1, chunkTotal * chunkSize - 1);
    let finalChunk = await fetchBilibiliMediaChunkDirect(selectedAudioUrl, finalOffset, finalEnd);
    if (!finalChunk?.ok) finalChunk = await fetchBilibiliAudioChunkInPage(tabId, selectedAudioUrl, bvid, finalOffset, finalEnd, cid, epId);
    if (!finalChunk?.ok) {
      if (finalChunk?.error === 'VIDEO_CHANGED') throw new Error('视频页面已切换，请返回原视频后重试');
      throw new Error(finalChunk?.status ? `音频提取失败（HTTP ${finalChunk.status}）` : '音频提取中断，请保持视频页面打开后重试');
    }
    const finalBytes = decodeBase64Bytes(finalChunk.base64);
    if (!finalBytes.byteLength) throw new Error('音频提取中断，请刷新视频页面后重试');
    void chrome.runtime.sendMessage({
      type: 'audioTranscriptionProgress', bvid, cid, phase: 'transcribing', percent: 100,
      requestId,
    }).catch(() => {});
    {
      let lastError = null;
      for (let attempt = 0; attempt < 2; attempt += 1) {
        let response = null;
        try {
          response = await fetch(`${serverUrl}/api/asr/transcriptions`, {
            method: 'POST',
            signal: controller.signal,
            headers: uploadChunkHeaders(finalChunkIndex, finalOffset),
            body: finalBytes,
          });
        } catch (error) {
          lastError = error;
          if (error?.name === 'AbortError') throw error;
          await new Promise((resolve) => setTimeout(resolve, 400));
          continue;
        }
        const queueAhead = Number(response.headers.get('X-ASR-Queue-Ahead') || 0);
        let stopQueuePolling = () => {};
        if (queueAhead > 0) {
          void chrome.runtime.sendMessage({
            type: 'audioTranscriptionProgress', bvid, cid, phase: 'queued', ahead: queueAhead,
            requestId,
          }).catch(() => {});
          let polling = true;
          stopQueuePolling = () => { polling = false; };
          void (async () => {
            while (polling && !controller.signal.aborted) {
              await new Promise((resolve) => setTimeout(resolve, 500));
              if (!polling || controller.signal.aborted) return;
              try {
                const statusResponse = await fetch(`${serverUrl}/api/asr/transcriptions/status?bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(cid)}&mode=${asrMode}`, {
                  headers: extensionRequestHeaders({ Authorization: `Bearer ${SERVER_CONFIG.authToken}` }),
                  signal: controller.signal,
                });
                const status = await statusResponse.json();
                const phase = status.status === 'active' ? 'transcribing' : status.status === 'queued' ? 'queued' : '';
                if (phase) {
                  void chrome.runtime.sendMessage({
                    type: 'audioTranscriptionProgress', bvid, cid, phase,
                    ahead: Number(status.ahead || 0), percent: phase === 'transcribing' ? 100 : undefined,
                    requestId,
                  }).catch(() => {});
                }
                if (status.status === 'active') return;
              } catch (_) {
                return;
              }
            }
          })();
        }
        try {
          const responseData = await readResponseData(response);
          if (responseData?.error) throw createServerApiError(responseData, JSON.stringify(responseData), '音频转写失败');
          if (!response.ok) throw createServerApiError(responseData, JSON.stringify(responseData), '音频转写失败');
          data = responseData;
          break;
        } catch (error) {
          lastError = error;
          stopQueuePolling();
          if (error?.name === 'AbortError') throw error;
          await new Promise((resolve) => setTimeout(resolve, 400));
        }
      }
      if (lastError && !data) throw lastError;
    }
    extractionCompletedAt ||= Date.now();
    // 异步协议：服务端已受理（202 accepted），转写在其后台继续，结果由侧栏
    // 直接轮询 status 端点取用（页面上下文不受扩展 SW 5 分钟生命周期限制）。
    if (data?.accepted === true) {
      return sendResponse({ success: true, async: true, requestId, bvid, cid });
    }
    const text = data?.text || data?.transcript || data?.data?.text || '';
    return sendResponse({
      success: true,
      requestId,
      bvid,
      cid,
      text,
      segments: Array.isArray(data?.segments) ? data.segments : [],
      timings: { browserExtractUploadMs: extractionCompletedAt - extractionStartedAt,
        server: data?.timings || {}, totalMs: Date.now() - requestStartedAt },
    });
  } catch (err) {
    if (err?.name === 'AbortError') {
      return sendResponse({ success: false, error: '音频转写排队已取消', code: 'ASR_QUEUE_CANCELLED' });
    }
    return sendResponse({ success: false, error: err.message || '音频转写失败', code: err.code });
  } finally {
    streamingProgressPolling = false;
    if (audioTranscriptionControllers.get(transcriptionKey) === controller) {
      audioTranscriptionControllers.delete(transcriptionKey);
    }
  }
}

async function handlePrepareKimiAudio(message, sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    return sendResponse({ success: false, error: '请先登录账号再使用 Kimi 音频总结' });
  }
  const tabId = Number(message?.tabId || 0);
  const bvid = String(message?.bvid || '');
  const cid = /^\d+$/.test(String(message?.cid || '')) ? String(message.cid) : '';
  const epId = /^\d+$/.test(String(message?.epId || '')) ? String(message.epId) : '';
  const requestId = String(message?.requestId || '');
  const audioUrls = [...new Set([...(Array.isArray(message?.audioUrls) ? message.audioUrls : []), message?.audioUrl]
    .map((url) => String(url || '').trim()).filter(isAllowedBilibiliAudioUrl))];
  if (!tabId || !isSupportedBilibiliMediaId(bvid) || !cid || audioUrls.length === 0) {
    return sendResponse({ success: false, error: '音频地址无效，请刷新视频页面后重试' });
  }
  try {
    await chrome.tabs.get(tabId);
    if (/^ep\d+$/.test(bvid) && !(await isCurrentBilibiliClassroomMedia(tabId, bvid, cid))) {
      throw new Error('课堂课时已切换，请重新点击 Kimi总结');
    }
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const chunkSize = 512 * 1024;
    const uploadId = globalThis.crypto.randomUUID().replace(/-/g, '');
    let selectedAudioUrl = '';
    let firstChunk = null;
    let lastAudioStatus = 0;
    for (const candidateUrl of audioUrls) {
      let candidate = await fetchBilibiliMediaChunkDirect(candidateUrl, 0, chunkSize - 1);
      if (!candidate?.ok) candidate = await fetchBilibiliAudioChunkInPage(tabId, candidateUrl, bvid, 0, chunkSize - 1, cid, epId);
      if (candidate.ok && candidate.total > 0) {
        selectedAudioUrl = candidateUrl;
        firstChunk = candidate;
        break;
      }
      lastAudioStatus = Number(candidate.status || lastAudioStatus || 0);
      if (candidate.error === 'VIDEO_CHANGED') throw new Error('视频页面已切换，请返回原视频后重试');
    }
    if (!selectedAudioUrl || !firstChunk) {
      throw new Error(lastAudioStatus ? `音频提取失败（HTTP ${lastAudioStatus}）` : '音频提取失败，请刷新视频页面后重试');
    }
    const audioByteLength = Number(firstChunk.total || 0);
    if (!audioByteLength) throw new Error('提取到的音频为空');
    if (audioByteLength > 100 * 1024 * 1024) throw new Error('音频文件超过 100MB，暂时无法处理');
    const chunkTotal = Math.ceil(audioByteLength / chunkSize);
    const mimeType = String(message.audioMimeType || firstChunk.contentType || 'audio/mp4').split(';')[0];
    let pageChunk = firstChunk;
    let nextChunkPromise = null;
    let prepared = null;
    for (let chunkIndex = 0; chunkIndex < chunkTotal; chunkIndex += 1) {
      const offset = chunkIndex * chunkSize;
      if (chunkIndex > 0) pageChunk = await nextChunkPromise;
      nextChunkPromise = chunkIndex + 1 < chunkTotal
        ? (async () => {
          const start = (chunkIndex + 1) * chunkSize;
          const end = Math.min(audioByteLength - 1, (chunkIndex + 2) * chunkSize - 1);
          let chunk = await fetchBilibiliMediaChunkDirect(selectedAudioUrl, start, end);
          if (!chunk?.ok) chunk = await fetchBilibiliAudioChunkInPage(tabId, selectedAudioUrl, bvid, start, end, cid, epId);
          return chunk;
        })()
        : null;
      if (!pageChunk?.ok) throw new Error('音频提取中断，请保持视频页面打开后重试');
      const audioChunk = decodeBase64Bytes(pageChunk.base64);
      if (!audioChunk.byteLength) throw new Error('音频提取中断，请刷新视频页面后重试');
      const finalChunk = chunkIndex === chunkTotal - 1;
      void chrome.runtime.sendMessage({
        type: 'kimiAudioPreparationProgress', requestId,
        phase: finalChunk ? 'compressing' : 'extracting',
        percent: finalChunk ? 80 : Math.round(((chunkIndex + 1) / chunkTotal) * 80),
      }).catch(() => {});
      const response = await fetch(`${serverUrl}/api/audio/kimi-preparations`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${SERVER_CONFIG.authToken}`,
          'Content-Type': mimeType,
          'X-Audio-Duration': String(Math.max(0, Number(message.duration || 0))),
          'X-Video-Bvid': bvid,
          'X-Video-Cid': cid,
          'X-Upload-Id': uploadId,
          'X-Chunk-Index': String(chunkIndex),
          'X-Chunk-Total': String(chunkTotal),
          'X-Chunk-Offset': String(offset),
        },
        body: audioChunk,
      });
      const data = await readResponseData(response);
      if (data?.error || !response.ok) throw createServerApiError(data, JSON.stringify(data), 'Kimi 音频压缩失败');
      if (finalChunk) prepared = data;
    }
    if (!prepared?.preparationId || !prepared?.size) throw new Error('Kimi 音频压缩结果无效');
    return sendResponse({ success: true, requestId, bvid, cid, ...prepared });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || 'Kimi 音频压缩失败', code: err.code });
  }
}

async function handleFetchPreparedKimiAudioChunk(message, sendResponse) {
  await ensureServerSessionLoaded();
  try {
    const preparationId = String(message?.preparationId || '');
    const start = Math.max(0, Number(message?.start || 0));
    const end = Math.max(start, Number(message?.end || start));
    if (!/^[a-f0-9]{48}$/i.test(preparationId)) throw new Error('压缩音频标识无效');
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const response = await fetch(`${serverUrl}/api/audio/kimi-preparations/${preparationId}`, {
      headers: { Authorization: `Bearer ${SERVER_CONFIG.authToken}`, Range: `bytes=${start}-${end}` },
    });
    if (!response.ok) {
      const data = await readResponseData(response);
      throw createServerApiError(data, JSON.stringify(data), '读取压缩音频失败');
    }
    const bytes = new Uint8Array(await response.arrayBuffer());
    const totalMatch = String(response.headers.get('content-range') || '').match(/\/(\d+)$/);
    return sendResponse({ success: true, data: { base64: encodeBase64Bytes(bytes), total: Number(totalMatch?.[1] || message?.total || bytes.length) } });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '读取压缩音频失败', code: err.code });
  }
}

async function handleDeletePreparedKimiAudio(message, sendResponse) {
  await ensureServerSessionLoaded();
  try {
    const preparationId = String(message?.preparationId || '');
    if (!/^[a-f0-9]{48}$/i.test(preparationId)) throw new Error('压缩音频标识无效');
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const response = await fetch(`${serverUrl}/api/audio/kimi-preparations/${preparationId}`, {
      method: 'DELETE', headers: { Authorization: `Bearer ${SERVER_CONFIG.authToken}` },
    });
    if (!response.ok && response.status !== 404) {
      const data = await readResponseData(response);
      throw createServerApiError(data, JSON.stringify(data), '清理压缩音频失败');
    }
    return sendResponse({ success: true });
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '清理压缩音频失败' });
  }
}

// opus 压缩直传（X-ASR-Async 协议）：侧栏把解码重编码后的 Ogg/Opus 分块经这里
// POST 给服务端，末块触发服务端后台转写。仅做鉴权转发，不做提取/转码。
async function handleUploadTranscribedAudioChunk(message, sendResponse) {
  await ensureServerSessionLoaded();
  try {
    if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) throw new Error('登录账户即可使用总结能力哟~');
    const bytes = decodeBase64Bytes(String(message?.bytes || ''));
    const uploadId = String(message?.uploadId || '');
    const chunkIndex = Math.max(0, Number(message?.chunkIndex || 0));
    const chunkTotal = Math.max(1, Number(message?.chunkTotal || 1));
    if (!bytes.byteLength || !uploadId) throw new Error('上传分块内容为空');
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const asrMode = message?.asrMode === 'fast' ? 'fast' : 'slow';
    const mimeType = String(message?.audioMimeType || 'audio/ogg').split(';')[0];
    const headers = extensionRequestHeaders({
      Authorization: `Bearer ${SERVER_CONFIG.authToken}`,
      'Content-Type': mimeType,
      'X-Audio-Duration': String(Math.max(0, Number(message?.duration || 0))),
      'X-Audio-Speed': String(Math.max(1, Number(message?.audioSpeed || 1))),
      'X-Audio-Preprocess': String(message?.audioPreprocess || '').slice(0, 40),
      'X-ASR-Mode': asrMode,
      'X-ASR-Async': '1',
      'X-Video-Bvid': String(message?.bvid || ''),
      'X-Video-Cid': String(message?.cid || ''),
      'X-Video-Title': encodeURIComponent(String(message?.title || '').slice(0, 200)),
      'X-Upload-Id': uploadId,
      'X-Chunk-Index': String(chunkIndex),
      'X-Chunk-Total': String(chunkTotal),
      'X-Chunk-Offset': String(Math.max(0, Number(message?.offset || 0))),
    });
    let lastError = null;
    for (let attempt = 0; attempt < 2; attempt += 1) {
      try {
        const response = await fetch(`${serverUrl}/api/asr/transcriptions`, {
          method: 'POST', headers, body: bytes,
        });
        const responseData = await readResponseData(response);
        if (responseData?.error) throw createServerApiError(responseData, JSON.stringify(responseData), '音频转写失败');
        if (!response.ok) throw createServerApiError(responseData, JSON.stringify(responseData), '音频转写失败');
        return sendResponse({ success: true, data: responseData });
      } catch (error) {
        lastError = error;
        await new Promise((resolve) => setTimeout(resolve, 400));
      }
    }
    throw lastError || new Error('上传失败');
  } catch (err) {
    return sendResponse({ success: false, error: err.message || '上传失败', code: err.code });
  }
}

// ============ 总结处理 ============
async function handleSummarize(message, sendResponse) {
  const { text, stylePrompt, bvid, title, owner, duration, factCheckResults, language } = message;
  const maxTokens = normalizeMaxOutputTokens(message.maxTokens);
  const locale = promptLocale(language);

  let systemPrompt = `${stylePrompt || locale.summary}\n\n${locale.summaryNoMetadata}\n\n${locale.mathFormatting}`;

  // 如果有事实核查结果，附加到提示词
  if (factCheckResults && factCheckResults.length > 0) {
    const verificationText = factCheckResults
      .map(
        (r, i) =>
          `[${locale.factCheck} ${i + 1}] ${r.claim}\n${language === 'en' ? 'Verdict' : (language === 'zh-TW' ? '結論' : '结论')}: ${r.verdict} (${r.confidence})\n${language === 'en' ? 'Sources' : (language === 'zh-TW' ? '來源' : '来源')}: ${r.sources?.join(', ') || (language === 'en' ? 'None' : (language === 'zh-TW' ? '無' : '无'))}`
      )
      .join('\n\n');

    const factInstruction = language === 'en'
      ? `Use these fact-check findings when relevant. Mark confidence with ✅ (${locale.verified}), ⚠️ (${locale.caution}), or ❌ (${locale.incorrect}).`
      : (language === 'zh-TW'
        ? `以下是影片重要觀點／數據的查核結果，請在摘要中適度引用，並以 ✅(${locale.verified}) ⚠️(${locale.caution}) ❌(${locale.incorrect}) 標示可信度。`
        : `以下是视频重要观点／数据的核查结果，请在总结中适度引用，并以 ✅(${locale.verified}) ⚠️(${locale.caution}) ❌(${locale.incorrect}) 标示可信度。`);
    systemPrompt += `\n\n## ${locale.factCheck}\n${verificationText}\n\n${factInstruction}`;
  }

  const userContent = `${locale.videoTitle}: ${title}\n${locale.creator}: ${owner}\n${locale.duration}: ${formatDuration(duration, language)}\n\n${locale.content}:\n${text}`;

  try {
    const result = await callProviderChat(systemPrompt, userContent, {
      action: 'summarize', bvid, title, maxTokens, streamRequestId: message.streamRequestId,
      summaryContext: message.summaryContext,
    });
    sendResponse({ success: true, summary: result });
  } catch (err) {
    sendResponse({ success: false, error: err.message, code: err.code });
  }
}

// ============ 辅助生成风格提示词 ============
async function handleAssistStyle(message, sendResponse) {
  const { text, language } = message;
  const lang = normalizeLanguage(language);
  const systemPrompt = lang === 'en'
    ? [
        'You turn what a user learned from watching videos into ONE concise style prompt for video summaries.',
        'The style prompt tells the summarizer how to organize output: tone, structure, level of detail, emphasis, etc.',
        'Output ONLY the style prompt itself — no quotes, no prefix, no explanation, no markdown — 100 characters or fewer.',
      ].join('\n')
    : (lang === 'zh-TW'
      ? '把用戶「透過看影片學到的東西」濃縮成一句簡潔的影片總結風格提示詞。風格提示詞用於指導 AI 如何組織總結（語氣、結構、詳略、強調重點等）。只輸出風格提示詞本身：不要引號、不要前綴、不要解釋、不要 Markdown，控制在 100 字以內。'
      : '把用户“通过看视频学到的东西”浓缩成一句简洁的视频总结风格提示词。风格提示词用于指导 AI 如何组织总结（语气、结构、详略、强调重点等）。只输出风格提示词本身：不要引号、不要前缀、不要解释、不要 Markdown，控制在 100 字以内。');

  try {
    const result = await callProviderChat(systemPrompt, String(text || '').trim(), {
      action: 'style-assist', maxTokens: 200,
    });
    sendResponse({ success: true, prompt: String(result || '').trim() });
  } catch (err) {
    sendResponse({ success: false, error: err.message, code: err.code });
  }
}

// ============ 追问处理 ============
async function handleFollowUp(message, sendResponse) {
  const { question, summary, transcript, history, bvid, title, owner, duration, language } = message;
  const locale = promptLocale(language);
  const maxTokens = normalizeMaxOutputTokens(message.maxTokens);

  const safeHistory = Array.isArray(history) ? history.slice(-8) : [];
  const historyText = safeHistory
    .map((item, i) => {
      const role = item.role === 'assistant' ? (language === 'en' ? 'Answer' : '回答') : (language === 'en' ? 'Question' : '追問');
      return '[' + (i + 1) + '] ' + role + ': ' + (item.content || '');
    })
    .join('\n\n');

  const systemPrompt = `${locale.followUpSystem}\n\n${locale.mathFormatting}`;

  const userContent = [
    locale.videoTitle + ': ' + (title || ''),
    locale.creator + ': ' + (owner || ''),
    locale.duration + ': ' + formatDuration(duration, language),
    '',
    '## ' + locale.generatedSummary,
    summary || '',
    '',
    '## ' + locale.transcript,
    transcript || '',
    '',
    historyText ? '## ' + locale.conversation + '\n' + historyText + '\n' : '',
    '## ' + locale.question,
    question || ''
  ].join('\n');

  try {
    const answer = await callProviderChat(systemPrompt, userContent, { action: 'followup', bvid, title, maxTokens, streamRequestId: message.streamRequestId });
    sendResponse({ success: true, answer });
  } catch (err) {
    sendResponse({ success: false, error: err.message, code: err.code });
  }
}

// ============ 事实核查处理 ============
async function handleFactCheck(message, sendResponse) {
  const { claims } = message;

  await ensureConfigLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    sendResponse({ success: false, error: '登录账户即可使用总结能力哟~' });
    return;
  }

  try {
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const resp = await fetch(`${serverUrl}/api/factcheck`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${SERVER_CONFIG.authToken}`,
      },
      body: JSON.stringify({ claims }),
    });
    if (!resp.ok) {
      const err = await resp.text();
      let errMsg = err;
      try {
        const j = JSON.parse(err);
        throw createServerApiError(j, err);
        errMsg = j.error || err;
      } catch (parseErr) {
        if (parseErr.code) throw parseErr;
      }
      throw new Error(errMsg);
    }
    const data = await resp.json();
    sendResponse({ success: true, results: data.results || [] });
  } catch (err) {
    sendResponse({ success: false, error: err.message });
  }
}

/**
 * 分析一条声明的可信度
 */
async function analyzeClaim(claim, searchData) {
  const answer = searchData.answer || '';
  const results = searchData.results || [];

  // 使用后端配置的模型分析声明
  const systemPrompt = `你是一个事实核查分析师。请根据搜索结果判断以下声明的真实性。

对每条声明，请输出 JSON 格式：
{
  "claim": "原始声明",
  "verdict": "已验证/部分正确/无法确认/有误",
  "confidence": "高/中/低",
  "sources": ["来源URL列表"],
  "detail": "简要说明判断依据（50字以内）"
}

只输出 JSON，不要其他内容。`;

  const userPrompt = `待核查声明: ${claim}

搜索结果:
${answer ? `AI 摘要: ${answer}\n` : ''}
${results.map((r, i) => `[${i + 1}] ${r.title}\n${r.url}\n${r.content?.substring(0, 200) || ''}`).join('\n\n')}`;

  try {
    const result = await callProviderChat(systemPrompt, userPrompt);
    // 尝试解析 JSON
    const jsonMatch = result.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[0]);
    }
  } catch (e) {
    // 解析失败，用简单逻辑判断
  }

  // 回退：简单关键词匹配
  const lowerAnswer = answer.toLowerCase();
  const lowerClaim = claim.toLowerCase();
  const hasMatch =
    lowerAnswer.includes(lowerClaim.substring(0, 20)) ||
    lowerClaim.includes(lowerAnswer.substring(0, 20));

  return {
    claim,
    verdict: hasMatch ? '部分正确' : '无法确认',
    confidence: '低',
    sources: results.slice(0, 3).map((r) => r.url),
    detail: '自动分析结果，建议人工核实',
  };
}

// ============ AI API 调用 ============
async function callProviderChat(systemPrompt, userContent, meta = {}) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    throw new Error('登录账户即可使用总结能力哟~');
  }
  return callServerLLM(systemPrompt, userContent, meta);
}

function extractProviderText(data) {
  if (!data) return '';
  if (typeof data === 'string') return looksLikeHtmlText(data) ? '' : data;
  if (typeof data.output_text === 'string') return data.output_text;

  const choice = data.choices?.[0];
  const content = choice?.message?.content ?? choice?.text;
  if (typeof content === 'string') return content;
  if (content && typeof content === 'object' && !Array.isArray(content)) {
    return content.text || content.content || content.value || '';
  }
  if (Array.isArray(content)) {
    return content
      .map((part) => {
        if (typeof part === 'string') return part;
        return part?.text || part?.output_text || part?.content || part?.value || '';
      })
      .join('')
      .trim();
  }

  if (Array.isArray(data.output)) {
    return data.output
      .flatMap((item) => Array.isArray(item?.content) ? item.content : [item])
      .map((part) => {
        if (typeof part === 'string') return part;
        return part?.text || part?.output_text || part?.content || part?.value || '';
      })
      .join('')
      .trim();
  }

  return '';
}

async function callServerLLM(systemPrompt, userContent, meta = {}) {
  if (!SERVER_CONFIG.baseUrl) throw new Error('未配置服务端地址');
  const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
  const maxTokens = normalizeMaxOutputTokens(meta.maxTokens);
  const streamRequested = !!meta.streamRequestId;
  const buildRequest = () => ({
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${SERVER_CONFIG.authToken}`,
    },
    body: JSON.stringify({
      action: meta.action || 'summarize',
      bvid: meta.bvid || '',
      title: meta.title || '',
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userContent },
      ],
      max_tokens: maxTokens,
      temperature: 0.3,
      stream: streamRequested,
      stream_options: streamRequested ? { include_usage: true } : undefined,
      summary_context: meta.summaryContext || undefined,
    }),
  });
  // 流式请求加两道看门狗：首包 2 分钟、后续每 2 分钟无增量即视为断流，
  // 避免服务端或上游卡住时侧栏永远停在「正在生成总结...」。
  const controller = streamRequested ? new AbortController() : null;
  let firstByteTimer = null;
  let idleTimer = null;
  const armFirstByteTimer = () => {
    if (!controller) return;
    clearTimeout(idleTimer);
    firstByteTimer = setTimeout(() => controller.abort(), STREAM_FIRST_BYTE_TIMEOUT_MS);
  };
  const armIdleTimer = () => {
    if (!controller) return;
    clearTimeout(firstByteTimer);
    idleTimer = setTimeout(() => controller.abort(), STREAM_IDLE_TIMEOUT_MS);
  };
  const toTimeoutError = (err) => {
    if (err?.name === 'AbortError') {
      const timeoutError = new Error('服务响应超时，请稍后重试');
      timeoutError.code = 'SERVER_TIMEOUT';
      return timeoutError;
    }
    return err;
  };
  armFirstByteTimer();
  let resp;
  try {
    resp = await fetch(`${serverUrl}/api/llm/chat/completions`, {
      ...buildRequest(),
      signal: controller?.signal,
    });
  } catch (err) {
    throw toTimeoutError(err);
  }
  clearTimeout(firstByteTimer);
  // 扩展里保存的令牌过期（如管理员改过密码）时，先从已打开的官网页恢复会话再重试一次。
  if (resp.status === 401) {
    await clearAuthenticatedSession();
    const pulled = await pullDashboardSession(true);
    if (pulled?.success) {
      try {
        resp = await fetch(`${serverUrl}/api/llm/chat/completions`, {
          ...buildRequest(),
          signal: controller?.signal,
        });
      } catch (err) {
        throw toTimeoutError(err);
      }
    }
  }
  if (!resp.ok) {
    const err = await resp.text();
    let errMsg = err;
    try {
      const j = JSON.parse(err);
      throw createServerApiError(j, err, 'API 调用失败');
      errMsg = j.error || err;
    } catch (parseErr) {
      if (parseErr.code) throw parseErr;
    }
    throw new Error(`API 调用失败: ${errMsg}`);
  }
  if (streamRequested) {
    const reader = resp.body?.getReader();
    if (!reader) throw new Error('浏览器不支持流式响应');
    const decoder = new TextDecoder();
    let buffered = '';
    let text = '';
    let usage = null;
    const emitLine = async (line) => {
      if (!line.trim()) return;
      const event = JSON.parse(line);
      if (event.type === 'error') throw createServerApiError(event, line, 'API 调用失败');
      if (event.type === 'delta' && event.delta) {
        text += event.delta;
        await chrome.runtime.sendMessage({ type: 'llmStreamChunk', requestId: meta.streamRequestId, delta: event.delta }).catch(() => {});
      }
      if (event.type === 'done') usage = event.usage || null;
    };
    try {
      while (true) {
        armIdleTimer();
        const { value, done } = await reader.read();
        clearTimeout(idleTimer);
        buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
        const lines = buffered.split('\n');
        buffered = lines.pop() || '';
        for (const line of lines) await emitLine(line);
        if (done) break;
      }
    } catch (err) {
      throw toTimeoutError(err);
    }
    if (buffered.trim()) await emitLine(buffered);
    if (usage) await chrome.storage.local.set({ lastQuotaRefresh: Date.now() });
    if (!text.trim()) throw new Error('模型返回内容为空，请检查服务端模型配置或上游响应格式');
    return text;
  }
  const data = await resp.json();
  if (typeof data === 'string' && looksLikeHtmlText(data)) {
    throw new Error(PROVIDER_CONFIG_ERROR_MESSAGE);
  }
  // 同步配额信息（服务端返回在 usage 中或直接透传）
  if (data.usage) {
    try {
      await chrome.storage.local.set({ lastQuotaRefresh: Date.now() });
    } catch {}
  }
  const text = extractProviderText(data);
  if (!text.trim()) throw new Error('模型返回内容为空，请检查服务端模型配置或上游响应格式');
  return text;
}

// ============ 收藏功能 ============
function sanitizeFilename(name) {
  const sanitized = String(name || '')
    .replace(/[\\/:*?"<>|]/g, '_')
    .replace(/[. ]+$/g, '')
    .trim()
    .substring(0, 80);
  if (!sanitized) return '未命名视频';
  return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(sanitized) ? '_' + sanitized : sanitized;
}

function showDownloadWhenComplete(downloadId) {
  const show = () => {
    try {
      chrome.downloads.show(downloadId).catch(() => {});
    } catch (e) {}
  };

  const onChanged = (delta) => {
    if (delta.id !== downloadId || delta.state?.current !== 'complete') return;
    chrome.downloads.onChanged.removeListener(onChanged);
    show();
  };

  try {
    chrome.downloads.onChanged.addListener(onChanged);
    setTimeout(() => {
      chrome.downloads.onChanged.removeListener(onChanged);
      show();
    }, 3010);
  } catch (e) {
    setTimeout(show, 600);
  }
}

async function handleSaveToCollection(message, sendResponse) {
  const { title, url, summary, folder } = message;
  try {
    const folderPath = (folder || 'collection').replace(/[\/\\]/g, '_');
    const extension = message.asMarkdown ? 'md' : 'txt';
    const filename = sanitizeFilename(title) + '.' + extension;
    const requestedFilename = folderPath + '/' + filename;
    const content = '视频链接: ' + url + '\n\n' + summary;
    const dataUrl = 'data:text/plain;charset=utf-8,' + encodeURIComponent(content);
    const downloadId = await chrome.downloads.download({
      url: dataUrl,
      filename: requestedFilename,
      saveAs: true,
    });
    showDownloadWhenComplete(downloadId);
    sendResponse({ success: true, downloadId: downloadId, filename: requestedFilename, shown: true });
  } catch (err) {
    sendResponse({ success: false, error: err.message });
  }
}

async function handleOpenCollection(message, sendResponse) {
  try {
    const cfg = await chrome.storage.local.get({ collectionFolder: 'collection' });
    const folder = (message?.folder || cfg.collectionFolder || 'collection').replace(/[\/\\]/g, '_');
    const id = await chrome.downloads.download({
      url: 'data:text/plain,',
      filename: folder + '/.keep',
      saveAs: false,
    });
    setTimeout(function() {
      chrome.downloads.show(id).catch(function() {});
    }, 500);
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err.message });
  }
}

// ============ 内容脚本注入 ============
async function handleInjectContentScript(message, sendResponse) {
  const { tabId } = message;
  try {
    await chrome.scripting.executeScript({
      target: { tabId },
      world: 'MAIN',
      files: ['content/playback-rate-main.js'],
    });
    await chrome.scripting.executeScript({
      target: { tabId },
      files: ['content/bilibili.js'],
    });
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err.message });
  }
}

// 内容脚本处于隔离世界，播放速度必须由主世界的 B 站播放器实例更新；
// 使用 executeScript 的 MAIN world，避免创建会被页面 CSP 拦截的内联 script。
async function handleSetPlaybackRateMainWorld(message, sender, sendResponse) {
  const tabId = sender?.tab?.id;
  const rate = Number(message?.rate);
  if (!tabId || !Number.isFinite(rate) || rate <= 0) {
    sendResponse({ success: false, applied: false, error: '无效的播放速度请求' });
    return;
  }
  try {
    const [injection] = await chrome.scripting.executeScript({
      target: { tabId },
      world: 'MAIN',
      func: (requestedRate) => {
        const player = window.player;
        if (!player || typeof player.setPlaybackRate !== 'function') return false;
        player.setPlaybackRate(requestedRate);
        return true;
      },
      args: [rate],
    });
    sendResponse({ success: true, applied: injection?.result === true });
  } catch (err) {
    sendResponse({ success: false, applied: false, error: err.message || '主世界播放器调用失败' });
  }
}

async function handleEnsurePlaybackRateMainWorld(message, sender, sendResponse) {
  const tabId = Number(message?.tabId || sender?.tab?.id || 0);
  if (!tabId) {
    sendResponse({ success: false, error: '缺少当前标签页 ID' });
    return;
  }
  try {
    await chrome.scripting.executeScript({
      target: { tabId },
      world: 'MAIN',
      files: ['content/playback-rate-main.js'],
    });
    sendResponse({ success: true });
  } catch (err) {
    sendResponse({ success: false, error: err.message || '主世界倍速桥加载失败' });
  }
}

// ============ 页面上下文 JSON 获取 ============
async function handleFetchJsonInPage(message, sender, sendResponse) {
  const tabId = message.tabId || sender?.tab?.id;
  const { url } = message;

  if (!tabId) {
    sendResponse({ success: false, error: '缺少当前标签页 ID' });
    return;
  }
  if (!url || !/^https:\/\/api\.bilibili\.com\//.test(url)) {
    sendResponse({ success: false, error: '不允许的请求地址' });
    return;
  }

  try {
    const [injection] = await chrome.scripting.executeScript({
      target: { tabId },
      world: 'MAIN',
      func: async (requestUrl) => {
        const resp = await fetch(requestUrl, {
          credentials: 'include',
          cache: 'no-store',
        });
        const text = await resp.text();
        return {
          ok: resp.ok,
          status: resp.status,
          text,
        };
      },
      args: [url],
    });

    const result = injection?.result;
    if (!result) {
      throw new Error('页面上下文未返回结果');
    }
    if (!result.ok) {
      throw new Error(`HTTP ${result.status}`);
    }

    sendResponse({ success: true, data: JSON.parse(result.text) });
  } catch (err) {
    console.warn('[BiliSummary] 页面上下文获取 JSON 失败:', err.message);
    sendResponse({ success: false, error: err.message });
  }
}

async function handleFetchClassroomSubtitleViewInPage(message, sender, sendResponse) {
  const tabId = message.tabId || sender?.tab?.id;
  const expected = {
    aid: /^\d+$/.test(String(message.aid || '')) ? String(message.aid) : '',
    cid: /^\d+$/.test(String(message.cid || '')) ? String(message.cid) : '',
    epId: /^\d+$/.test(String(message.epId || '')) ? String(message.epId) : '',
    seasonId: /^\d+$/.test(String(message.seasonId || '')) ? String(message.seasonId) : '',
  };
  if (!tabId || !expected.aid || !expected.cid || !expected.epId || !expected.seasonId) {
    sendResponse({ success: false, error: '课堂字幕身份无效' });
    return;
  }

  try {
    const [injection] = await chrome.scripting.executeScript({
      target: { tabId },
      world: 'MAIN',
      func: async (identity) => {
        const readIdentity = async () => {
          if (!/\/cheese\/play\//.test(location.pathname)) return null;
          let cheeseState = {};
          try {
            cheeseState = typeof window.__EduPlayPiniaState__ === 'string'
              ? JSON.parse(window.__EduPlayPiniaState__)
              : (window.__EduPlayPiniaState__ || {});
          } catch {}
          const cheeseIndex = cheeseState.index || {};
          const episodes = Array.isArray(cheeseIndex.epList)
            ? cheeseIndex.epList
            : (Array.isArray(cheeseIndex.epPlayList) ? cheeseIndex.epPlayList : []);
          let manifest = {};
          try {
            if (window.playerRaw && typeof window.playerRaw.getManifest === 'function') {
              manifest = await Promise.resolve(window.playerRaw.getManifest()) || {};
            }
          } catch {}
          const routeEpisodeId = location.pathname.match(/\/cheese\/play\/ep(\d+)/)?.[1] || '';
          const episodeId = String(manifest.episodeId || manifest.epId || routeEpisodeId || cheeseIndex.currentEp?.id || cheeseIndex.currentEp?.episodeId || '');
          const episode = episodes.find((item) => String(item.id || item.episodeId) === episodeId)
            || (String(cheeseIndex.currentEp?.id || cheeseIndex.currentEp?.episodeId) === episodeId ? cheeseIndex.currentEp : null);
          return {
            aid: String(episode?.aid || manifest.aid || ''),
            cid: String(episode?.cid || manifest.cid || ''),
            epId: episodeId,
            seasonId: String(manifest.seasonId || manifest.season_id || cheeseIndex.viewInfo?.season_id || episode?.seasonId || episode?.season_id || ''),
          };
        };
        const matches = (actual) => actual && ['aid', 'cid', 'epId', 'seasonId']
          .every((field) => actual[field] === identity[field]);
        const before = await readIdentity();
        if (!matches(before)) return { ok: false, error: 'VIDEO_CHANGED' };

        const url = new URL('https://api.bilibili.com/x/v2/subtitle/web/view');
        url.searchParams.set('oid', identity.cid);
        url.searchParams.set('pid', identity.aid);
        url.searchParams.set('type', '1');
        url.searchParams.set('context_ext', JSON.stringify({ video_type: 3 }));
        const response = await fetch(url.href, {
          credentials: 'include',
          cache: 'no-store',
        });
        if (!response.ok) return { ok: false, status: response.status };
        const bytes = new Uint8Array(await response.arrayBuffer());
        const after = await readIdentity();
        if (!matches(after)) return { ok: false, error: 'VIDEO_CHANGED' };
        let binary = '';
        for (let offset = 0; offset < bytes.length; offset += 0x8000) {
          binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
        }
        return { ok: true, base64: btoa(binary) };
      },
      args: [expected],
    });
    const result = injection?.result;
    if (!result?.ok) {
      throw new Error(result?.error === 'VIDEO_CHANGED'
        ? '课堂课时已切换，请重新检测字幕'
        : (result?.status ? `HTTP ${result.status}` : '页面上下文未返回课堂字幕'));
    }
    sendResponse({ success: true, base64: result.base64 });
  } catch (error) {
    console.debug('[BiliSummary] 页面上下文获取课堂字幕失败:', error.message);
    sendResponse({ success: false, error: error.message });
  }
}

async function handleGetBilibiliPageState(message, sender, sendResponse) {
  const tabId = message.tabId || sender?.tab?.id;
  if (!tabId) {
    sendResponse({ success: false, error: '缺少当前标签页 ID' });
    return;
  }

  try {
    const [injection] = await chrome.scripting.executeScript({
      target: { tabId },
      world: 'MAIN',
      func: async () => {
        const toNumber = (value) => {
          const num = Number(value);
          return Number.isFinite(num) && num > 0 ? num : null;
        };
        const url = new URL(location.href);
        const bvidFromUrl = (url.pathname.match(/\/video\/(BV[\w]+)/) || [])[1]
          || (/\/list\/watchlater\//.test(url.pathname) ? (url.searchParams.get('bvid') || '') : '');
        const cheeseRoute = url.pathname.match(/\/cheese\/play\/(ss|ep)(\d+)/);
        const bangumiRoute = url.pathname.match(/\/bangumi\/play\/(ss|ep)(\d+)/);
        const pageFromUrl = toNumber(url.searchParams.get('p')) || 1;

        const state = window.__INITIAL_STATE__ || {};
        const videoData = state.videoData || {};
        let cheeseState = {};
        try {
          cheeseState = typeof window.__EduPlayPiniaState__ === 'string'
            ? JSON.parse(window.__EduPlayPiniaState__)
            : (window.__EduPlayPiniaState__ || {});
        } catch {}
        const cheeseIndex = cheeseState.index || {};
        const cheeseView = cheeseIndex.viewInfo || {};
        const cheeseEpisodes = Array.isArray(cheeseIndex.epList)
          ? cheeseIndex.epList
          : (Array.isArray(cheeseIndex.epPlayList) ? cheeseIndex.epPlayList : []);
        const routeEpisode = cheeseRoute?.[1] === 'ep'
          ? cheeseEpisodes.find((episode) => String(episode.id || episode.episodeId) === cheeseRoute[2])
          : null;
        let playerManifest = {};
        try {
          if (window.playerRaw && typeof window.playerRaw.getManifest === 'function') {
            playerManifest = await Promise.resolve(window.playerRaw.getManifest()) || {};
          }
        } catch {}
        const manifestEpisodeId = toNumber(playerManifest.episodeId || playerManifest.epId);
        const manifestEpisode = manifestEpisodeId
          ? cheeseEpisodes.find((episode) => String(episode.id || episode.episodeId) === String(manifestEpisodeId))
          : null;
        const initialCheeseEpisode = manifestEpisode || routeEpisode || cheeseIndex.currentEp || null;
        let bangumiSeason = {};
        if (bangumiRoute) {
          try {
            const seasonUrl = new URL('https://api.bilibili.com/pgc/view/web/season');
            seasonUrl.searchParams.set(bangumiRoute[1] === 'ep' ? 'ep_id' : 'season_id', bangumiRoute[2]);
            const seasonResponse = await fetch(seasonUrl.href, { credentials: 'include', cache: 'no-store' });
            const seasonJson = await seasonResponse.json();
            if (seasonJson?.code === 0 && seasonJson.result) bangumiSeason = seasonJson.result;
          } catch {}
        }
        const bangumiEpisodes = [
          ...(Array.isArray(bangumiSeason.episodes) ? bangumiSeason.episodes : []),
          ...(Array.isArray(bangumiSeason.section)
            ? bangumiSeason.section.flatMap((section) => Array.isArray(section?.episodes) ? section.episodes : [])
            : []),
        ];
        const initialBangumiEp = state.epInfo || state.ep_info || null;
        const bangumiRouteEpisode = bangumiRoute?.[1] === 'ep'
          ? bangumiEpisodes.find((episode) => String(episode.ep_id || episode.id) === bangumiRoute[2])
          : null;
        const playInfo = window.__playinfo__ || {};
        const playData = playInfo.data || playInfo.result || playInfo;
        const videoInfo = playData.video_info || playData.videoInfo || {};
        const dash = playData.dash || videoInfo.dash || {};
        let audioItems = Array.isArray(dash.audio) ? dash.audio : [];
        let bilibiliLoggedIn = null;
        let bilibiliMid = 0;
        try {
          const navResponse = await fetch('https://api.bilibili.com/x/web-interface/nav', {
            credentials: 'include', cache: 'no-store',
          });
          const navData = await navResponse.json();
          bilibiliLoggedIn = navData?.code === 0 ? navData?.data?.isLogin === true : null;
          if (navData?.code === 0 && navData?.data?.isLogin) {
            bilibiliMid = Number(navData.data?.mid || 0);
          }
        } catch {}

        let playerInfo = {};
        try {
          if (window.player && typeof window.player.getVideoInfo === 'function') {
            playerInfo = window.player.getVideoInfo() || {};
          }
        } catch {}

        const initialBvid = videoData.bvid || state.bvid || '';
        // SPA 切换选集/合集后 __INITIAL_STATE__ 可能仍属于上一个 BV，不能再用
        // 它的 pages 把旧 cid 绑定到当前 URL。
        const pagesMatchUrl = !bvidFromUrl || !initialBvid || initialBvid === bvidFromUrl;
        const pages = pagesMatchUrl && Array.isArray(videoData.pages) ? videoData.pages : [];
        const cid = toNumber(playerManifest.cid) || toNumber(playerInfo.cid) || toNumber(playData.cid) || toNumber(videoInfo.cid);
        const cheeseEpisodeByCid = cheeseEpisodes.find((episode) => String(episode.cid) === String(cid));
        const cheeseEpisode = manifestEpisode || cheeseEpisodeByCid || initialCheeseEpisode;
        const bangumiEpisodeByCid = bangumiEpisodes.find((episode) => String(episode.cid) === String(cid));
        const bangumiEpisodeByState = bangumiEpisodes.find((episode) => (
          String(episode.ep_id || episode.id) === String(initialBangumiEp?.ep_id || initialBangumiEp?.id || '')
        ));
        const bangumiEpisode = bangumiRouteEpisode || bangumiEpisodeByCid || bangumiEpisodeByState
          || (bangumiRoute?.[1] === 'ss' ? bangumiEpisodes[0] : null);
        const cidFromUrl = /\/list\/watchlater\//.test(url.pathname) ? toNumber(url.searchParams.get('oid')) : null;
        const pageByCid = cid ? pages.find((page) => String(page.cid) === String(cid)) : null;
        const pageByUrl = cidFromUrl
          ? (pages.find((page) => String(page.cid) === String(cidFromUrl)) || null)
          : (pages.find((page) => Number(page.page) === pageFromUrl) || pages[pageFromUrl - 1] || null);
        // URL 的 p/oid 是用户当前选择；播放器对象在 SPA 切换瞬间可能仍是旧 cid。
        const currentPage = pageByUrl || pageByCid || null;
        const resolvedEpId = toNumber(bangumiEpisode?.ep_id || bangumiEpisode?.id)
          || toNumber(cheeseEpisode?.id || cheeseEpisode?.episodeId) || manifestEpisodeId;
        const resolvedBvid = bvidFromUrl || bangumiEpisode?.bvid || (cheeseRoute && resolvedEpId ? `ep${resolvedEpId}` : '')
          || playerInfo.bvid || playData.bvid || videoInfo.bvid || state.bvid || videoData.bvid || '';
        const resolvedCid = toNumber(bangumiEpisode?.cid) || toNumber(cheeseEpisode?.cid) || toNumber(currentPage?.cid) || cid || toNumber(videoData.cid);
        const resolvedAid = toNumber(bangumiEpisode?.aid) || toNumber(cheeseEpisode?.aid) || toNumber(playerManifest.aid) || toNumber(playerInfo.aid) || toNumber(playData.aid) || toNumber(videoInfo.aid) || toNumber(state.aid) || toNumber(videoData.aid) || toNumber(window.aid);
        const resolvedSeasonId = toNumber(bangumiSeason.season_id)
          || toNumber(playerManifest.seasonId || playerManifest.season_id || cheeseView.season_id || cheeseEpisode?.season_id || cheeseEpisode?.seasonId);
        let refreshedDuration = null;

        // The signed CDN URL in __playinfo__ can expire while a tab stays open.
        // Refresh it at action time in the signed-in Bilibili page context.
        if (cheeseRoute && resolvedAid && resolvedCid && resolvedEpId && resolvedSeasonId) {
          try {
            const playUrl = new URL('https://api.bilibili.com/pugv/player/web/playurl');
            playUrl.searchParams.set('avid', String(resolvedAid));
            playUrl.searchParams.set('cid', String(resolvedCid));
            playUrl.searchParams.set('qn', '80');
            playUrl.searchParams.set('fnver', '0');
            playUrl.searchParams.set('fnval', '4048');
            playUrl.searchParams.set('fourk', '1');
            playUrl.searchParams.set('season_id', String(resolvedSeasonId));
            playUrl.searchParams.set('ep_id', String(resolvedEpId));
            playUrl.searchParams.set('from_client', 'BROWSER');
            const playResponse = await fetch(playUrl.href, {
              credentials: 'include', cache: 'no-store',
            });
            const freshPlayInfo = await playResponse.json();
            const freshPlayData = freshPlayInfo?.data || freshPlayInfo?.result || {};
            const freshAudioItems = freshPlayInfo?.code === 0 && Array.isArray(freshPlayData?.dash?.audio)
              ? freshPlayData.dash.audio
              : [];
            if (freshAudioItems.length) audioItems = freshAudioItems;
            refreshedDuration = toNumber(freshPlayData.timelength) ? Number(freshPlayData.timelength) / 1000 : null;
          } catch {}
        } else if (bangumiRoute && resolvedBvid && resolvedCid && resolvedEpId) {
          try {
            const playUrl = new URL('https://api.bilibili.com/pgc/player/web/playurl');
            playUrl.searchParams.set('bvid', resolvedBvid);
            playUrl.searchParams.set('cid', String(resolvedCid));
            playUrl.searchParams.set('ep_id', String(resolvedEpId));
            playUrl.searchParams.set('qn', '80');
            playUrl.searchParams.set('fnver', '0');
            playUrl.searchParams.set('fnval', '4048');
            playUrl.searchParams.set('fourk', '1');
            const playResponse = await fetch(playUrl.href, { credentials: 'include', cache: 'no-store' });
            const freshPlayInfo = await playResponse.json();
            const freshPlayData = freshPlayInfo?.result || freshPlayInfo?.data || {};
            const freshAudioItems = freshPlayInfo?.code === 0 && Array.isArray(freshPlayData?.dash?.audio)
              ? freshPlayData.dash.audio
              : [];
            if (freshAudioItems.length) audioItems = freshAudioItems;
            refreshedDuration = toNumber(freshPlayData.timelength) ? Number(freshPlayData.timelength) / 1000 : null;
          } catch {}
        } else if (/^BV[\w]+$/.test(resolvedBvid) && resolvedCid) {
          try {
            const playUrl = new URL('https://api.bilibili.com/x/player/playurl');
            playUrl.searchParams.set('bvid', resolvedBvid);
            playUrl.searchParams.set('cid', String(resolvedCid));
            playUrl.searchParams.set('fnval', '4048');
            playUrl.searchParams.set('fourk', '1');
            const playResponse = await fetch(playUrl.href, {
              credentials: 'include', cache: 'no-store',
            });
            const freshPlayInfo = await playResponse.json();
            const freshAudioItems = freshPlayInfo?.code === 0 && Array.isArray(freshPlayInfo?.data?.dash?.audio)
              ? freshPlayInfo.data.dash.audio
              : [];
            if (freshAudioItems.length) audioItems = freshAudioItems;
          } catch {}
        }

        const preferredAudio = audioItems.slice().sort((a, b) => Number(a.bandwidth || 0) - Number(b.bandwidth || 0))[0] || null;
        const preferredAudioUrls = preferredAudio
          ? [...new Set([
            preferredAudio.baseUrl,
            preferredAudio.base_url,
            ...(Array.isArray(preferredAudio.backupUrl) ? preferredAudio.backupUrl : []),
            ...(Array.isArray(preferredAudio.backup_url) ? preferredAudio.backup_url : []),
          ].filter(Boolean))]
          : [];

        return {
          bvid: resolvedBvid,
          aid: resolvedAid,
          cid: resolvedCid,
          duration: refreshedDuration || (bangumiEpisode?.duration ? Number(bangumiEpisode.duration) / 1000 : null) || toNumber(cheeseEpisode?.duration) || toNumber(currentPage?.duration) || toNumber(playerInfo.duration) || toNumber(videoData.duration),
          page: toNumber(bangumiEpisode?.title) || toNumber(cheeseEpisode?.index) || toNumber(currentPage?.page) || pageFromUrl,
          part: bangumiEpisode?.long_title || bangumiEpisode?.show_title || cheeseEpisode?.title || currentPage?.part || '',
          pages: (bangumiRoute ? bangumiEpisodes : cheeseEpisodes).map((episode, index) => ({
            cid: toNumber(episode.cid),
            page: toNumber(episode.title || episode.index) || index + 1,
            part: episode.long_title || episode.show_title || episode.title || '',
            duration: bangumiRoute && episode.duration ? Number(episode.duration) / 1000 : toNumber(episode.duration),
          })).filter((episode) => episode.cid),
          title: bangumiEpisode?.long_title || bangumiEpisode?.show_title || cheeseEpisode?.title || videoData.title || bangumiSeason.title || document.title.replace(/_哔哩哔哩.*$/, ''),
          owner: bangumiSeason.up_info?.uname || cheeseView.up_info?.uname || videoData.owner?.name || '',
          pic: bangumiEpisode?.cover || bangumiSeason.cover || cheeseEpisode?.cover || cheeseView.cover || videoData.pic || '',
          epId: resolvedEpId,
          seasonId: resolvedSeasonId,
          pageType: cheeseRoute ? 'cheese' : (bangumiRoute ? 'bangumi' : 'video'),
          bilibiliLoggedIn,
          bilibiliMid,
          audioUrl: preferredAudioUrls[0] || '',
          audioUrls: preferredAudioUrls,
          audioMimeType: preferredAudio?.mimeType || preferredAudio?.mime_type || 'audio/mp4',
          source: bangumiEpisode ? 'bangumi-season' : (manifestEpisode ? 'player-manifest' : (pageByCid ? 'player-cid' : (pageByUrl ? 'url-page' : 'page-state'))),
        };
      },
    });

    const data = injection?.result;
    if (!data?.bvid && !data?.cid) {
      throw new Error('页面播放状态为空');
    }
    if (Number(data.bilibiliMid) > 0) cacheBilibiliMid(data.bilibiliMid);
    sendResponse({ success: true, data });
  } catch (err) {
    console.warn('[BiliSummary] 页面播放状态获取失败:', err.message);
    sendResponse({ success: false, error: err.message });
  }
}

function bilibiliMediaUrls(item) {
  return [...new Set([
    ...(Array.isArray(item?.urls) ? item.urls : []),
    item?.baseUrl, item?.base_url,
    ...(Array.isArray(item?.backupUrl) ? item.backupUrl : []),
    ...(Array.isArray(item?.backup_url) ? item.backup_url : []),
  ].filter((url) => /^https:\/\/[^/]+\.(?:bilivideo\.com|bilivideo\.cn|hdslb\.com)(?::\d+)?\//i.test(String(url))))];
}

async function handleGetBilibiliToolboxData(message, sendResponse) {
  const tabId = Number(message.tabId);
  const bvid = String(message.bvid || '');
  const cid = String(message.cid || '');
  const quality = [80, 64, 32, 16].includes(Number(message.quality)) ? Number(message.quality) : 80;
  if (!tabId || !/^BV[a-zA-Z0-9]+$/.test(bvid) || !/^\d+$/.test(cid)) {
    sendResponse({ success: false, error: '视频信息无效' });
    return;
  }
  try {
    const [injection] = await chrome.scripting.executeScript({
      target: { tabId }, world: 'MAIN',
      func: async (requestedBvid, requestedCid, requestedQuality) => {
        const requestJson = async (url) => {
          const response = await fetch(url, { credentials: 'include', cache: 'no-store' });
          if (!response.ok) throw new Error(`HTTP ${response.status}`);
          const json = await response.json();
          if (json?.code !== 0) throw new Error(json?.message || `B站接口错误 ${json?.code}`);
          return json.data;
        };
        const createPlayUrl = (fnval) => {
          const url = new URL('https://api.bilibili.com/x/player/playurl');
          url.searchParams.set('bvid', requestedBvid);
          url.searchParams.set('cid', requestedCid);
          url.searchParams.set('qn', String(requestedQuality));
          url.searchParams.set('fnval', String(fnval));
          url.searchParams.set('fnver', '0');
          url.searchParams.set('fourk', '1');
          url.searchParams.set('high_quality', '1');
          return url.href;
        };
        const [dashPlay, progressivePlay, nav] = await Promise.all([
          requestJson(createPlayUrl(4048)).catch(() => null),
          requestJson(createPlayUrl(1)).catch(() => null),
          requestJson('https://api.bilibili.com/x/web-interface/nav').catch(() => null),
        ]);
        const normalizeUrls = (item) => [...new Set([
          item?.baseUrl, item?.base_url,
          ...(Array.isArray(item?.backupUrl) ? item.backupUrl : []),
          ...(Array.isArray(item?.backup_url) ? item.backup_url : []),
        ].filter(Boolean))];
        const audios = Array.isArray(dashPlay?.dash?.audio) ? dashPlay.dash.audio : [];
        const progressiveSegments = Array.isArray(progressivePlay?.durl) ? progressivePlay.durl : [];
        const availableQualities = [...new Set([
          ...(Array.isArray(progressivePlay?.accept_quality) ? progressivePlay.accept_quality : []),
          progressivePlay?.quality,
        ].map(Number).filter((value) => [80, 64, 32, 16].includes(value)))];
        const targetAudio = audios.slice().sort((a, b) => Number(b.bandwidth || 0) - Number(a.bandwidth || 0))[0] || null;
        return {
          loggedIn: nav?.isLogin === true,
          mid: Number(nav?.mid || 0),
          quality: Number(progressivePlay?.quality || 0),
          availableQualities,
          video: progressiveSegments.length ? {
            segments: progressiveSegments.map((segment) => ({
              urls: [...new Set([segment.url, ...(Array.isArray(segment.backup_url) ? segment.backup_url : [])].filter(Boolean))],
            })),
            mimeType: String(progressivePlay?.format || '').includes('flv') ? 'video/x-flv' : 'video/mp4',
          } : null,
          audio: targetAudio ? { urls: normalizeUrls(targetAudio), mimeType: targetAudio.mimeType || targetAudio.mime_type || 'audio/mp4' } : null,
        };
      },
      args: [bvid, cid, quality],
    });
    const data = injection?.result;
    if (!data) throw new Error('B站页面未返回下载信息');
    if (data.mid > 0) cacheBilibiliMid(data.mid);
    if (data.video) {
      data.video.segments = (data.video.segments || []).map((segment) => ({ urls: bilibiliMediaUrls(segment) })).filter((segment) => segment.urls.length);
    }
    if (data.audio) data.audio.urls = bilibiliMediaUrls(data.audio);
    sendResponse({ success: true, data });
  } catch (err) {
    sendResponse({ success: false, error: err.message || '读取下载信息失败' });
  }
}

async function handleFetchBilibiliMediaChunk(message, sendResponse) {
  const tabId = Number(message.tabId);
  const bvid = String(message.bvid || '');
  const cid = /^\d+$/.test(String(message.cid || '')) ? String(message.cid) : '';
  const epId = /^\d+$/.test(String(message.epId || '')) ? String(message.epId) : '';
  const url = String(message.url || '');
  const start = Math.max(0, Number(message.start || 0));
  const end = Math.max(start, Number(message.end || start));
  if (!tabId || !/^BV[a-zA-Z0-9]+$/.test(bvid) || end - start >= 4 * 1024 * 1024 || !bilibiliMediaUrls({ baseUrl: url }).length) {
    sendResponse({ success: false, error: '媒体分块请求无效' });
    return;
  }
  try {
    let result = await fetchBilibiliMediaChunkDirect(url, start, end);
    if (!result?.ok) result = await fetchBilibiliAudioChunkInPage(tabId, url, bvid, start, end, cid, epId);
    if (!result?.ok || !result.base64) throw new Error(result?.error || `媒体读取失败 HTTP ${result?.status || 0}`);
    sendResponse({ success: true, data: result });
  } catch (err) {
    sendResponse({ success: false, error: err.message || '媒体读取失败' });
  }
}

const VIDEO_STATS_QUEUE_STORAGE_KEY = 'videoStatsSyncQueueV1';
const VIDEO_STATS_LAST_ATTEMPT_STORAGE_KEY = 'videoStatsLastAttemptAtV1';
const VIDEO_STATS_LAST_SUCCESS_STORAGE_KEY = 'videoStatsLastSuccessAtV1';
const VIDEO_STATS_SYNC_INTERVAL_MS = 60_000;
const VIDEO_STATS_QUEUE_MAX_AGE_MS = 7 * 24 * 60 * 60_000;
const VIDEO_STATS_QUEUE_MAX_ITEMS = 200;
let videoStatsQueueWork = Promise.resolve();

function shanghaiDateKey(now = new Date()) {
  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(now);
  const date = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]));
  return `${date.year}-${date.month}-${date.day}`;
}

function normalizeVideoStatsPayload(payload = {}) {
  const bvid = String(payload.bvid || '').trim();
  if (!/^BV[a-zA-Z0-9]+$/.test(bvid)) return null;
  const clampSeconds = (value) => Math.max(0, Math.min(120, Math.round(Number(value || 0))));
  const stats = {
    bvid,
    cid: String(payload.cid || '').trim().slice(0, 32),
    title: String(payload.title || '').slice(0, 240),
    owner: String(payload.owner || '').slice(0, 120),
    duration: Math.max(0, Math.min(86400, Math.round(Number(payload.duration || 0)))),
    subtitle_chars: Math.max(0, Math.min(500000, Math.round(Number(payload.subtitle_chars || 0)))),
    visible_seconds_delta: clampSeconds(payload.visible_seconds_delta),
    playing_seconds_delta: clampSeconds(payload.playing_seconds_delta),
  };
  return stats.visible_seconds_delta || stats.playing_seconds_delta || stats.duration ? stats : null;
}

function trimVideoStatsQueue(queue) {
  return Object.fromEntries(Object.entries(queue)
    .filter(([, item]) => item?.updatedAt > Date.now() - VIDEO_STATS_QUEUE_MAX_AGE_MS)
    .sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
    .slice(0, VIDEO_STATS_QUEUE_MAX_ITEMS));
}

async function queueAndMaybeSyncVideoStats(stats) {
  const userId = String(SERVER_CONFIG.user?.id || '');
  if (!userId) return { ignored: true, error: 'not logged in' };

  const now = Date.now();
  const stored = await chrome.storage.local.get({
    [VIDEO_STATS_QUEUE_STORAGE_KEY]: {},
    [VIDEO_STATS_LAST_ATTEMPT_STORAGE_KEY]: 0,
    [VIDEO_STATS_LAST_SUCCESS_STORAGE_KEY]: 0,
  });
  const queue = trimVideoStatsQueue(stored[VIDEO_STATS_QUEUE_STORAGE_KEY] || {});
  const key = `${userId}:${stats.bvid}:${stats.cid}:${shanghaiDateKey()}`;
  const previous = queue[key]?.stats || {};
  queue[key] = {
    userId,
    updatedAt: now,
    stats: {
      ...stats,
      title: stats.title || previous.title || '',
      owner: stats.owner || previous.owner || '',
      duration: Math.max(Number(previous.duration || 0), stats.duration),
      subtitle_chars: Math.max(Number(previous.subtitle_chars || 0), stats.subtitle_chars),
      visible_seconds_delta: Math.min(86400, Number(previous.visible_seconds_delta || 0) + stats.visible_seconds_delta),
      playing_seconds_delta: Math.min(86400, Number(previous.playing_seconds_delta || 0) + stats.playing_seconds_delta),
    },
  };

  const lastAttemptAt = Number(stored[VIDEO_STATS_LAST_ATTEMPT_STORAGE_KEY] || 0);
  if (now - lastAttemptAt < VIDEO_STATS_SYNC_INTERVAL_MS) {
    await chrome.storage.local.set({ [VIDEO_STATS_QUEUE_STORAGE_KEY]: queue });
    return { queued: true, synced: false };
  }

  const batchKeys = Object.keys(queue).filter((queueKey) => queue[queueKey]?.userId === userId);
  const batch = batchKeys.map((queueKey) => queue[queueKey].stats);
  await chrome.storage.local.set({
    [VIDEO_STATS_QUEUE_STORAGE_KEY]: queue,
    [VIDEO_STATS_LAST_ATTEMPT_STORAGE_KEY]: now,
  });
  if (!batch.length) return { queued: true, synced: false };

  try {
    const serverUrl = SERVER_CONFIG.baseUrl.replace(/\/+$/, '');
    const resp = await fetch(`${serverUrl}/api/video-stats/batch`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${SERVER_CONFIG.authToken}` },
      body: JSON.stringify({ stats: batch }),
    });
    const data = await readResponseData(resp);
    if (!resp.ok) throw new Error(data.error || ('HTTP ' + resp.status));
    batchKeys.forEach((queueKey) => delete queue[queueKey]);
    await chrome.storage.local.set({
      [VIDEO_STATS_QUEUE_STORAGE_KEY]: queue,
      [VIDEO_STATS_LAST_SUCCESS_STORAGE_KEY]: Date.now(),
    });
    return { queued: false, synced: true };
  } catch (err) {
    console.debug('[BiliSummary] watch stats kept locally for retry:', err.message);
    return { queued: true, synced: false };
  }
}

async function handleReportVideoWatchStats(message, sendResponse) {
  await ensureServerSessionLoaded();
  if (!SERVER_CONFIG.baseUrl || !SERVER_CONFIG.authToken) {
    return sendResponse({ success: false, ignored: true, error: 'not logged in' });
  }

  const stats = normalizeVideoStatsPayload(message.stats);
  if (!stats) return sendResponse({ success: true, ignored: true });
  const work = videoStatsQueueWork.then(() => queueAndMaybeSyncVideoStats(stats));
  videoStatsQueueWork = work.catch(() => {});
  try {
    const result = await work;
    sendResponse(result.ignored ? { success: false, ignored: true, error: result.error } : { success: true, ...result });
  } catch (err) {
    sendResponse({ success: false, error: err.message || 'watch stats queue failed' });
  }
}

async function handleFetchBilibiliJson(message, sendResponse) {
  const { url } = message;

  if (!url || !/^https:\/\/api\.bilibili\.com\//.test(url)) {
    sendResponse({ success: false, error: '不允许的请求地址' });
    return;
  }

  try {
    const resp = await fetch(url, {
      credentials: 'include',
      cache: 'no-store',
    });
    if (!resp.ok) {
      throw new Error('HTTP ' + resp.status);
    }
    sendResponse({ success: true, data: await resp.json() });
  } catch (err) {
    console.warn('[BiliSummary] background 获取 Bilibili JSON 失败:', err.message);
    sendResponse({ success: false, error: err.message });
  }
}

// ============ 字幕内容获取 (CDN 代理) ============
async function handleFetchSubtitle(message, sendResponse) {
  const { url, referer } = message;
  try {
    const resp = await fetch(url, {
      cache: 'no-store',
      credentials: 'include',
      headers: {
        Referer: referer || 'https://www.bilibili.com/',
      },
    });
    if (!resp.ok) {
      throw new Error(`HTTP ${resp.status}`);
    }
    const data = await resp.json();

    let items = [];
    if (data.body && Array.isArray(data.body)) {
      items = data.body;
    } else if (data.subtitles && Array.isArray(data.subtitles)) {
      // 兼容其他格式
      items = data.subtitles;
    }

    const result = items.map((item) => ({
      from: item.from || item.startTime || 0,
      to: item.to || item.endTime || 0,
      content: item.content || item.text || '',
    }));

    console.log(`[BiliSummary] background 获取字幕成功，共 ${result.length} 条`);
    sendResponse({ success: true, data: result });
  } catch (err) {
    console.warn('[BiliSummary] background 获取字幕失败:', err.message);
    sendResponse({ success: false, error: err.message });
  }
}

// ============ 工具函数 ============
function formatDuration(seconds, language = 'zh-CN') {
  const lang = normalizeLanguage(language);
  if (!seconds) return lang === 'en' ? 'Unknown' : (lang === 'zh-TW' ? '未知' : '未知');
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  if (lang === 'en') return h > 0 ? `${h}h ${m}m ${s}s` : `${m}m ${s}s`;
  if (lang === 'zh-TW') return h > 0 ? `${h} 小時 ${m} 分 ${s} 秒` : `${m} 分 ${s} 秒`;
  if (h > 0) return `${h}小时${m}分${s}秒`;
  return `${m}分${s}秒`;
}

console.log('[BiliSummary] 后台服务已启动');
