/**
 * Bilibili 视频信息提取 - 内容脚本
 * 从页面提取视频信息、字幕、流地址
 */

(function () {
  'use strict';

  // 普通页面加载只注入一次；扩展重载会销毁内容脚本的隔离世界与标志位，
  // 后台重新注入时不会命中此守卫，可正常恢复。已注入过就一律跳过——
  // 否则后台 onInstalled/onStartup 的强制重注入会在同页累积多个消息监听器，
  // 一条消息被多个监听器各执行一次，发布评论会变成重复评论。
  const forcedInjectionNonce = window.__biliSummaryForceInjectionNonce;
  if (window.__biliSummaryInjected) return;
  window.__biliSummaryInjected = forcedInjectionNonce || true;
  if (forcedInjectionNonce) document.getElementById('bili-summary-sidepanel-entry-host')?.remove();

  function isExtensionContextAlive() {
    try { return !!(chrome.runtime && chrome.runtime.id); } catch { return false; }
  }

  async function runtimeSendMessage(message) {
    if (!isExtensionContextAlive()) {
      throw new Error('Extension context invalidated');
    }
    return chrome.runtime.sendMessage(message);
  }

  function showSummaryCompleteToast() {
    const id = 'bili-summary-complete-toast';
    const existing = document.getElementById(id);
    if (existing) existing.remove();

    const toast = document.createElement('div');
    toast.id = id;
    toast.textContent = '\u603b\u7ed3\u5b8c\u6210';
    Object.assign(toast.style, {
      position: 'fixed',
      left: '50%',
      top: '50%',
      transform: 'translate(-50%, -50%)',
      zIndex: '2147483647',
      padding: '14px 22px',
      borderRadius: '8px',
      background: 'rgba(24, 25, 28, 0.92)',
      color: '#fff',
      fontSize: '16px',
      fontWeight: '600',
      lineHeight: '1',
      boxShadow: '0 10px 28px rgba(0, 0, 0, 0.24)',
      pointerEvents: 'none',
      opacity: '0',
      transition: 'opacity 160ms ease, transform 160ms ease',
    });
    document.documentElement.appendChild(toast);
    requestAnimationFrame(() => {
      toast.style.opacity = '1';
      toast.style.transform = 'translate(-50%, -50%) scale(1.02)';
    });
    setTimeout(() => {
      toast.style.opacity = '0';
      toast.style.transform = 'translate(-50%, -50%) scale(0.98)';
      setTimeout(() => toast.remove(), 220);
    }, 1700);
  }

  function showFloatingButtonHint(message) {
    const id = 'bili-summary-floating-hint';
    const existing = document.getElementById(id);
    if (existing) existing.remove();
    const hint = document.createElement('div');
    hint.id = id;
    hint.textContent = message;
    Object.assign(hint.style, {
      position: 'fixed',
      right: '18px',
      top: 'auto',
      bottom: '72px',
      zIndex: '2147483647',
      maxWidth: '220px',
      padding: '8px 10px',
      borderRadius: '8px',
      background: 'rgba(24, 25, 28, 0.9)',
      color: '#fff',
      fontSize: '12px',
      lineHeight: '1.45',
      boxShadow: '0 10px 28px rgba(0,0,0,0.18)',
      pointerEvents: 'none',
    });
    document.documentElement.appendChild(hint);
    setTimeout(() => hint.remove(), 2400);
  }

  const FLOATING_BUTTON_ENABLED_KEY = 'biliSummaryFloatingButtonEnabled';

  async function initFloatingSidePanelButton() {
    const hostId = 'bili-summary-sidepanel-entry-host';
    if (document.getElementById(hostId)) return;

    const hiddenKey = 'biliSummaryFloatingButtonHidden';
    try {
      const setting = await chrome.storage.local.get({ [FLOATING_BUTTON_ENABLED_KEY]: true });
      if (!setting[FLOATING_BUTTON_ENABLED_KEY]) return;
    } catch (e) {
      return;
    }
    try {
      if (window.sessionStorage.getItem(hiddenKey) === '1') return;
    } catch (e) {}
    if (document.getElementById(hostId)) return;

    const host = document.createElement('div');
    host.id = hostId;
    const shadow = host.attachShadow({ mode: 'closed' });
    const style = document.createElement('style');
    style.textContent = `
        :host {
          all: initial;
          position: fixed;
          right: 0;
          top: 50%;
          bottom: auto;
          transform: translateY(-50%);
          z-index: 2147483647;
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
        }
        .wrap {
          position: relative;
          width: 42px;
          height: 42px;
        }
        .open {
          position: absolute;
          right: 0;
          top: 3px;
          width: 34px;
          height: 34px;
          border: 0;
          border-radius: 12px 0 0 12px;
          background: transparent;
          cursor: grab;
          padding: 0;
          opacity: 0.88;
          touch-action: none;
          user-select: none;
          -webkit-user-drag: none;
        }
        .open:hover { opacity: 1; }
        .open.dragging { cursor: grabbing; }
        .open:focus-visible {
          outline: 2px solid rgba(251, 114, 153, 0.38);
          outline-offset: 2px;
        }
        .mark {
          display: block; width: 34px; height: 34px; border-radius: 12px 0 0 12px;
          pointer-events: none; user-select: none; -webkit-user-drag: none;
        }
        .close {
          position: absolute;
          left: -4px;
          bottom: -2px;
          width: 16px;
          height: 16px;
          border: 0;
          border-radius: 999px;
          background: rgba(174, 178, 184, 0.92);
          color: #fff;
          cursor: pointer;
          font-size: 13px;
          line-height: 16px;
          font-weight: 800;
          padding: 0;
          opacity: 0;
          pointer-events: none;
          transition: opacity 120ms ease;
        }
        .close.visible { opacity: 1; pointer-events: auto; }
        .close:hover {
          background: rgba(148, 153, 160, 0.96);
        }
        .hint {
          position: absolute; right: 42px; top: 6px; width: max-content;
          padding: 6px 9px; border-radius: 6px;
          background: rgba(24, 25, 28, 0.94); color: #fff;
          font-size: 12px; line-height: 1.4; pointer-events: none;
          opacity: 0; visibility: hidden; transition: opacity 60ms ease;
        }
        .wrap:hover .hint, .open:focus-visible + .hint { opacity: 1; visibility: visible; }
        :host([data-edge="left"]) .hint { left: 42px; right: auto; }
        :host([data-edge="left"]) .open,
        :host([data-edge="left"]) .mark { border-radius: 0 12px 12px 0; }
        :host([data-edge="left"]) .open { left: 0; right: auto; }
        :host([data-edge="left"]) .close { left: auto; right: -4px; }
    `;
    const wrap = document.createElement('div');
    wrap.className = 'wrap';
    wrap.innerHTML = `
        <button class="open" type="button" title="打开 bilibili视频总结侧栏" aria-label="打开 bilibili视频总结侧栏">
          <img class="mark" src="${chrome.runtime.getURL('icons/icon48.png')}" alt="" draggable="false" />
        </button>
        <span class="hint" role="tooltip">打开bilibili视频总结侧栏</span>
        <button class="close" type="button" title="关闭悬浮入口" aria-label="关闭悬浮入口">×</button>
      `;
    shadow.append(style, wrap);
    document.documentElement.appendChild(host);

    const openBtn = wrap.querySelector('.open');
    const closeBtn = wrap.querySelector('.close');
    const hint = wrap.querySelector('.hint');
    const positionKey = 'biliSummaryFloatingButtonPosition';
    const hostSize = 42;
    let dragState = null;
    let suppressOpenClick = false;
    let closeRevealTimer = 0;
    let sidePanelOpen = false;

    function setSidePanelOpen(open) {
      sidePanelOpen = !!open;
      const text = sidePanelOpen ? '收起bilibili视频总结侧栏' : '打开bilibili视频总结侧栏';
      hint.textContent = text;
      openBtn.title = text;
      openBtn.setAttribute('aria-label', text);
    }
    host.__setSidePanelOpen = setSidePanelOpen;

    function clamp(value, min, max) {
      return Math.min(Math.max(value, min), max);
    }

    function applyPosition(left, top, edge, save = false, docked = false) {
      const maxLeft = Math.max(0, window.innerWidth - hostSize);
      const maxTop = Math.max(0, window.innerHeight - hostSize);
      const nextLeft = clamp(left, 0, maxLeft);
      const nextTop = clamp(top, 0, maxTop);
      // 右侧停靠必须使用 right: 0，而不是用 left 推算；这样渲染与 1.0.2
      // 初始状态完全一致，按钮本体不会因宿主宽度变化被裁到屏幕外。
      if (docked && edge === 'right') {
        host.style.left = 'auto';
        host.style.right = '0px';
      } else {
        host.style.left = nextLeft + 'px';
        host.style.right = 'auto';
      }
      host.style.top = nextTop + 'px';
      host.style.bottom = 'auto';
      host.style.transform = 'none';
      host.dataset.edge = edge;
      if (save) {
        try {
          window.sessionStorage.setItem(positionKey, JSON.stringify({
            edge,
            top: nextTop,
            topRatio: maxTop ? nextTop / maxTop : 0.5,
          }));
        } catch (e) {}
      }
    }

    function restorePosition() {
      try {
        const saved = JSON.parse(window.sessionStorage.getItem(positionKey) || 'null');
        if (saved && (saved.edge === 'left' || saved.edge === 'right')) {
          const maxLeft = Math.max(0, window.innerWidth - hostSize);
          const maxTop = Math.max(0, window.innerHeight - hostSize);
          const savedTop = Number(saved.top);
          const savedRatio = Number(saved.topRatio);
          const top = Number.isFinite(savedTop)
            ? clamp(savedTop, 0, maxTop)
            : maxTop * clamp(Number.isFinite(savedRatio) ? savedRatio : 0.5, 0, 1);
          applyPosition(saved.edge === 'right' ? maxLeft : 0, top, saved.edge, false, true);
          return;
        }
      } catch (e) {}
      host.dataset.edge = 'right';
    }

    function snapToEdge() {
      const rect = host.getBoundingClientRect();
      const maxLeft = Math.max(0, window.innerWidth - hostSize);
      const edge = rect.left + rect.width / 2 < window.innerWidth / 2 ? 'left' : 'right';
      applyPosition(edge === 'right' ? maxLeft : 0, rect.top, edge, true, true);
    }

    restorePosition();
    window.addEventListener('resize', () => {
      const rect = host.getBoundingClientRect();
      const edge = host.dataset.edge === 'left' ? 'left' : 'right';
      applyPosition(edge === 'right' ? Math.max(0, window.innerWidth - hostSize) : 0, rect.top, edge, true, true);
    });

    openBtn.addEventListener('pointerdown', (event) => {
      if (event.button !== 0) return;
      const rect = host.getBoundingClientRect();
      applyPosition(rect.left, rect.top, host.dataset.edge || 'right');
      dragState = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top, moved: false };
      openBtn.setPointerCapture(event.pointerId);
    });
    openBtn.addEventListener('pointermove', (event) => {
      if (!dragState || event.pointerId !== dragState.pointerId) return;
      const deltaX = event.clientX - dragState.startX;
      const deltaY = event.clientY - dragState.startY;
      if (!dragState.moved && Math.hypot(deltaX, deltaY) < 4) return;
      dragState.moved = true;
      openBtn.classList.add('dragging');
      applyPosition(dragState.startLeft + deltaX, dragState.startTop + deltaY, host.dataset.edge || 'right');
      event.preventDefault();
    });
    function finishDrag(event) {
      if (!dragState || event.pointerId !== dragState.pointerId) return;
      const moved = dragState.moved;
      dragState = null;
      openBtn.classList.remove('dragging');
      if (moved) {
        suppressOpenClick = true;
        snapToEdge();
        setTimeout(() => { suppressOpenClick = false; }, 0);
      }
    }
    openBtn.addEventListener('pointerup', finishDrag);
    openBtn.addEventListener('pointercancel', finishDrag);
    openBtn.addEventListener('dragstart', (event) => event.preventDefault());
    function scheduleCloseReveal() {
      clearTimeout(closeRevealTimer);
      closeRevealTimer = setTimeout(() => closeBtn.classList.add('visible'), 1200);
    }
    function hideClose() {
      clearTimeout(closeRevealTimer);
      closeBtn.classList.remove('visible');
    }
    wrap.addEventListener('pointerenter', scheduleCloseReveal);
    wrap.addEventListener('pointerleave', hideClose);
    wrap.addEventListener('focusin', scheduleCloseReveal);
    wrap.addEventListener('focusout', (event) => {
      if (!wrap.contains(event.relatedTarget)) hideClose();
    });
    openBtn.addEventListener('click', async () => {
      if (suppressOpenClick) return;
      const opening = !sidePanelOpen;
      openBtn.disabled = true;
      try {
        const resp = await runtimeSendMessage({ action: opening ? 'openSidePanel' : 'closeSidePanel' });
        if (!resp?.success) {
          showFloatingButtonHint(resp?.error || '侧栏打开失败，请点击扩展图标重试');
        } else {
          setSidePanelOpen(opening);
        }
      } catch (err) {
        const msg = /Extension context invalidated|Receiving end does not exist/i.test(String(err?.message || ''))
          ? '扩展已更新，请刷新页面后重试'
          : (err?.message || '侧栏打开失败，请点击扩展图标重试');
        showFloatingButtonHint(msg);
      } finally {
        openBtn.disabled = false;
      }
    });
    closeBtn.addEventListener('click', (event) => {
      event.stopPropagation();
      hideClose();
      try { window.sessionStorage.setItem(hiddenKey, '1'); } catch (e) {}
      runtimeSendMessage({ action: 'setFloatingButtonEnabled', enabled: false }).catch(() => {
        chrome.storage.local.set({ [FLOATING_BUTTON_ENABLED_KEY]: false }).catch(() => {});
      });
      host.remove();
    });
  }

  /**
   * 从 URL 提取 BV 号。稍后再看播放页(/list/watchlater/)的 BV 在查询参数里，
   * 常规视频页(/video/)的 BV 在路径里。
   */
  function extractBvid() {
    const match = window.location.pathname.match(/\/video\/(BV[\w]+)/);
    if (match) return match[1];
    if (/\/list\/watchlater\//.test(window.location.pathname)) {
      const bvid = new URLSearchParams(window.location.search).get('bvid');
      return isStandardBvid(bvid) ? bvid : null;
    }
    return null;
  }

  function isStandardBvid(value) {
    return /^BV[a-zA-Z0-9]+$/.test(String(value || ''));
  }

  /**
   * 从 URL 提取当前分 P 的 CID。稍后再看播放页 URL 不带 p 参数，
   * 但 oid 参数就是当前正在播放分P的 CID，据此可直接锁定对应分P。
   */
  function extractUrlCid() {
    if (!/\/list\/watchlater\//.test(window.location.pathname)) return null;
    const oid = new URLSearchParams(window.location.search).get('oid');
    return /^\d+$/.test(String(oid || '')) ? oid : null;
  }

  /**
   * 从 URL 提取当前分 P。Bilibili 多 P 视频默认 p=1。
   */
  function extractPageNumber() {
    const page = parseInt(new URLSearchParams(window.location.search).get('p'), 10);
    return Number.isFinite(page) && page > 0 ? page : 1;
  }

  /**
   * 根据当前 URL 的 p 参数（稍后再看页按 oid 即 CID），从 pages 中选择正确的分 P 数据。
   */
  function findCurrentPage(pages) {
    if (!Array.isArray(pages) || pages.length === 0) return null;
    const cidFromUrl = extractUrlCid();
    if (cidFromUrl) {
      const byCid = pages.find((p) => String(p.cid) === String(cidFromUrl));
      if (byCid) return byCid;
    }
    const pageParam = extractPageNumber();
    return pages.find((p) => p.page === pageParam) || pages[pageParam - 1] || null;
  }

  function findPageByCid(pages, cid) {
    if (!Array.isArray(pages) || pages.length === 0 || !cid) return null;
    return pages.find((p) => String(p.cid) === String(cid)) || null;
  }

  async function getPageRuntimeInfo() {
    try {
      const resp = await runtimeSendMessage({ action: 'getBilibiliPageState' });
      return resp?.success ? resp.data : null;
    } catch (e) {
      if (!/Extension context invalidated|Receiving end does not exist/i.test(String(e?.message || ''))) {
        console.warn('[BiliSummary] 获取页面播放状态失败:', e.message);
      }
      return null;
    }
  }

  /**
   * 从页面 __INITIAL_STATE__ 获取初始数据
   */
  function getInitialState() {
    try {
      const state = window.__INITIAL_STATE__;
      if (state && state.videoData) {
        // 多P视频：从 URL 获取当前分P，匹配正确的 CID
        let cid = state.videoData.cid;
        const currentPage = findCurrentPage(state.videoData.pages);
        if (currentPage) {
          cid = currentPage.cid;
        }

        return {
          bvid: state.videoData.bvid,
          aid: state.videoData.aid,
          cid: cid,
          title: state.videoData.title,
          desc: state.videoData.desc,
          duration: currentPage?.duration || state.videoData.duration,
          owner: state.videoData.owner?.name || '',
          pic: state.videoData.pic,
          page: currentPage?.page || extractPageNumber(),
          part: currentPage?.part || '',
          pages: state.videoData.pages || [],
        };
      }
    } catch (e) {
      console.warn('[BiliSummary] __INITIAL_STATE__ 解析失败:', e);
    }
    return null;
  }

  /**
   * 通过 API 获取视频信息
   */
  async function fetchVideoInfo(bvid) {
    if (!isStandardBvid(bvid)) {
      const runtimeInfo = await getPageRuntimeInfo();
      return runtimeInfo?.bvid === bvid ? runtimeInfo : null;
    }
    if (/\/bangumi\/play\//.test(location.pathname)) {
      const runtimeInfo = await getPageRuntimeInfo();
      if (runtimeInfo?.pageType === 'bangumi' && runtimeInfo.bvid === bvid) return runtimeInfo;
    }
    try {
      const resp = await fetch(
        `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`,
        { credentials: 'include' }
      );
      const data = await resp.json();
      if (data.code === 0) {
        const pages = data.data.pages || [];
        const runtimeInfo = await getPageRuntimeInfo();
        if (runtimeInfo?.pageType === 'bangumi' && runtimeInfo.bvid === data.data.bvid) {
          return runtimeInfo;
        }
        const urlPage = findCurrentPage(pages);
        const runtimePage = runtimeInfo?.bvid === data.data.bvid ? findPageByCid(pages, runtimeInfo.cid) : null;
        // Bilibili 是 SPA。刚切视频/分 P 时，播放器对象可能仍保留上一页的
        // cid，而 URL 已经是新页面。URL 的 p 参数与 view API 的 pages 列表
        // 才是当前请求的稳定身份；runtime cid 只在它与 URL 分 P 一致时采用。
        const runtimeMatchesUrl = runtimePage && urlPage && String(runtimePage.cid) === String(urlPage.cid);
        const currentPage = runtimeMatchesUrl ? runtimePage : (urlPage || runtimePage);
        const cid = currentPage?.cid || data.data.cid;
        const info = {
          bvid: data.data.bvid,
          aid: data.data.aid,
          cid,
          title: data.data.title,
          desc: data.data.desc,
          duration: currentPage?.duration || data.data.duration,
          owner: data.data.owner?.name || '',
          pic: data.data.pic,
          pages,
          page: currentPage?.page || extractPageNumber(),
          part: currentPage?.part || '',
          bilibiliLoggedIn: runtimeInfo?.bilibiliLoggedIn ?? null,
          audioUrl: runtimeInfo?.audioUrl || '',
          audioUrls: Array.isArray(runtimeInfo?.audioUrls) ? runtimeInfo.audioUrls : [],
          audioMimeType: runtimeInfo?.audioMimeType || 'audio/mp4',
          source: runtimeMatchesUrl ? 'view-api-runtime-cid' : 'view-api-url-page',
        };
        return info;
      }
    } catch (e) {
      console.warn('[BiliSummary] API 获取视频信息失败:', e);
    }
    const initialState = getInitialState();
    if (initialState?.bvid === bvid) {
      return { ...initialState, source: 'initial-state-url-page' };
    }
    return null;
  }

  let videoInfoRequestCache = null;

  function fetchCurrentVideoInfo(bvid) {
    if (!isStandardBvid(bvid)) return fetchVideoInfo(bvid);
    const key = `${location.href}|${bvid}`;
    const now = Date.now();
    if (videoInfoRequestCache?.key === key && videoInfoRequestCache.expiresAt > now) {
      return videoInfoRequestCache.promise;
    }
    const promise = fetchVideoInfo(bvid).then((info) => (
      `${location.href}|${bvid}` === key ? info : null
    ));
    videoInfoRequestCache = { key, promise, expiresAt: now + 5000 };
    promise.then((info) => {
      if (!info && videoInfoRequestCache?.promise === promise) videoInfoRequestCache = null;
    }, () => {
      if (videoInfoRequestCache?.promise === promise) videoInfoRequestCache = null;
    });
    return promise;
  }

  // 字幕 URL 只做本页内存缓存。不要持久化，否则 B 站字幕 CDN URL 变化后容易读到旧/错字幕。
  const subtitleCache = new Map();
  const SUBTITLE_CACHE_TTL = 3 * 60 * 1000;
  function buildPlayerApiUrl(path, bvid, cid, aid) {
    const params = new URLSearchParams({ cid: String(cid) });
    if (aid) params.set('aid', String(aid));
    // vCaptions 及 B 站播放器对 wbi/v2 的分P请求都只使用 aid+cid。
    // 给该接口额外带 bvid 会在部分合集分P上得到空字幕列表。
    if (path !== 'x/player/wbi/v2' && isStandardBvid(bvid)) params.set('bvid', bvid);
    return `https://api.bilibili.com/${path}?${params.toString()}`;
  }

  function getSubtitleItems(data) {
    if (data?.code !== 0) {
      console.warn('[BiliSummary] 字幕 API 返回异常:', data?.code, data?.message);
      return [];
    }
    const subtitles = data?.data?.subtitle?.subtitles;
    return Array.isArray(subtitles) ? subtitles : [];
  }

  function decodeClassroomSubtitleUrl(value) {
    const source = String(value || '');
    const match = source.match(/^\/\/subtitle\.bilibili\.com\/([^?]+)(?:\?(.*))?$/i);
    if (!match) return source;
    // B 站课堂播放器会把 subtitle.bilibili.com 的路径异或解码后再交给
    // aisubtitle.hdslb.com；这里保持与当前官方播放器相同的两组兼容密钥。
    const ciphers = [
      ['nP](wOFRvU.+<fjS{jn-!$D|Dz&",zT`', '=CFxYRn{.y|uVyO$uh&sikph?N.ilF/`'],
      ['Bn"q~|albg@]Go~ACgyDvKnd+)_D}^&J?', "Cu~L!xs~f^&r@'vh=q]q{eeng*sEg^kp#J"],
    ];
    for (const [prefix, key] of ciphers) {
      try {
        const encoded = decodeURIComponent(match[1]);
        const xorKey = `${key}bilibili`;
        let decoded = '';
        for (let index = 0; index < encoded.length; index += 1) {
          decoded += String.fromCharCode(encoded.charCodeAt(index) ^ xorKey.charCodeAt(index % xorKey.length));
        }
        if (!decoded.startsWith(prefix)) continue;
        const path = decoded.slice(prefix.length);
        if (!path.startsWith('/')) return '';
        return `//aisubtitle.hdslb.com${path}${match[2] ? `?${match[2]}` : ''}`;
      } catch (_) {}
    }
    return source;
  }

  function decodeClassroomSubtitleView(buffer) {
    const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer || 0);
    const decoder = new TextDecoder('utf-8');

    const readVarint = (state) => {
      let value = 0n;
      let shift = 0n;
      for (let count = 0; count < 10 && state.offset < bytes.length; count += 1) {
        const byte = bytes[state.offset++];
        value |= BigInt(byte & 0x7f) << shift;
        if ((byte & 0x80) === 0) return value;
        shift += 7n;
      }
      throw new Error('课堂字幕 Protobuf varint 无效');
    };

    const readMessage = (start, end) => {
      const fields = new Map();
      const state = { offset: start };
      const append = (field, value) => fields.set(field, [...(fields.get(field) || []), value]);
      while (state.offset < end) {
        const tag = Number(readVarint(state));
        const field = tag >>> 3;
        const wireType = tag & 7;
        if (!field) throw new Error('课堂字幕 Protobuf 字段无效');
        if (wireType === 0) {
          append(field, readVarint(state));
        } else if (wireType === 1) {
          if (state.offset + 8 > end) throw new Error('课堂字幕 Protobuf 越界');
          append(field, bytes.subarray(state.offset, state.offset + 8));
          state.offset += 8;
        } else if (wireType === 2) {
          const length = Number(readVarint(state));
          const valueEnd = state.offset + length;
          if (!Number.isSafeInteger(length) || length < 0 || valueEnd > end) {
            throw new Error('课堂字幕 Protobuf 长度无效');
          }
          append(field, bytes.subarray(state.offset, valueEnd));
          state.offset = valueEnd;
        } else if (wireType === 5) {
          if (state.offset + 4 > end) throw new Error('课堂字幕 Protobuf 越界');
          append(field, bytes.subarray(state.offset, state.offset + 4));
          state.offset += 4;
        } else {
          throw new Error(`课堂字幕 Protobuf wire type 不支持: ${wireType}`);
        }
      }
      return fields;
    };

    const first = (fields, field) => fields.get(field)?.[0];
    const text = (fields, field) => {
      const value = first(fields, field);
      return value instanceof Uint8Array ? decoder.decode(value) : '';
    };
    const number = (fields, field) => {
      const value = first(fields, field);
      return typeof value === 'bigint' ? Number(value) : 0;
    };

    const root = readMessage(0, bytes.length);
    const videoBytes = first(root, 1);
    if (!(videoBytes instanceof Uint8Array) || videoBytes.length === 0) return [];
    const video = readMessage(videoBytes.byteOffset - bytes.byteOffset, videoBytes.byteOffset - bytes.byteOffset + videoBytes.byteLength);
    return (video.get(3) || []).map((itemBytes) => {
      if (!(itemBytes instanceof Uint8Array)) return null;
      const itemStart = itemBytes.byteOffset - bytes.byteOffset;
      const item = readMessage(itemStart, itemStart + itemBytes.byteLength);
      const id = text(item, 2) || String(first(item, 1) || '');
      return {
        id,
        id_str: id,
        lan: text(item, 3),
        lan_doc: text(item, 4),
        subtitle_url: decodeClassroomSubtitleUrl(text(item, 5)),
        type: number(item, 7),
        lan_doc_brief: text(item, 8),
        ai_type: number(item, 9),
        ai_status: number(item, 10),
        role: number(item, 11),
      };
    }).filter((item) => item?.subtitle_url);
  }

  async function fetchClassroomSubtitleItems(info) {
    if (info?.pageType !== 'cheese' || !info?.aid || !info?.cid || !info?.epId || !info?.seasonId) return [];
    const response = await runtimeSendMessage({
      action: 'fetchClassroomSubtitleViewInPage',
      aid: info.aid,
      cid: info.cid,
      epId: info.epId,
      seasonId: info.seasonId,
    });
    if (!response?.success || !response.base64) {
      throw new Error(response?.error || '课堂字幕页面请求失败');
    }
    const binary = atob(response.base64);
    const bytes = new Uint8Array(binary.length);
    for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
    return decodeClassroomSubtitleView(bytes);
  }

  function subtitleScore(subtitle) {
    const lan = String(subtitle?.lan || '').toLowerCase();
    const lanDoc = String(subtitle?.lan_doc || '').toLowerCase();
    let score = 0;
    if (lan === 'zh-cn') score += 100;
    else if (lan === 'zh-hans') score += 95;
    else if (lan === 'ai-zh') score += 90;
    else if (lan.startsWith('zh')) score += 85;
    else if (lan.includes('en') || lanDoc.includes('英文')) score += 25;
    if (lanDoc.includes('中文') || lanDoc.includes('简体')) score += 10;
    if (subtitle?.type === 0 || subtitle?.type === '0') score += 6;
    if (subtitle?.ai_type) score -= 3;
    if (lan.startsWith('ai-')) score -= 2;
    return score;
  }

  function subtitleStableKey(subtitle) {
    return [
      String(subtitle?.lan || '').toLowerCase(),
      String(subtitle?.lan_doc || '').toLowerCase(),
      String(subtitle?.id || subtitle?.subtitle_id || ''),
      String(subtitle?.subtitle_url || subtitle?.subtitle_url_v2 || ''),
    ].join('|');
  }

  function sortSubtitles(subtitles) {
    if (!Array.isArray(subtitles) || subtitles.length === 0) return [];
    return subtitles
      .map((subtitle, index) => ({ subtitle, index, score: subtitleScore(subtitle) }))
      .sort((a, b) => (b.score - a.score) || subtitleStableKey(a.subtitle).localeCompare(subtitleStableKey(b.subtitle)) || (a.index - b.index))
      .map((item) => item.subtitle);
  }

  function subtitleCandidateIdentity(chosen, url, aid, cid) {
    const lan = String(chosen?.lan || '').toLowerCase();
    const type = Number(chosen?.type);
    const aiType = Number(chosen?.ai_type);
    const isAiSubtitle = type === 1 || aiType > 0 || lan.startsWith('ai-') || /\/ai_subtitle\/prod\//i.test(url);
    if (!isAiSubtitle) return { accepted: true, requiresConsensus: false, key: '' };
    if (!aid || !cid) return { accepted: false, requiresConsensus: false, key: '' };
    const match = url.match(/\/ai_subtitle\/prod\/([^?/#]+)/i);
    const cdnIdentity = String(match?.[1] || '');
    const expectedPrefix = String(aid) + String(cid);
    if (cdnIdentity.startsWith(expectedPrefix)) {
      return { accepted: true, requiresConsensus: false, key: cdnIdentity };
    }

    // B 站新版 AI 字幕使用不包含 aid/cid 的 32 位十六进制资源哈希。
    // 单次响应不能证明归属，只有同一当前页的两次独立 wbi/v2 响应同时返回
    // 相同 subtitle id + 资源哈希时才进入正文与时间轴校验。
    const subtitleId = String(chosen?.id_str || chosen?.id || chosen?.subtitle_id || '');
    const isOpaqueHash = /^[a-f0-9]{32}$/i.test(cdnIdentity);
    return {
      accepted: Boolean(isOpaqueHash && subtitleId),
      requiresConsensus: Boolean(isOpaqueHash && subtitleId),
      key: isOpaqueHash && subtitleId ? `${subtitleId}|${cdnIdentity.toLowerCase()}` : '',
    };
  }

  function isSubtitleCandidateForVideo(chosen, url, aid, cid) {
    return subtitleCandidateIdentity(chosen, url, aid, cid).accepted;
  }

  function normalizeSubtitleInfo(chosen, bvid, cid, aid) {
    const subtitleUrl = chosen?.subtitle_url || chosen?.subtitle_url_v2;
    if (!subtitleUrl) return null;
    let url = subtitleUrl;
    if (url.startsWith('//')) url = 'https:' + url;
    if (url.startsWith('/')) url = 'https://i0.hdslb.com' + url;

    const identity = subtitleCandidateIdentity(chosen, url, aid, cid);
    if (!identity.accepted) return null;

    return {
      url,
      lan: chosen.lan,
      lan_doc: chosen.lan_doc,
      id: chosen.id || chosen.subtitle_id || '',
      type: chosen.type,
      ai_type: chosen.ai_type,
      requiresConsensus: identity.requiresConsensus,
      consensusKey: identity.key,
      bvid,
      cid,
      aid,
    };
  }

  function currentVideoRequestIdentity() {
    const bvid = extractBvid() || '';
    const params = new URLSearchParams(location.search);
    const page = params.get('p') || '1';
    const oid = params.get('oid') || '';
    return `${location.pathname}|${bvid}|p=${page}|oid=${oid}`;
  }

  function isCurrentVideoInfo(info) {
    if (!info) return false;
    const urlBvid = extractBvid();
    if (urlBvid && urlBvid !== info.bvid) return false;
    if (isStandardBvid(info.bvid) && Array.isArray(info.pages) && info.pages.length) {
      const urlPage = findCurrentPage(info.pages);
      if (urlPage?.cid && String(urlPage.cid) !== String(info.cid)) return false;
    }
    return true;
  }

  async function isCurrentVideoInfoFresh(info) {
    if (!isCurrentVideoInfo(info)) return false;
    if (!['cheese', 'bangumi'].includes(info?.pageType)) return true;
    const current = await getPageRuntimeInfo();
    if (!current || current.pageType !== info.pageType) return false;
    return ['bvid', 'aid', 'cid', 'epId', 'seasonId'].every((field) => (
      info[field] == null || info[field] === '' || String(current[field] || '') === String(info[field])
    ));
  }

  async function fetchJsonInContent(apiUrl) {
    const resp = await fetch(apiUrl, {
      credentials: 'include',
      cache: 'no-store',
      referrer: window.location.href,
    });
    if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
    return resp.json();
  }

  async function fetchJsonInPage(apiUrl) {
    const resp = await runtimeSendMessage({
      action: 'fetchJsonInPage',
      url: apiUrl,
    });
    if (!resp?.success) {
      throw new Error(resp?.error || '页面上下文请求失败');
    }
    return resp.data;
  }

  async function fetchJsonInBackground(apiUrl) {
    const resp = await runtimeSendMessage({
      action: 'fetchBilibiliJson',
      url: apiUrl,
    });
    if (!resp?.success) {
      throw new Error(resp?.error || 'background 请求失败');
    }
    return resp.data;
  }

  async function fetchPlayerJson(apiUrl) {
    let fallbackData = null;

    // 与 Chrome 商店 vCaptions 2.4.6 的实际 BilibiliAdapter 一致：优先直接
    // 在内容脚本中携带页面登录态请求，只有直接请求失败时才使用页面注入回退。
    try {
      const contentData = await fetchJsonInContent(apiUrl);
      if (getSubtitleItems(contentData).length > 0) {
        return contentData;
      }
      fallbackData = contentData || fallbackData;
    } catch (e) {
      console.warn('[BiliSummary] 内容脚本请求字幕 API 失败:', e.message);
    }

    try {
      const pageData = await fetchJsonInPage(apiUrl);
      if (getSubtitleItems(pageData).length > 0) {
        return pageData;
      }
      fallbackData = pageData || fallbackData;
    } catch (e) {
      console.warn('[BiliSummary] 页面上下文请求字幕 API 失败:', e.message);
    }

    // Do not use background as a subtitle-list fallback. Diagnostics showed that
    // x/player/v2 can return unrelated AI subtitle metadata outside page context.
    return fallbackData;
  }

  async function fetchSubtitleCandidates(bvid, cid, aid, pageInfo = null) {
    const requestIdentity = currentVideoRequestIdentity();
    const wbiUrl = buildPlayerApiUrl('x/player/wbi/v2', bvid, cid, aid);
    const apiUrls = [
      // 旧 x/player/v2 已现场确认会间歇返回其他视频的 AI 字幕。保留两次独立
      // wbi/v2 请求，在新版 opaque hash 无法由 aid+cid 反推时用于一致性确认。
      wbiUrl,
      wbiUrl,
    ];
    const candidates = [];
    const seenResources = new Set();
    const consensusCandidates = new Map();

    const [responses, classroomSubtitles] = await Promise.all([
      Promise.all(apiUrls.map(async (apiUrl) => {
        console.log('[BiliSummary] 请求字幕 API:', apiUrl);
        try {
          return await fetchPlayerJson(apiUrl);
        } catch (e) {
          console.warn('[BiliSummary] 获取字幕候选失败:', e);
          return null;
        }
      })),
      pageInfo?.pageType === 'cheese'
        ? fetchClassroomSubtitleItems(pageInfo).catch((error) => {
          console.debug('[BiliSummary] 课堂字幕接口暂不可用:', error.message);
          return [];
        })
        : Promise.resolve([]),
    ]);

    if (currentVideoRequestIdentity() !== requestIdentity) return [];

    for (let responseIndex = 0; responseIndex < responses.length; responseIndex += 1) {
      const data = responses[responseIndex];
      try {
        const subtitles = sortSubtitles(getSubtitleItems(data));
        console.log('[BiliSummary] 字幕列表:', JSON.stringify(subtitles));

        for (const subtitle of subtitles) {
          const result = normalizeSubtitleInfo(subtitle, bvid, cid, aid);
          if (!result) continue;
          if (result.requiresConsensus) {
            const existing = consensusCandidates.get(result.consensusKey) || {
              responseIndexes: new Set(),
              result,
            };
            existing.responseIndexes.add(responseIndex);
            existing.result = result;
            consensusCandidates.set(result.consensusKey, existing);
            continue;
          }
          let resourceKey = result.url;
          try {
            const parsed = new URL(result.url);
            resourceKey = `${result.id}|${parsed.origin}${parsed.pathname}`;
          } catch (_) {}
          if (seenResources.has(resourceKey)) continue;
          seenResources.add(resourceKey);
          candidates.push(result);
        }
      } catch (e) {
        console.warn('[BiliSummary] 获取字幕候选失败:', e);
      }
    }

    for (const candidate of consensusCandidates.values()) {
      if (candidate.responseIndexes.size === responses.length && responses.length >= 2) {
        candidates.push(candidate.result);
      }
    }

    // 课堂播放器的正式字幕接口使用 Protobuf，作为原有 wbi/v2 路径的补充。
    // AI 字幕仍必须通过既有 aid+cid CDN 身份校验；opaque hash 不因接口成功而放宽。
    for (const subtitle of sortSubtitles(classroomSubtitles)) {
      const result = normalizeSubtitleInfo(subtitle, bvid, cid, aid);
      if (!result || result.requiresConsensus) continue;
      let resourceKey = result.url;
      try {
        const parsed = new URL(result.url);
        resourceKey = `${result.id}|${parsed.origin}${parsed.pathname}`;
      } catch (_) {}
      if (seenResources.has(resourceKey)) continue;
      seenResources.add(resourceKey);
      candidates.push(result);
    }

    if (['cheese', 'bangumi'].includes(pageInfo?.pageType) && !(await isCurrentVideoInfoFresh(pageInfo))) return [];

    // 候选 URL 尚未经过正文和时间轴校验，不能缓存。B 站 AI 字幕可能延迟
    // 就绪，后续静默重试必须重新读取页面上下文，不能被第一次异常列表压住。
    return candidates;
  }

  async function fetchSubtitleUrl(bvid, cid, aid, pageInfo = null) {
    const candidates = await fetchSubtitleCandidates(bvid, cid, aid, pageInfo);
    if (candidates.length > 0) return candidates[0];

    // 不缓存“无字幕/失败”的负结果。B 站 AI 字幕和登录态有时会延迟就绪，
    // 缓存 null 会让侧栏的等待重试全部失效。
    console.log('[BiliSummary] 暂未获取到字幕，不写入负缓存:', `sub_v4_${bvid}_${cid}`);
    return null;
  }

  /**
   * 获取字幕内容（通过 background 代理，避免 CORS）
   */
  async function fetchSubtitleContent(url) {
    try {
      const resp = await runtimeSendMessage({
        action: 'fetchSubtitle',
        url: url,
        referer: window.location.href,
      });
      if (resp && resp.success && resp.data) {
        console.log('[BiliSummary] background 获取字幕成功，条数:', resp.data.length);
        return resp.data;
      }
      throw new Error(resp?.error || 'background 获取字幕失败');
    } catch (e) {
      if (/Extension context invalidated|Receiving end does not exist/i.test(String(e?.message || ''))) {
        return null;
      }
      console.warn('[BiliSummary] background 获取字幕失败，尝试直接获取:', e.message);
      try {
        // 浏览器禁止脚本手动设置 Referer，不要带该头，否则会刷控制台报错
        const resp = await fetch(url, {
          credentials: 'include',
          cache: 'no-store',
        });
        const text = await resp.text();
        if (!text.trim().startsWith('{') && !text.trim().startsWith('[')) {
          throw new Error('响应不是 JSON: ' + text.substring(0, 50));
        }
        const data = JSON.parse(text);
        if (data.body && Array.isArray(data.body)) {
          return data.body.map((item) => ({
            from: item.from,
            to: item.to,
            content: item.content,
          }));
        }
        throw new Error('format mismatch');
      } catch (e2) {
        console.warn('[BiliSummary] 直接获取字幕也失败:', e2.message);
      }
      return null;
    }
  }

  function getSubtitleEndTime(subtitles) {
    if (!Array.isArray(subtitles) || subtitles.length === 0) return 0;
    return subtitles.reduce((max, item) => {
      const to = Number(item.to || item.endTime || item.end || 0);
      const from = Number(item.from || item.startTime || item.start || 0);
      return Math.max(max, to, from);
    }, 0);
  }

  function validateSubtitleForVideo(subtitles, info) {
    if (!Array.isArray(subtitles) || subtitles.length === 0) {
      return { ok: false, reason: '字幕内容为空' };
    }

    const textLength = subtitles.reduce((sum, item) => sum + String(item.content || item.text || '').trim().length, 0);
    if (textLength < 20 && subtitles.length < 3) {
      return { ok: false, reason: '字幕内容过短' };
    }

    const duration = Number(info?.duration || 0);
    const endTime = getSubtitleEndTime(subtitles);
    if (duration > 0 && endTime > 0) {
      const suspiciouslyShort = duration >= 180 && endTime < Math.min(90, duration * 0.25) && subtitles.length < 20;
      if (suspiciouslyShort) {
        return {
          ok: false,
          reason: '字幕过短，疑似不属于当前视频: subtitleEnd=' + endTime + ', duration=' + duration,
        };
      }
      const tooLong = endTime > duration + Math.max(90, duration * 0.4);
      if (tooLong) {
        return {
          ok: false,
          reason: '字幕时间轴与当前视频时长不匹配: subtitleEnd=' + endTime + ', duration=' + duration,
        };
      }
    }

    return { ok: true };
  }

  function buildSubtitleResult(subtitleInfo, subtitles, info = {}) {
    const fullText = subtitlesToText(subtitles);
    return {
      success: true,
      data: {
        fullText,
        segments: subtitles.map((item) => ({
          from: Number(item.from || item.startTime || item.start || 0),
          to: Number(item.to || item.endTime || item.end || 0),
          content: String(item.content || item.text || ''),
        })),
        totalSegments: subtitles.length,
        subtitleLan: subtitleInfo.lan_doc || subtitleInfo.lan,
        subtitleUrl: subtitleInfo.url,
        bvid: info.bvid || subtitleInfo.bvid,
        cid: info.cid || subtitleInfo.cid,
        page: info.page,
        part: info.part,
      },
    };
  }

  /**
   * 将字幕转为纯文本
   */
  function subtitlesToText(subtitles) {
    if (!subtitles || !subtitles.length) return '';
    return subtitles.map((s) => s.content).join('\n');
  }

  let watchInfo = null;
  let watchInfoKey = '';
  let watchLocationHref = '';
  let windowFocused = document.hasFocus();
  let lastWatchTickAt = Date.now();
  let lastWatchFlushAt = Date.now();
  let pendingVisibleSeconds = 0;
  let pendingPlayingSeconds = 0;
  const WATCH_STATS_TICK_MS = 10_000;
  const WATCH_STATS_FLUSH_INTERVAL_MS = 60_000;

  function watchKey(info) {
    return [info?.bvid || '', info?.cid || '', info?.page || ''].join(':');
  }

  function learningRecordTitle(info) {
    return Array.isArray(info?.pages) && info.pages.length > 1 && info.part
      ? info.part
      : (info?.title || '');
  }

  async function refreshWatchInfo() {
    const bvid = extractBvid();
    if (!bvid) return null;
    if (watchInfo?.bvid === bvid && watchLocationHref === location.href) return watchInfo;
    const info = await fetchCurrentVideoInfo(bvid).catch(() => null);
    if (!info?.bvid) return null;
    const nextKey = watchKey(info);
    watchInfo = info;
    watchInfoKey = nextKey;
    watchLocationHref = location.href;
    return watchInfo;
  }

  function getMainVideoElement() {
    const videos = Array.from(document.querySelectorAll('video'));
    // 优先取 B 站播放器容器内的视频，避免命中页面上的预览/广告等旁路 <video>
    const playerContainer = document.querySelector('#bilibili-player, .bpx-player-container, .bilibili-player-video-wrap');
    if (playerContainer) {
      const inPlayer = videos.filter((video) => playerContainer.contains(video));
      if (inPlayer.length) return inPlayer.find((video) => video.duration > 0) || inPlayer[0];
    }
    return videos.find((video) => video.duration > 0) || videos[0] || null;
  }

  // ============ B站播放速度控制（参考 Global Speed 思路，仅作用于 B 站页面） ============
  // 侧栏选定速度后设置 video.playbackRate。B 站是 SPA，切换视频/分P、切清晰度
  // 等都会新建 <video> 或重置播放速率，通过 MutationObserver 监听新增 video、
  // loadedmetadata 负责在媒体重新就绪时恢复选定值，保证多视频来回切换、画质切换
  // 后调速依然生效。不要监听 ratechange 后再次写回，否则会和播放器自己的档位状态
  // 形成第二条反馈环，导致控制栏速度数字闪动。
  // B 站播放器实例只挂在主世界 window.player 上，内容脚本（隔离世界）访问不到；
  // 若只在隔离世界改 video，播放器会在各种事件里把旧档位重新写回——侧栏选
  // 0.9、1.1 这类非原生速度时速率来回跳，控制栏下方的速度数字就会闪动。
  // MAIN world 桥对原生档位调用播放器 API；对自定义档位直接设置主视频并拦住
  // 播放器的旧档位回写。桥失效时退回隔离世界直接设置。
  let desiredPlaybackRate = 1;
  let playbackApplyScheduled = false;

  // 内容脚本运行在隔离世界，不能直接访问 B 站主世界的 window.player；
  // 由 manifest 注册的 MAIN world 内容脚本直接监听事件并调用播放器，
  // 避免动态插入内联 <script> 或每次调速都经过 service worker 产生竞态。
  function applyPlaybackRate() {
    playbackApplyScheduled = false;
    if (!desiredPlaybackRate || desiredPlaybackRate <= 0) return;
    const video = getMainVideoElement();
    if (!video) return;
    if (Math.abs(video.playbackRate - desiredPlaybackRate) < 1e-4) return;
    try {
      document.dispatchEvent(new CustomEvent('__biliSummarySetPlaybackRate', {
        detail: { rate: desiredPlaybackRate },
      }));
    } catch (_) {}
    const latestVideo = getMainVideoElement();
    if (!latestVideo || Math.abs(latestVideo.playbackRate - desiredPlaybackRate) < 1e-4) return;
    // 播放器实例尚未就绪或主世界脚本尚未完成加载时，至少让当前 video 立即采用目标速度；
    // 后续 loadedmetadata 会再次尝试同步播放器内部状态。
    latestVideo.playbackRate = desiredPlaybackRate;
  }

  function schedulePlaybackRateApply() {
    if (playbackApplyScheduled) return;
    playbackApplyScheduled = true;
    requestAnimationFrame(applyPlaybackRate);
  }

  function initPlaybackRateWatch() {
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (!mutation.addedNodes || !mutation.addedNodes.length) continue;
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;
          if (node.tagName === 'VIDEO' || node.querySelector?.('video')) {
            schedulePlaybackRateApply();
            return;
          }
        }
      }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
    document.addEventListener('loadedmetadata', (event) => {
      if (event.target instanceof HTMLVideoElement) schedulePlaybackRateApply();
    }, true);
    schedulePlaybackRateApply();
  }

  function isPageActiveForStats() {
    return document.visibilityState === 'visible' && windowFocused;
  }

  function isVideoEffectivelyPlaying() {
    const video = getMainVideoElement();
    return !!video && !video.paused && !video.ended && video.readyState >= 2;
  }

  function collectWatchDelta() {
    const now = Date.now();
    const delta = Math.max(0, Math.min(10, (now - lastWatchTickAt) / 1000));
    lastWatchTickAt = now;
    if (!delta || !extractBvid()) return;
    if (isPageActiveForStats()) {
      pendingVisibleSeconds += delta;
      if (isVideoEffectivelyPlaying()) {
        pendingPlayingSeconds += delta;
      }
    }
  }

  async function flushWatchStats(options = {}) {
    if (!options.skipTick) collectWatchDelta();
    const visible = Math.round(pendingVisibleSeconds);
    const playing = Math.round(pendingPlayingSeconds);
    if (!options.force && visible < 3 && playing < 3) return;
    const locationChanged = watchLocationHref && watchLocationHref !== location.href;
    const info = (options.useExistingInfo || locationChanged) ? watchInfo : await refreshWatchInfo();
    if (!info?.bvid) return;
    if (!visible && !playing) return;

    pendingVisibleSeconds = Math.max(0, pendingVisibleSeconds - visible);
    pendingPlayingSeconds = Math.max(0, pendingPlayingSeconds - playing);
    lastWatchFlushAt = Date.now();

    try {
      const resp = await runtimeSendMessage({
        action: 'reportVideoWatchStats',
        stats: {
          bvid: info.bvid,
          cid: info.cid,
          title: learningRecordTitle(info),
          owner: info.owner,
          duration: info.duration,
          visible_seconds_delta: visible,
          playing_seconds_delta: playing,
        },
      });
      if (!resp?.success && !resp?.ignored) throw new Error(resp?.error || 'watch stats report failed');
    } catch (e) {
      pendingVisibleSeconds += visible;
      pendingPlayingSeconds += playing;
      if (!/Extension context invalidated|Receiving end does not exist/i.test(String(e?.message || ''))) {
        console.debug('[BiliSummary] watch stats report skipped:', e.message);
      }
    }
  }

  function scheduleWatchStats() {
    let watchTimer = 0;
    const stopWatchStats = () => {
      if (watchTimer) {
        clearInterval(watchTimer);
        watchTimer = 0;
      }
    };
    refreshWatchInfo().catch(() => {});
    const settleRouteChange = () => {
      if (!isExtensionContextAlive()) { stopWatchStats(); return; }
      flushWatchStats({ force: true, useExistingInfo: true }).finally(() => {
        pendingVisibleSeconds = 0;
        pendingPlayingSeconds = 0;
        watchInfo = null;
        watchInfoKey = '';
        watchLocationHref = '';
        lastWatchTickAt = Date.now();
        setTimeout(() => {
          if (isExtensionContextAlive()) refreshWatchInfo().catch(() => {});
        }, 800);
      });
    };
    for (const method of ['pushState', 'replaceState']) {
      const original = history[method];
      history[method] = function (...args) {
        const before = location.href;
        const result = original.apply(this, args);
        if (location.href !== before) settleRouteChange();
        return result;
      };
    }
    window.addEventListener('popstate', settleRouteChange);
    watchTimer = setInterval(() => {
      if (!isExtensionContextAlive()) { stopWatchStats(); return; }
      collectWatchDelta();
      if (watchLocationHref && watchLocationHref !== location.href) {
        flushWatchStats({ force: true, skipTick: true, useExistingInfo: true }).finally(() => {
          pendingVisibleSeconds = 0;
          pendingPlayingSeconds = 0;
          refreshWatchInfo().catch(() => {});
        });
        return;
      }
      if (Date.now() - lastWatchFlushAt >= WATCH_STATS_FLUSH_INTERVAL_MS) {
        flushWatchStats().catch(() => {});
      }
    }, WATCH_STATS_TICK_MS);
    document.addEventListener('visibilitychange', () => {
      flushWatchStats({ force: true }).catch(() => {});
    });
    window.addEventListener('focus', () => {
      windowFocused = true;
      lastWatchTickAt = Date.now();
      refreshWatchInfo().catch(() => {});
    });
    window.addEventListener('blur', () => {
      flushWatchStats({ force: true }).catch(() => {});
      windowFocused = false;
    });
    window.addEventListener('pagehide', () => {
      flushWatchStats({ force: true }).catch(() => {});
    });
  }

  /**
   * 监听来自 side panel / background 的消息
   */
  chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message.action === 'getVideoInfo') {
      handleGetVideoInfo()
        .then(sendResponse)
        .catch((err) =>
          sendResponse({ success: false, error: err.message })
        );
      return true; // 异步响应
    }

    if (message.action === 'getSubtitleText') {
      handleGetSubtitleText(message.bvid, message.cid)
        .then(sendResponse)
        .catch((err) =>
          sendResponse({ success: false, error: err.message })
        );
      return true;
    }

    if (message.action === 'clearVerifiedSubtitleCache') {
      const bvid = String(message.bvid || '').trim();
      const cid = String(message.cid || '').trim();
      if (bvid && cid) subtitleCache.delete(`verified_sub_v4_${bvid}_${cid}`);
      sendResponse({ success: true });
      return false;
    }

    if (message.action === 'commentVideoWithSummary') {
      if (window.__biliSummaryCommentBusy) {
        sendResponse({ success: false, error: '评论正在发布中，请勿重复点击' });
        return false;
      }
      window.__biliSummaryCommentBusy = true;
      handleCommentVideoWithSummary(message)
        .then(sendResponse)
        .catch((err) => sendResponse({ success: false, error: err.message }))
        .finally(() => { window.__biliSummaryCommentBusy = false; });
      return true;
    }

    if (message.action === 'showSummaryCompleteToast') {
      showSummaryCompleteToast();
      sendResponse({ success: true });
      return false;
    }

    if (message.action === 'seekVideo') {
      const video = getMainVideoElement();
      const time = Math.max(0, Number(message.time || 0));
      if (!video) {
        sendResponse({ success: false, error: '视频播放器尚未就绪' });
      } else {
        video.currentTime = time;
        video.play().catch(() => {});
        sendResponse({ success: true });
      }
      return false;
    }

    if (message.action === 'getVideoPlaybackState') {
      const video = getMainVideoElement();
      if (!video) {
        sendResponse({ success: false, error: '视频播放器尚未就绪' });
      } else {
        sendResponse({ success: true, currentTime: Math.max(0, Number(video.currentTime || 0)) });
      }
      return false;
    }

    if (message.action === 'setPlaybackSpeed') {
      const rate = Number(message.rate);
      if (!Number.isFinite(rate) || rate <= 0) {
        sendResponse({ success: false, error: '无效的播放速度' });
        return false;
      }
      desiredPlaybackRate = rate;
      applyPlaybackRate();
      sendResponse({ success: true });
      return false;
    }

    if (message.action === 'pauseBilibiliVideo') {
      const video = getMainVideoElement();
      if (!video) {
        sendResponse({ success: false, error: '视频播放器尚未就绪' });
      } else {
        video.pause().catch(() => {});
        sendResponse({ success: true });
      }
      return false;
    }

    if (message.action === 'setFloatingButtonEnabled') {
      const enabled = message.enabled !== false;
      try {
        if (enabled) {
          window.sessionStorage.removeItem('biliSummaryFloatingButtonHidden');
          initFloatingSidePanelButton().then(() => sendResponse({ success: true })).catch((err) => sendResponse({ success: false, error: err.message }));
        } else {
          window.sessionStorage.setItem('biliSummaryFloatingButtonHidden', '1');
          document.getElementById('bili-summary-sidepanel-entry-host')?.remove();
          sendResponse({ success: true });
        }
      } catch (err) {
        sendResponse({ success: false, error: err.message });
      }
      return true;
    }

    if (message.action === 'setSidePanelOpenState') {
      document.getElementById('bili-summary-sidepanel-entry-host')?.__setSidePanelOpen?.(message.open);
      sendResponse({ success: true });
      return false;
    }

    if (message.action === 'getBilibiliBotCookies') {
      const read = (name) => document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`))?.[1] || '';
      fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include', cache: 'no-store' })
        .then((resp) => resp.json())
        .then((data) => {
          const isLogin = data?.code === 0 && data?.data?.isLogin === true;
          sendResponse({
            success: true,
            data: {
              sessdata: '',
              biliJct: read('bili_jct'),
              dedeUserId: read('DedeUserID'),
              dedeUserIdCkMd5: read('DedeUserID__ckMd5'),
              buvid3: read('buvid3'),
              buvid4: read('buvid4'),
              mid: isLogin ? Number(data.data.mid || 0) : 0,
              uname: isLogin ? String(data.data.uname || '') : '',
              isLogin,
            },
          });
        })
        .catch((err) => sendResponse({ success: false, error: err.message }));
      return true;
    }

    if (message.action === 'getBilibiliAccountInfo') {
      fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include', cache: 'no-store' })
        .then((resp) => resp.json())
        .then((data) => {
          const isLogin = data?.code === 0 && data?.data?.isLogin === true;
          sendResponse({
            success: true,
            data: {
              isLogin,
              mid: isLogin ? Number(data.data.mid || 0) : 0,
              uname: isLogin ? String(data.data.uname || '') : '',
            },
          });
        })
        .catch((err) => sendResponse({ success: false, error: err.message }));
      return true;
    }

    if (message.action === 'getBilibiliUserCard') {
      const mid = Number(message.mid);
      if (!mid) { sendResponse({ success: false, error: '缺少 uid' }); return false; }
      fetch(`https://api.bilibili.com/x/web-interface/card?mid=${mid}&photo=false`, { credentials: 'include', cache: 'no-store' })
        .then((resp) => resp.json())
        .then((data) => {
          const name = String(data?.data?.card?.name || '');
          sendResponse({ success: true, data: { mid, name } });
        })
        .catch((err) => sendResponse({ success: false, error: err.message }));
      return true;
    }

    if (message.action === 'getBilibiliRelation') {
      const fid = Number(message.fid);
      if (!fid) { sendResponse({ success: false, error: '缺少对方 uid' }); return false; }
      fetch(`https://api.bilibili.com/x/relation?fid=${fid}`, { credentials: 'include', cache: 'no-store' })
        .then((resp) => resp.json())
        .then((data) => {
          if (data?.code !== 0) throw new Error(data?.message || `B站接口错误 ${data?.code}`);
          const attribute = Number(data?.data?.attribute || 0);
          sendResponse({ success: true, data: { following: (attribute & 2) === 2 } });
        })
        .catch((err) => sendResponse({ success: false, error: err.message }));
      return true;
    }

    if (message.action === 'followBilibiliUser') {
      const fid = Number(message.fid);
      if (!fid) { sendResponse({ success: false, error: '缺少对方 uid' }); return false; }
      const csrf = document.cookie.match(/(?:^|;\s*)bili_jct=([^;]+)/)?.[1];
      if (!csrf) { sendResponse({ success: false, error: '请先登录B站' }); return false; }
      const fields = { fid: String(fid), act: '1', re_src: '11', csrf, csrf_token: csrf };
      fetch('https://api.bilibili.com/x/relation/modify', {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: new URLSearchParams(fields).toString(),
      })
        .then((resp) => resp.json())
        .then(async (data) => {
          const relationResponse = await fetch(`https://api.bilibili.com/x/relation?fid=${fid}`, { credentials: 'include', cache: 'no-store' });
          const relation = await relationResponse.json();
          const following = relation?.code === 0 && (Number(relation?.data?.attribute || 0) & 2) === 2;
          if (!following) throw new Error(data?.message || relation?.message || `关注未生效（${data?.code ?? relation?.code ?? '未知错误'}）`);
          sendResponse({ success: true, data: { following: true } });
        })
        .catch((err) => sendResponse({ success: false, error: err.message }));
      return true;
    }

  });

  async function handleCommentVideoWithSummary({ aid, bvid, summary, footer: configuredFooter }) {
    const currentBvid = extractBvid() || (await getPageRuntimeInfo())?.bvid;
    if (!aid || !bvid || currentBvid !== bvid) throw new Error('视频页面已切换，请重新点击评论');
    const csrf = document.cookie.match(/(?:^|;\s*)bili_jct=([^;]+)/)?.[1];
    if (!csrf) throw new Error('请先登录B站');
    const accountResponse = await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include' });
    const account = await accountResponse.json();
    if (account.code !== 0 || !account.data?.isLogin) throw new Error('请先登录B站');
    const footerTemplate = String(configuredFooter || '——由 @user 和bilibili视频总结插件自动生成（插件官网 cetle.cn）').trim().slice(0, 300);
    const footer = footerTemplate.replace('@user', account.data.uname || 'B站用户');
    const footerChars = Array.from(footer);
    const summaryChars = Array.from(String(summary || '').trim());
    const chunkSize = Math.max(1, 1000 - footerChars.length - 2);
    const chunks = [];
    for (let index = 0; index < summaryChars.length; index += chunkSize) {
      chunks.push(summaryChars.slice(index, index + chunkSize).join(''));
    }
    if (!chunks.length) throw new Error('总结内容为空');
    chunks[chunks.length - 1] += `\n\n${footer}`;

    // 记住本页已经发过的评论（按视频 + 总结哈希），中途失败重试时从断点继续，
    // 已全部发完再点则直接拦截，避免向 B 站重复发同一条评论被警告“不要重复评论”。
    const summaryHash = String(summary || '').split('').reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 7);
    const state = (window.__biliSummaryCommentState || (window.__biliSummaryCommentState = {}))[bvid];

    let rootRpid = '';
    let parentRpid = '';
    let startIndex = 0;
    if (state && state.summaryHash === summaryHash && state.rootRpid) {
      if (state.postedCount >= chunks.length) throw new Error('该总结已评论过此视频，请勿重复评论');
      rootRpid = state.rootRpid;
      parentRpid = state.parentRpid || state.rootRpid;
      startIndex = state.postedCount;
    }
    for (let index = startIndex; index < chunks.length; index += 1) {
      const fields = {
        oid: String(aid), type: '1', message: chunks[index], plat: '1', csrf, csrf_token: csrf,
      };
      if (rootRpid) Object.assign(fields, { root: rootRpid, parent: parentRpid || rootRpid });
      const response = await fetch('https://api.bilibili.com/x/v2/reply/add', {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
        body: new URLSearchParams(fields).toString(),
      });
      const result = await response.json();
      if (result.code !== 0) {
        const prefix = index ? `已发布${index}条，后续回复失败：` : '';
        throw new Error(prefix + (result.message || `B站接口错误 ${result.code}`));
      }
      const rpid = String(result.data?.rpid_str || result.data?.reply?.rpid_str || result.data?.rpid || result.data?.reply?.rpid || '');
      if (!rpid) throw new Error(`已发布${index + 1}条，但无法继续取得评论编号`);
      if (!rootRpid) rootRpid = rpid;
      parentRpid = rpid;
      window.__biliSummaryCommentState[bvid] = { summaryHash, postedCount: index + 1, rootRpid, parentRpid };
      if (index < chunks.length - 1) {
        chrome.runtime.sendMessage({ action: 'biliSummaryCommentProgress', posted: index + 1, total: chunks.length }).catch(() => {});
        await new Promise((resolve) => setTimeout(resolve, 2000 + Math.floor(Math.random() * 1000)));
      }
    }
    return { success: true, rpid: rootRpid, count: chunks.length };
  }

  /**
   * 处理获取视频信息 —— 始终从 URL 取 BVID 并调 API，不用 __INITIAL_STATE__
   */
  async function handleGetVideoInfo() {
    const runtimeInfo = await getPageRuntimeInfo();
    const bvid = extractBvid() || runtimeInfo?.bvid;
    if (!bvid) {
      return { success: false, error: '无法识别 Bilibili 视频' };
    }

    // 强制走 API 获取最新数据，避免缓存/SPA残留旧数据
    const info = await fetchCurrentVideoInfo(bvid);
    if (!info) {
      return { success: false, error: '获取视频信息失败' };
    }

    // 校验 API 返回的 BVID 是否与 URL 一致
    if (info.bvid !== bvid) {
      return { success: false, error: '视频信息不匹配，请刷新页面重试' };
    }

    // 尝试获取字幕
    let subtitleInfo = null;
    if (info.cid) {
      subtitleInfo = await fetchSubtitleUrl(info.bvid, info.cid, info.aid, info);
    }

    if (!(await isCurrentVideoInfoFresh(info))) {
      return { success: false, error: '视频页面已切换，请重新检测视频' };
    }

    return {
      success: true,
      data: {
        ...info,
        hasSubtitle: !!subtitleInfo,
        subtitleInfo,
      },
    };
  }

  /**
   * 处理获取字幕文本
   */
  async function handleGetSubtitleText(bvid, cid) {
    // 每次取字幕前重新按当前页面解析，避免侧栏/页面切换后继续用旧 cid。
    const runtimeInfo = await getPageRuntimeInfo();
    const currentBvid = extractBvid() || runtimeInfo?.bvid;
    if (currentBvid && currentBvid !== bvid) {
      return { success: false, error: '视频已切换，请刷新后重试' };
    }

    const info = await fetchCurrentVideoInfo(bvid);
    if (!info) {
      return { success: false, error: '重新获取视频信息失败' };
    }
    if (info.bvid !== bvid) {
      return { success: false, error: '视频信息与当前页面不匹配，请刷新后重试' };
    }
    if (String(info.cid) !== String(cid)) {
      return { success: false, error: '当前视频分P已切换，请重新检测视频后重试' };
    }
    if (!(await isCurrentVideoInfoFresh(info))) {
      return { success: false, error: '视频页面已切换，请重新检测字幕' };
    }

    const cacheKey = `verified_sub_v4_${info.bvid}_${info.cid}`;
    const cached = subtitleCache.get(cacheKey);
    if (cached?.expiresAt > Date.now()) {
      console.log('[BiliSummary] 命中已验证字幕缓存:', cacheKey);
      return buildSubtitleResult(cached.subtitleInfo, cached.subtitles, info);
    }
    if (cached) subtitleCache.delete(cacheKey);

    const candidates = await fetchSubtitleCandidates(info.bvid, info.cid, info.aid, info);
    if (!(await isCurrentVideoInfoFresh(info))) {
      return { success: false, error: '视频页面已切换，请重新检测字幕' };
    }
    if (candidates.length === 0) {
      return { success: false, error: '该视频没有可用字幕' };
    }

    const failures = [];
    for (const subtitleInfo of candidates) {
      const subtitles = await fetchSubtitleContent(subtitleInfo.url);
      if (!(await isCurrentVideoInfoFresh(info))) {
        return { success: false, error: '视频页面已切换，请重新检测字幕' };
      }
      const validation = validateSubtitleForVideo(subtitles, info);
      if (!validation.ok) {
        failures.push((subtitleInfo.lan_doc || subtitleInfo.lan || 'unknown') + ': ' + validation.reason);
        console.debug(
          '[BiliSummary] 跳过疑似错配字幕:',
          'language=' + (subtitleInfo.lan_doc || subtitleInfo.lan || 'unknown'),
          'id=' + (subtitleInfo.id || 'unknown'),
          'url=' + subtitleInfo.url,
          validation.reason,
        );
        continue;
      }

      subtitleCache.set(cacheKey, {
        subtitleInfo,
        subtitles,
        expiresAt: Date.now() + SUBTITLE_CACHE_TTL,
      });
      return buildSubtitleResult(subtitleInfo, subtitles, info);
    }

    return {
      success: false,
      error: failures.length ? '未找到与当前视频时长匹配的字幕' : '字幕内容为空',
    };
  }

  scheduleWatchStats();
  initFloatingSidePanelButton().catch(() => {});
  initPlaybackRateWatch();
  console.log('[BiliSummary] 内容脚本已加载');
})();
