/**
 * BiliIndex 共享库（content / popup / service worker 共用）
 */
const BiliIndex = (globalThis.BiliIndex = globalThis.BiliIndex || {});

/** 无 chrome.runtime 时的兜底（正常以 manifest.json 的 version 为准） */
BiliIndex.EXTENSION_VERSION = "2.0.3";

BiliIndex.parseVersionParts = (version) => {
  const parts = [];
  for (const piece of String(version || "").trim().split(".")) {
    if (!/^\d+$/.test(piece)) break;
    parts.push(Number(piece));
  }
  return parts.length ? parts : [0];
};

/** @returns {number} 1 if a>b, -1 if a<b, 0 if equal */
BiliIndex.compareVersion = (a, b) => {
  const pa = BiliIndex.parseVersionParts(a);
  const pb = BiliIndex.parseVersionParts(b);
  const n = Math.max(pa.length, pb.length);
  for (let i = 0; i < n; i += 1) {
    const x = pa[i] || 0;
    const y = pb[i] || 0;
    if (x > y) return 1;
    if (x < y) return -1;
  }
  return 0;
};

BiliIndex.getExtensionVersion = () => {
  if (typeof chrome !== "undefined" && chrome.runtime?.getManifest) {
    return chrome.runtime.getManifest().version;
  }
  return BiliIndex.EXTENSION_VERSION;
};

/** 供控制台 document.documentElement.getAttribute('data-biliindex-version') 诊断 */
BiliIndex.stampPageVersion = () => {
  try {
    const v = BiliIndex.getExtensionVersion();
    if (typeof document !== "undefined" && document.documentElement) {
      document.documentElement.setAttribute("data-biliindex-version", v);
    }
    return v;
  } catch {
    return null;
  }
};

if (typeof document !== "undefined") {
  if (document.readyState === "loading") {
    document.addEventListener(
      "DOMContentLoaded",
      () => {
        BiliIndex.stampPageVersion();
      },
      { once: true }
    );
  } else {
    BiliIndex.stampPageVersion();
  }
}

/**
 * 简易 LRU Map（按插入顺序淘汰最久未访问项），供 displayCache 等复用。
 * @param {number} maxSize
 */
BiliIndex.createLruMap = (maxSize = 256) => {
  const cap = Math.max(16, Number(maxSize) || 256);
  const map = new Map();
  return {
    get size() {
      return map.size;
    },
    has(key) {
      if (!map.has(key)) return false;
      const val = map.get(key);
      map.delete(key);
      map.set(key, val);
      return true;
    },
    get(key) {
      if (!map.has(key)) return undefined;
      const val = map.get(key);
      map.delete(key);
      map.set(key, val);
      return val;
    },
    set(key, val) {
      if (map.has(key)) map.delete(key);
      else if (map.size >= cap) {
        const oldest = map.keys().next().value;
        if (oldest !== undefined) map.delete(oldest);
      }
      map.set(key, val);
    },
    delete(key) {
      return map.delete(key);
    },
    clear() {
      map.clear();
    },
    keys() {
      return map.keys();
    },
  };
};

/** requestIdleCallback 封装，带 timeout 兜底 */
BiliIndex.runWhenIdle = (fn, opts = {}) => {
  if (typeof fn !== "function") return;
  const timeout = opts.timeout ?? 800;
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(
      () => {
        try {
          fn();
        } catch {
          /* ignore */
        }
      },
      { timeout }
    );
  } else {
    setTimeout(() => {
      try {
        fn();
      } catch {
        /* ignore */
      }
    }, 16);
  }
};

BiliIndex.DEMO_CODENAME = "demo1";

BiliIndex.DEFAULT_API_BASE = "http://127.0.0.1:8000";
/** 本地模式固定 API（不可改） */
BiliIndex.LOCAL_API_BASE = "http://127.0.0.1:8000";
/** 联网模式固定 API（不可改） */
BiliIndex.CLOUD_API_BASE = "https://api.biliindex.cn";
BiliIndex.TAG_CACHE_MS = 60 * 60 * 1000;

/** 与 API EMOJI_PRELOAD 一致，用于前端展示兜底 */
BiliIndex.EMOJI_ICON_BY_CODE = {
  emoji_smile: "😊",
  emoji_laugh: "😂",
  emoji_rofl: "🤣",
  emoji_love: "😍",
  emoji_think: "🤔",
  emoji_cry: "😭",
  emoji_angry: "😡",
  emoji_wow: "😮",
  emoji_roll: "🙄",
  emoji_sleep: "😴",
  emoji_up: "👍",
  emoji_down: "👎",
  emoji_heart: "❤️",
  emoji_party: "🎉",
  emoji_sparkle: "✨",
  emoji_hundred: "💯",
  emoji_fist: "👊",
  emoji_mask: "🎭",
  emoji_game: "🎮",
  emoji_mic: "🎤",
  emoji_chat: "💬",
  emoji_star: "⭐",
  emoji_fire: "🔥",
  emoji_muscle: "💪",
  emoji_cake: "🎂",
  emoji_basketball: "🏀",
  emoji_dog: "🐶",
  emoji_pet_cat: "🐱",
  emoji_mouse: "🐭",
  emoji_rabbit: "🐰",
  emoji_fox: "🦊",
  emoji_bear: "🐻",
  emoji_panda: "🐼",
  emoji_koala: "🐨",
  emoji_tiger: "🐯",
  emoji_lion: "🦁",
  emoji_pig: "🐷",
  emoji_frog: "🐸",
  emoji_monkey: "🐵",
  emoji_chicken: "🐔",
  emoji_penguin: "🐧",
  emoji_turtle: "🐢",
  emoji_crocodile: "🐊",
  emoji_snake: "🐍",
  emoji_unicorn: "🦄",
  emoji_butterfly: "🦋",
  flag_us: "🇺🇸",
  flag_cn: "🇨🇳",
  flag_jp: "🇯🇵",
  flag_kr: "🇰🇷",
  flag_eu: "🇪🇺",
};

/** 后台误存 US/CN 文本时的展示兜底（与 API ICON_TEXT_TO_FLAG 一致） */
BiliIndex.ICON_TEXT_TO_FLAG = {
  US: "🇺🇸",
  USA: "🇺🇸",
  CN: "🇨🇳",
  CHN: "🇨🇳",
  JP: "🇯🇵",
  JPN: "🇯🇵",
  KR: "🇰🇷",
  KOR: "🇰🇷",
  EU: "🇪🇺",
};

BiliIndex.resolveTagIcon = (tag) => {
  if (!tag) return "";
  const fix = BiliIndex.EMOJI_ICON_BY_CODE[tag.code];
  if (fix) return fix;
  const ic = (tag.icon || "").trim();
  if (ic) {
    const upper = ic.toUpperCase();
    if (BiliIndex.ICON_TEXT_TO_FLAG[upper]) return BiliIndex.ICON_TEXT_TO_FLAG[upper];
    if (!/^[A-Z]{2,4}$/.test(ic)) return ic;
  }
  return "";
};

/** 展示用文案：避免 icon 与 name 相同时出现「游戏 游戏」 */
BiliIndex.formatTagLabel = (tag) => {
  if (!tag) return "";
  const name = (tag.name || "").trim();
  const icon = BiliIndex.resolveTagIcon(tag);
  if (BiliIndex.isEmojiTag(tag)) {
    if (icon && name && icon !== name) return `${icon} ${name}`;
    return icon || name || tag.code || "";
  }
  const rawIcon = (tag.icon || "").trim();
  if (rawIcon && rawIcon !== name && !/^[A-Z]{2,4}$/.test(rawIcon)) {
    return `${rawIcon} ${name}`.trim();
  }
  return name || tag.code || "";
};

BiliIndex.parseFanCount = (raw) => {
  if (raw == null || raw === "") return null;
  if (typeof raw === "number" && Number.isFinite(raw)) {
    return Math.max(0, Math.floor(raw));
  }
  const s = String(raw).trim().replace(/,/g, "").replace(/\s/g, "");
  if (!s) return null;
  const wan = s.match(/^([\d.]+)万$/);
  if (wan) {
    const n = Math.round(parseFloat(wan[1]) * 10000);
    return Number.isFinite(n) ? n : null;
  }
  const n = parseInt(s, 10);
  return Number.isFinite(n) ? Math.max(0, n) : null;
};

BiliIndex.formatFanCount = (fanCount, opts = {}) => {
  if (opts.deactivated) return "—";
  if (fanCount == null || fanCount === "") return "—";
  const n = Number(fanCount);
  if (!Number.isFinite(n) || n < 0) return "—";
  if (n < 100) return "<100";
  if (n < 10000) return String(Math.floor(n));
  return `${(n / 10000).toFixed(1)}万`;
};

/** 标签打标人数：1–99 原样；100–999999 为 0.1K–999K；≥1000000 为 x.xM（均一位小数） */
BiliIndex.formatVoteCount = (raw) => {
  const n = Number(raw);
  if (!Number.isFinite(n) || n <= 0) return "";
  if (n <= 99) return String(Math.floor(n));
  if (n >= 1_000_000) {
    const m = Math.floor((n / 1_000_000) * 10) / 10;
    return `${m.toFixed(1)}M`;
  }
  const k = n / 1000;
  if (k >= 100) return `${Math.min(999, Math.floor(k))}K`;
  const tenths = Math.floor(k * 10) / 10;
  return `${tenths.toFixed(1)}K`;
};

BiliIndex.parseVoteCountDisplay = (text) => {
  const s = String(text || "").trim().toUpperCase();
  if (!s) return 0;
  const plain = Number(s);
  if (Number.isFinite(plain) && !/[KM]/.test(s)) return Math.max(0, Math.floor(plain));
  const m = s.match(/^([\d.]+)M$/);
  if (m) return Math.round(parseFloat(m[1]) * 1_000_000);
  const k = s.match(/^([\d.]+)K$/);
  if (k) return Math.round(parseFloat(k[1]) * 1000);
  return Number.isFinite(plain) ? Math.max(0, Math.floor(plain)) : 0;
};

BiliIndex.fanCountFromCommentItem = (item) => {
  if (!(item instanceof HTMLElement)) return null;
  const d = item.__data;
  if (!d) return null;
  const m = d.member || d.reply?.member || d.user;
  return BiliIndex.parseFanCount(
    m?.fans ?? m?.follower ?? m?.follower_num ?? d.fans ?? null
  );
};

BiliIndex.fanCountFromVideoPage = (upMid) => {
  try {
    const st = window.__INITIAL_STATE__;
    const candidates = [
      st?.elecFullInfo?.up_info,
      st?.videoData?.owner,
      st?.upData,
    ].filter(Boolean);
    for (const up of candidates) {
      if (up && String(up.mid ?? up.uid ?? "") === String(upMid)) {
        return BiliIndex.parseFanCount(up.fans ?? up.follower ?? up.follower_num);
      }
    }
  } catch {
    /* ignore */
  }
  return null;
};

/** 从 B 站公开接口拉取粉丝数（需 manifest host_permissions） */
BiliIndex.fetchFanCountAsync = async (upMid) => {
  const mid = String(upMid || "").trim();
  if (!mid) return null;
  try {
    const res = await fetch(
      `https://api.bilibili.com/x/relation/stat?vmid=${encodeURIComponent(mid)}`,
      { credentials: "omit" }
    );
    const data = await res.json();
    if (data?.code === 0 && data.data) {
      return BiliIndex.parseFanCount(data.data.follower);
    }
  } catch {
    /* ignore */
  }
  return null;
};

BiliIndex.fanCountFromSpacePage = (upMid) => {
  try {
    const st = window.__INITIAL_STATE__;
    const info = st?.spaceInfo || st?.space || st?.h;
    const mid = String(info?.mid ?? info?.uid ?? st?.mid ?? "");
    if (mid && String(upMid) === mid) {
      return BiliIndex.parseFanCount(
        info?.fans ?? info?.follower ?? st?.fans ?? null
      );
    }
  } catch {
    /* ignore */
  }
  return null;
};

BiliIndex.isEmojiTag = (tag) => {
  if (!tag) return false;
  if (tag.tag_kind === "emoji") return true;
  return Object.prototype.hasOwnProperty.call(BiliIndex.EMOJI_ICON_BY_CODE, tag.code);
};

BiliIndex._extensionContextInvalid = false;
BiliIndex._extensionContextWarned = false;

BiliIndex.isExtensionContextInvalidated = () => {
  if (BiliIndex._extensionContextInvalid) return true;
  try {
    if (typeof chrome === "undefined" || !chrome.runtime?.id) {
      BiliIndex._extensionContextInvalid = true;
      return true;
    }
    return false;
  } catch {
    BiliIndex._extensionContextInvalid = true;
    return true;
  }
};

BiliIndex.isContextInvalidatedError = (err) =>
  /extension context invalidated/i.test(String(err?.message || err || ""));

BiliIndex.warnExtensionContextInvalidated = () => {
  if (BiliIndex._extensionContextWarned) return;
  BiliIndex._extensionContextWarned = true;
  console.warn(
    "[BiliIndex] 扩展已在 chrome://extensions 重新加载，当前页的旧脚本已失效。请按 F5 刷新本页后再使用打标/屏蔽。"
  );
};

BiliIndex.markExtensionContextInvalidated = () => {
  BiliIndex._extensionContextInvalid = true;
  BiliIndex.warnExtensionContextInvalidated();
};

BiliIndex.storageGet = (keys) =>
  new Promise((resolve, reject) => {
    if (BiliIndex.isExtensionContextInvalidated()) {
      BiliIndex.markExtensionContextInvalidated();
      reject(new Error("Extension context invalidated"));
      return;
    }
    try {
      chrome.storage.local.get(keys, (data) => {
        const err = chrome.runtime?.lastError;
        if (err) {
          if (BiliIndex.isContextInvalidatedError(err)) {
            BiliIndex.markExtensionContextInvalidated();
          }
          reject(new Error(err.message || String(err)));
          return;
        }
        resolve(data);
      });
    } catch (e) {
      BiliIndex.markExtensionContextInvalidated();
      reject(e);
    }
  });

BiliIndex.storageSet = (obj) =>
  new Promise((resolve, reject) => {
    if (BiliIndex.isExtensionContextInvalidated()) {
      BiliIndex.markExtensionContextInvalidated();
      reject(new Error("Extension context invalidated"));
      return;
    }
    try {
      chrome.storage.local.set(obj, () => {
        const err = chrome.runtime?.lastError;
        if (err) {
          if (BiliIndex.isContextInvalidatedError(err)) {
            BiliIndex.markExtensionContextInvalidated();
          }
          reject(new Error(err.message || String(err)));
          return;
        }
        resolve();
      });
    } catch (e) {
      BiliIndex.markExtensionContextInvalidated();
      reject(e);
    }
  });

BiliIndex._normalizeApiBaseUrl = (raw) => {
  const s = String(raw || "").trim().replace(/\/+$/, "");
  if (!s) return "";
  if (/^https?:\/\//i.test(s)) return s;
  return `http://${s}`;
};

/** 联网模式实际 API 根地址（支持自建服务器） */
BiliIndex.resolveCloudApiBase = async () => {
  const { customCloudApiBase } = await BiliIndex.storageGet(["customCloudApiBase"]);
  const custom = BiliIndex._normalizeApiBaseUrl(customCloudApiBase);
  return custom || BiliIndex.CLOUD_API_BASE;
};

/** 为自建 API 申请 host 权限（MV3） */
BiliIndex.ensureApiHostPermission = async (apiBase) => {
  const base = BiliIndex._normalizeApiBaseUrl(apiBase);
  if (!base || typeof chrome === "undefined" || !chrome.permissions?.request) return true;
  const origin = `${base}/*`;
  const defaults = new Set([
    `${BiliIndex.LOCAL_API_BASE}/*`,
    `${BiliIndex.CLOUD_API_BASE}/*`,
  ]);
  if (defaults.has(origin)) return true;
  try {
    const has = await chrome.permissions.contains({ origins: [origin] });
    if (has) return true;
    return await chrome.permissions.request({ origins: [origin] });
  } catch {
    return false;
  }
};

/** 将 storage 中的 API 模式/地址校正；联网模式可用 customCloudApiBase */
BiliIndex.ensureFixedApiConfig = async () => {
  const data = await BiliIndex.storageGet([
    "apiMode",
    "apiBase",
    "cloudApiBase",
    "customCloudApiBase",
  ]);
  const storeBuild =
    !BiliIndex.DEFAULT_API_BASE.includes("127.0.0.1") &&
    !BiliIndex.DEFAULT_API_BASE.includes("localhost");
  let mode = data.apiMode === "local" ? "local" : "cloud";
  if (storeBuild && mode === "local") {
    mode = "cloud";
  }
  const cloudBase =
    BiliIndex._normalizeApiBaseUrl(data.customCloudApiBase) || BiliIndex.CLOUD_API_BASE;
  const apiBase = mode === "local" ? BiliIndex.LOCAL_API_BASE : cloudBase;
  const patch = {
    apiMode: mode,
    apiBase,
    cloudApiBase: cloudBase,
  };
  if (
    data.apiMode !== patch.apiMode ||
    data.apiBase !== patch.apiBase ||
    data.cloudApiBase !== patch.cloudApiBase
  ) {
    await BiliIndex.storageSet(patch);
  }
  return mode;
};

/** @returns {"local"|"cloud"} */
BiliIndex.getApiMode = async () => {
  return BiliIndex.ensureFixedApiConfig();
};

BiliIndex.getCloudApiBase = async () => {
  await BiliIndex.ensureFixedApiConfig();
  return BiliIndex.resolveCloudApiBase();
};

BiliIndex.getApiBase = async () => {
  const mode = await BiliIndex.getApiMode();
  if (mode === "local") return BiliIndex.LOCAL_API_BASE;
  return BiliIndex.resolveCloudApiBase();
};

BiliIndex.setApiMode = async (mode) => {
  const next = mode === "local" ? "local" : "cloud";
  const cloudBase = await BiliIndex.resolveCloudApiBase();
  if (next === "cloud") {
    const ok = await BiliIndex.ensureApiHostPermission(cloudBase);
    if (!ok) {
      throw new Error("未授予访问该 API 地址的权限，请在浏览器提示中允许");
    }
  }
  await BiliIndex.storageSet({
    apiMode: next,
    apiBase: next === "local" ? BiliIndex.LOCAL_API_BASE : cloudBase,
    cloudApiBase: cloudBase,
  });
  return next;
};

/** 保存自建服务器地址（联网模式） */
BiliIndex.setCustomCloudApiBase = async (raw) => {
  const custom = BiliIndex._normalizeApiBaseUrl(raw);
  const cloudBase = custom || BiliIndex.CLOUD_API_BASE;
  const ok = await BiliIndex.ensureApiHostPermission(cloudBase);
  if (!ok) {
    throw new Error("未授予访问该 API 地址的权限，请在浏览器提示中允许");
  }
  await BiliIndex.storageSet({
    customCloudApiBase: custom,
    cloudApiBase: cloudBase,
    apiBase: (await BiliIndex.getApiMode()) === "local" ? BiliIndex.LOCAL_API_BASE : cloudBase,
  });
  return cloudBase;
};

BiliIndex.probeApiHealth = async (apiBase, options = {}) => {
  const base = BiliIndex._normalizeApiBaseUrl(apiBase);
  if (!base) throw new Error("API 地址为空");
  const retries = options.retries ?? 2;
  let lastErr;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(`${base}/api/health`, { cache: "no-store" });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        const msg = data.detail || data.message || res.statusText;
        throw new Error(typeof msg === "string" ? msg : `HTTP ${res.status}`);
      }
      return data;
    } catch (e) {
      lastErr = e;
      const retryable = /Failed to fetch|NetworkError|Network request failed|ERR_|timeout/i.test(
        String(e?.message || e)
      );
      if (!retryable || attempt >= retries) break;
      await new Promise((r) => setTimeout(r, 600 * (attempt + 1)));
    }
  }
  throw lastErr;
};

/** @deprecated 联网地址已固定，保留接口避免旧代码报错 */
BiliIndex.setCloudApiBase = async () => {
  await BiliIndex.setApiMode("cloud");
  return BiliIndex.CLOUD_API_BASE;
};

BiliIndex.getToken = async () => {
  const { accessToken } = await BiliIndex.storageGet(["accessToken"]);
  return accessToken || null;
};

BiliIndex.setSession = async (token, user) => {
  await BiliIndex.storageSet({ accessToken: token, user });
};

BiliIndex.clearSession = async () => {
  await BiliIndex.storageSet({
    accessToken: null,
    user: null,
    passerbyScanMode: "",
    passerbyEcoScanEnabled: false,
    passerbyScanEnabled: false,
  });
};

/** 登录偏好：记住密码 / 自动登录（仅存本机 chrome.storage.local） */
BiliIndex.getLoginPrefs = async () => {
  const data = await BiliIndex.storageGet([
    "rememberPassword",
    "autoLogin",
    "savedUsername",
    "savedPassword",
  ]);
  return {
    rememberPassword: !!data.rememberPassword,
    autoLogin: !!data.autoLogin,
    savedUsername: data.savedUsername || "",
    savedPassword: data.savedPassword || "",
  };
};

BiliIndex.saveLoginPrefs = async ({
  username,
  password,
  rememberPassword,
  autoLogin,
}) => {
  const patch = {
    rememberPassword: !!rememberPassword,
    autoLogin: !!autoLogin,
  };
  if (rememberPassword && username) {
    patch.savedUsername = username;
    patch.savedPassword = password || "";
  } else {
    patch.savedUsername = "";
    patch.savedPassword = "";
    patch.autoLogin = false;
  }
  await BiliIndex.storageSet(patch);
};

BiliIndex.getApiLinks = async () => {
  const base = (await BiliIndex.getApiBase()).replace(/\/$/, "");
  return {
    base,
    loginPage: `${base}/login`,
    loginApi: `${base}/api/auth/login`,
    registerApi: `${base}/api/auth/register`,
    docs: `${base}/docs`,
    admin: `${base}/admin/`,
  };
};

/**
 * B 站等网页里的内容脚本不能直接 fetch api.biliindex.cn（会 CORS → Failed to fetch），
 * 必须经 Service Worker；扩展弹窗页可直连。
 */
BiliIndex._apiProxyCache = null;

BiliIndex.invalidateApiProxyCache = () => {
  BiliIndex._apiProxyCache = null;
};

BiliIndex.isExtensionPageContext = () => {
  try {
    const href = globalThis.location?.href || "";
    return href.startsWith("chrome-extension://");
  } catch {
    return false;
  }
};

BiliIndex.needsApiProxy = async () => {
  if (BiliIndex._apiProxyCache != null) return BiliIndex._apiProxyCache;
  try {
    if (typeof chrome === "undefined" || !chrome.runtime?.id) {
      BiliIndex._apiProxyCache = false;
      return false;
    }
    if (BiliIndex.isExtensionPageContext()) {
      BiliIndex._apiProxyCache = false;
      return false;
    }
    BiliIndex._apiProxyCache = true;
    return true;
  } catch {
    BiliIndex._apiProxyCache = false;
    return false;
  }
};

BiliIndex.shouldProxyApi = () => {
  if (BiliIndex._apiProxyCache != null) return BiliIndex._apiProxyCache;
  return !BiliIndex.isExtensionPageContext() && !!chrome.runtime?.id;
};

BiliIndex.humanizeApiError = (err) => {
  const msg = String(err?.message || err || "");
  if (/Failed to fetch/i.test(msg)) {
    return "无法连接 BiliIndex 服务器：请在 chrome://extensions 重新加载扩展后刷新 B 站页面";
  }
  if (/Extension context invalidated/i.test(msg)) {
    return "扩展已重载，请对本页按 F5 刷新";
  }
  return msg;
};

if (typeof chrome !== "undefined" && chrome.storage?.onChanged) {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area !== "local") return;
    if (changes.apiBase || changes.apiMode || changes.customCloudApiBase) {
      BiliIndex.invalidateApiProxyCache();
    }
  });
}

BiliIndex.PROXY_API_TIMEOUT_MS = 12000;

BiliIndex.WAKE_SW_TIMEOUT_MS = 2000;

BiliIndex.wakeServiceWorker = () =>
  new Promise((resolve) => {
    let done = false;
    const finish = () => {
      if (done) return;
      done = true;
      clearTimeout(timer);
      resolve();
    };
    const timer = setTimeout(finish, BiliIndex.WAKE_SW_TIMEOUT_MS);
    try {
      chrome.runtime.sendMessage({ type: "PING" }, (resp) => {
        void chrome.runtime.lastError;
        if (resp?.ok) finish();
      });
    } catch {
      finish();
    }
  });

BiliIndex.proxyApiMessage = async (type, path, options) => {
  if (BiliIndex.isExtensionContextInvalidated()) {
    BiliIndex.markExtensionContextInvalidated();
    throw new Error("Extension context invalidated");
  }
  await BiliIndex.wakeServiceWorker();
  return new Promise((resolve, reject) => {
    let settled = false;
    const finish = (fn, val) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      fn(val);
    };
    const timer = setTimeout(() => {
      finish(
        reject,
        new Error(
          "API 请求超时：请重载扩展并刷新页面；若仍失败请检查 api.biliindex.cn 是否可打开"
        )
      );
    }, BiliIndex.PROXY_API_TIMEOUT_MS);
    try {
      chrome.runtime.sendMessage({ type, path, options }, (resp) => {
        const err = chrome.runtime.lastError;
        if (err) {
          if (BiliIndex.isContextInvalidatedError(err)) {
            BiliIndex.markExtensionContextInvalidated();
          }
          finish(reject, new Error(BiliIndex.humanizeApiError(err)));
          return;
        }
        if (!resp?.ok) {
          finish(
            reject,
            new Error(BiliIndex.humanizeApiError(resp?.error || "API 请求失败"))
          );
          return;
        }
        finish(resolve, resp.data);
      });
    } catch (e) {
      finish(reject, e);
    }
  });
};

BiliIndex._apiFetchDirect = async (path, options = {}) => {
  const base = await BiliIndex.getApiBase();
  const token = await BiliIndex.getToken();
  const isFormBody =
    typeof FormData !== "undefined" && options.body instanceof FormData;
  const headers = {
    ...(isFormBody ? {} : { "Content-Type": "application/json" }),
    ...(options.headers || {}),
  };
  if (token) headers.Authorization = `Bearer ${token}`;

  const retries = options.retries ?? 1;
  const timeoutMs = options.timeoutMs ?? 20000;
  let lastErr;
  for (let attempt = 0; attempt <= retries; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);
    try {
      const res = await fetch(`${base}${path}`, {
        ...options,
        headers,
        signal: controller.signal,
      });
      clearTimeout(timer);
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        const msg = data.detail || data.error || data.message || res.statusText;
        throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg));
      }
      return data;
    } catch (e) {
      clearTimeout(timer);
      lastErr =
        e?.name === "AbortError"
          ? new Error(`API 请求超时（${timeoutMs}ms）：${base}`)
          : e;
      const retryable = /Failed to fetch|NetworkError|Network request failed|ERR_|超时|AbortError/i.test(
        String(lastErr?.message || lastErr)
      );
      if (!retryable || attempt >= retries) break;
      await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
    }
  }
  throw lastErr;
};

BiliIndex.apiFetch = async (path, options = {}) => {
  if (BiliIndex.isExtensionContextInvalidated()) {
    BiliIndex.markExtensionContextInvalidated();
    throw new Error("Extension context invalidated");
  }
  if (await BiliIndex.needsApiProxy()) {
    return BiliIndex.proxyApiMessage("API_FETCH", path, options);
  }
  return BiliIndex._apiFetchDirect(path, options);
};

BiliIndex.submitBugReport = async ({ body, imageFile }) => {
  const fd = new FormData();
  fd.append("body", body);
  if (imageFile) fd.append("image", imageFile, imageFile.name || "screenshot.png");
  return BiliIndex.apiFetch("/api/user/bugs", { method: "POST", body: fd });
};

BiliIndex.fetchBugQuota = () => BiliIndex.apiFetch("/api/user/bugs/quota");

BiliIndex.openAdminPortal = async () => {
  const data = await BiliIndex.apiFetch("/api/user/admin-portal", { method: "POST" });
  const base = BiliIndex.LOCAL_API_BASE.replace(/\/$/, "");
  let url = data.admin_url || "";
  if (url.startsWith("/")) url = `${base}${url}`;
  if (typeof chrome !== "undefined" && chrome.tabs?.create) {
    chrome.tabs.create({ url });
  } else {
    window.open(url, "_blank", "noopener,noreferrer");
  }
  return data;
};

/** 无需登录的公开接口（标签树、快捷列表） */
BiliIndex._fetchPublicDirect = async (path, options = {}) => {
  const base = await BiliIndex.getApiBase();
  const retries = options.retries ?? 1;
  let lastErr;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(`${base}${path}`, { cache: "no-store" });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) {
        const msg = data.detail || data.message || res.statusText;
        throw new Error(typeof msg === "string" ? msg : String(msg));
      }
      return data;
    } catch (e) {
      lastErr = e;
      const retryable = /Failed to fetch|NetworkError|Network request failed|ERR_/i.test(
        String(e?.message || e)
      );
      if (!retryable || attempt >= retries) break;
      await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
    }
  }
  throw lastErr;
};

BiliIndex.fetchPublic = async (path) => {
  if (BiliIndex.isExtensionContextInvalidated()) {
    BiliIndex.markExtensionContextInvalidated();
    throw new Error("Extension context invalidated");
  }
  if (await BiliIndex.needsApiProxy()) {
    return BiliIndex.proxyApiMessage("FETCH_PUBLIC", path);
  }
  return BiliIndex._fetchPublicDirect(path);
};

BiliIndex.fetchPublicJson = async (path, options = {}) => {
  if (BiliIndex.isExtensionContextInvalidated()) {
    BiliIndex.markExtensionContextInvalidated();
    throw new Error("Extension context invalidated");
  }
  if (await BiliIndex.needsApiProxy()) {
    return BiliIndex.proxyApiMessage("API_FETCH", path, options);
  }
  const base = await BiliIndex.getApiBase();
  const res = await fetch(`${base}${path}`, {
    method: options.method || "GET",
    headers: { "Content-Type": "application/json", ...(options.headers || {}) },
    body: options.body,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const msg = data.detail || data.message || res.statusText;
    throw new Error(typeof msg === "string" ? msg : String(msg));
  }
  return data;
};

BiliIndex.fetchBlockTags = () => BiliIndex.apiFetch("/api/user/block-tags");

BiliIndex.saveBlockTags = (codes) =>
  BiliIndex.apiFetch("/api/user/block-tags", {
    method: "PUT",
    body: JSON.stringify({ codes: codes || [] }),
  });

BiliIndex.fetchKeywordBlocks = () => BiliIndex.apiFetch("/api/user/keyword-blocks");

BiliIndex.saveKeywordBlocks = (keywords) =>
  BiliIndex.apiFetch("/api/user/keyword-blocks", {
    method: "PUT",
    body: JSON.stringify({ keywords: keywords || [] }),
  });

BiliIndex.syncKeywordBlocks = async () => {
  try {
    const token = await BiliIndex.getToken();
    if (!token) {
      await BiliIndex.storageSet({ keywordBlockKeywords: [] });
      return { keywords: [], active_keywords: [] };
    }
    const data = await BiliIndex.fetchKeywordBlocks();
    const active = (data?.active_keywords || []).map(String);
    await BiliIndex.storageSet({ keywordBlockKeywords: active });
    try {
      if (typeof chrome !== "undefined" && chrome.runtime?.sendMessage) {
        chrome.runtime.sendMessage({ type: "KEYWORD_BLOCK_SYNCED" });
      }
    } catch {
      /* ignore */
    }
    if (typeof document !== "undefined") {
      document.dispatchEvent(new CustomEvent("biliindex-keyword-block-changed"));
    }
    return data;
  } catch {
    return { keywords: [], active_keywords: [] };
  }
};

BiliIndex.fetchSuperHateUps = () => BiliIndex.apiFetch("/api/user/super-hate");

BiliIndex.fetchSuperHateTags = () => BiliIndex.apiFetch("/api/user/super-hate-tags");

BiliIndex.saveSuperHateTags = (codes) =>
  BiliIndex.apiFetch("/api/user/super-hate-tags", {
    method: "PUT",
    body: JSON.stringify({ codes: codes || [] }),
  });

BiliIndex.addSuperHateUp = (up_mid, nickname = null) =>
  BiliIndex.apiFetch("/api/user/super-hate", {
    method: "POST",
    body: JSON.stringify({ up_mid: String(up_mid || ""), nickname }),
  });

BiliIndex.removeSuperHateUp = (up_mid) => {
  const q = new URLSearchParams({ up_mid: String(up_mid || "") });
  return BiliIndex.apiFetch(`/api/user/super-hate?${q}`, { method: "DELETE" });
};

/** 同步超级讨厌标签规则 + 单独标记的 UP 列表到本地存储 */
BiliIndex.syncSuperHateRules = async () => {
  try {
    const token = await BiliIndex.getToken();
    if (!token) {
      await BiliIndex.storageSet({
        superHateTagCodes: [],
        superHateMids: [],
        superHateTags: [],
      });
      return { codes: [], mids: [], tags: [] };
    }
    const [tagData, upData] = await Promise.all([
      BiliIndex.fetchSuperHateTags(),
      BiliIndex.fetchSuperHateUps(),
    ]);
    const codes = (tagData?.codes || []).map(String);
    const mids = (upData?.up_mids || []).map(String);
    const tags = (tagData?.tags || []).map((t) => ({
      id: t.id,
      code: t.code,
      name: t.name,
      icon: BiliIndex.resolveTagIcon?.(t) || t.icon || "",
      tag_kind: t.tag_kind,
    }));
    await BiliIndex.storageSet({
      superHateTagCodes: codes,
      superHateMids: mids,
      superHateTags: tags,
    });
    try {
      if (chrome.storage?.session) {
        await chrome.storage.session.set({ superHateMids: mids });
      }
    } catch {
      /* ignore */
    }
    try {
      if (typeof chrome !== "undefined" && chrome.runtime?.sendMessage) {
        chrome.runtime.sendMessage({ type: "SUPER_HATE_SYNCED" });
      }
    } catch {
      /* ignore */
    }
    if (typeof document !== "undefined") {
      document.dispatchEvent(new CustomEvent("biliindex-super-hate-changed"));
    }
    return { codes, mids, tags };
  } catch {
    return { codes: [], mids: [], tags: [] };
  }
};

BiliIndex.syncSuperHateMids = async () => BiliIndex.syncSuperHateRules();

BiliIndex.cacheValid = (cachedAt, items) =>
  !!(
    cachedAt &&
    Array.isArray(items) &&
    items.length > 0 &&
    Date.now() - cachedAt < BiliIndex.TAG_CACHE_MS
  );

BiliIndex.hashString = async (str) => {
  const buf = new TextEncoder().encode(str);
  const hash = await crypto.subtle.digest("SHA-256", buf);
  return Array.from(new Uint8Array(hash))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
};

BiliIndex.getCanvasFingerprint = () => {
  try {
    const c = document.createElement("canvas");
    const ctx = c.getContext("2d");
    if (!ctx) return "";
    ctx.textBaseline = "top";
    ctx.font = "14px Arial";
    ctx.fillText("biliindex-fp", 2, 2);
    return c.toDataURL();
  } catch {
    return "";
  }
};

BiliIndex.getWebGLFingerprint = () => {
  try {
    const c = document.createElement("canvas");
    const gl = c.getContext("webgl") || c.getContext("experimental-webgl");
    if (!gl) return "";
    const dbg = gl.getExtension("WEBGL_debug_renderer_info");
    if (!dbg) return gl.getParameter(gl.VERSION) || "";
    return [
      gl.getParameter(dbg.UNMASKED_VENDOR_WEBGL),
      gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL),
    ].join("|");
  } catch {
    return "";
  }
};

BiliIndex.getAudioFingerprint = () => {
  return new Promise((resolve) => {
    try {
      const ctx = new OfflineAudioContext(1, 5000, 44100);
      const osc = ctx.createOscillator();
      const comp = ctx.createDynamicsCompressor();
      osc.connect(comp);
      comp.connect(ctx.destination);
      osc.start(0);
      ctx.startRendering().then((buf) => {
        const data = buf.getChannelData(0).slice(0, 30);
        resolve(data.reduce((a, b) => a + Math.abs(b), 0).toString());
      });
    } catch {
      resolve("");
    }
  });
};

BiliIndex.collectFingerprint = async () => {
  const audio = await BiliIndex.getAudioFingerprint();
  const parts = [
    navigator.userAgent,
    `${screen.width}x${screen.height}x${screen.colorDepth}`,
    navigator.hardwareConcurrency,
    navigator.deviceMemory || "",
    Intl.DateTimeFormat().resolvedOptions().timeZone,
    navigator.language,
    BiliIndex.getCanvasFingerprint(),
    BiliIndex.getWebGLFingerprint(),
    audio,
    (document.fonts && document.fonts.size) || 0,
  ];
  return BiliIndex.hashString(parts.join("|||"));
};

BiliIndex.fetchSliderCaptcha = () =>
  BiliIndex.fetchPublic("/api/captcha/slider");

BiliIndex.checkSliderCaptcha = async (captcha_id, captcha_x) => {
  const base = await BiliIndex.getApiBase();
  const res = await fetch(`${base}/api/captcha/slider/check`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ captcha_id, captcha_x }),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    throw new Error(data.detail || data.message || res.statusText);
  }
  return !!data.ok;
};

BiliIndex.verifyPointsCredit = (ledger_ids, expected_delta = null) =>
  BiliIndex.apiFetch("/api/user/points/verify", {
    method: "POST",
    body: JSON.stringify({
      ledger_ids,
      expected_delta: expected_delta ?? undefined,
    }),
  });

/** 打标后校验积分流水；返回 { verified, message, user_points } */
BiliIndex.confirmPointsAfterTag = async (tagResult) => {
  const ids = [];
  let expected = 0;
  if (tagResult?.points_ledger_id) {
    ids.push(tagResult.points_ledger_id);
    expected += Number(tagResult.points_earned) || 0;
  }
  if (tagResult?.activation_ledger_id) {
    ids.push(tagResult.activation_ledger_id);
    expected += Number(tagResult.activation_bonus) || 0;
  }
  if (!ids.length) {
    return {
      verified: true,
      message: "",
      user_points: tagResult?.user_points,
    };
  }
  const verify = await BiliIndex.verifyPointsCredit(ids, expected);
  let message = "";
  if (verify.verified) {
    message = `积分已到账 +${verify.actual_delta}`;
  } else {
    message = "积分到账校验未通过，请在弹窗查看积分明细";
  }
  return {
    verified: !!verify.verified,
    message,
    user_points: verify.user_points,
    verify,
  };
};

BiliIndex.register = async (username, password, captcha = {}, opts = {}) => {
  const fingerprint_hash = await BiliIndex.collectFingerprint();
  const data = await BiliIndex.apiFetch("/api/auth/register", {
    method: "POST",
    body: JSON.stringify({
      username,
      password,
      fingerprint_hash,
      accept_privacy: !!opts.accept_privacy,
      captcha_id: captcha.captcha_id,
      captcha_x: captcha.captcha_x,
    }),
  });
  const me = await BiliIndex.apiFetch("/api/user/me", {
    headers: { Authorization: `Bearer ${data.access_token}` },
  });
  await BiliIndex.setSession(data.access_token, me);
  return me;
};

BiliIndex.resetPassword = async (username, resetKey, newPassword) =>
  BiliIndex.apiFetch("/api/auth/reset-password", {
    method: "POST",
    body: JSON.stringify({
      username: String(username || "").trim(),
      reset_key: String(resetKey || "").replace(/\D/g, ""),
      new_password: newPassword,
    }),
  });

BiliIndex.humanizeApiError = (err, base) => {
  const m = String(err?.message || err || "未知错误");
  if (/Failed to fetch|NetworkError|Network request failed|ERR_CONNECTION|ERR_CERT|certificate|SSL|CERT/i.test(m)) {
    return (
      `无法连接 API（${base || "未配置"}）。` +
      "请确认：① 扩展设置→API 为「联网模式」；② 浏览器能打开 " +
      `${base || BiliIndex.CLOUD_API_BASE}/api/health；` +
      "③ 若仅部分网络失败，可能是服务器/Nginx/备案线路问题，请换热点重试。"
    );
  }
  if (/绑定了其他浏览器指纹/.test(m)) {
    return `${m}（服务器需 DEVICE_BINDING_ENABLED=false 并重启 API）`;
  }
  return m;
};

/** 登录/注册前：校正 API 模式、申请 host 权限并探测连通性 */
BiliIndex.ensureLoginApiReady = async () => {
  await BiliIndex.ensureFixedApiConfig();
  let mode = await BiliIndex.getApiMode();
  let base = await BiliIndex.getApiBase();

  if (mode === "local") {
    try {
      await BiliIndex.probeApiHealth(BiliIndex.LOCAL_API_BASE);
    } catch {
      try {
        await BiliIndex.setApiMode("cloud");
        mode = "cloud";
        base = await BiliIndex.resolveCloudApiBase();
      } catch {
        /* 仍保持 local；权限/联网不可用时由后续 probe 报错 */
      }
    }
  }

  const okPerm = await BiliIndex.ensureApiHostPermission(base);
  if (!okPerm) {
    throw new Error("未授予扩展访问 API 的权限，请在浏览器提示中点「允许」");
  }

  try {
    await BiliIndex.probeApiHealth(base);
  } catch (e) {
    throw new Error(BiliIndex.humanizeApiError(e, base));
  }
  return base;
};

BiliIndex.login = async (username, password, captcha = {}) => {
  const base = await BiliIndex.ensureLoginApiReady();
  const fingerprint_hash = await BiliIndex.collectFingerprint();
  try {
    const data = await BiliIndex.apiFetch("/api/auth/login", {
      method: "POST",
      body: JSON.stringify({
        username,
        password,
        fingerprint_hash,
        captcha_id: captcha.captcha_id,
        captcha_x: captcha.captcha_x,
      }),
    });
    const me = await BiliIndex.apiFetch("/api/user/me", {
      headers: { Authorization: `Bearer ${data.access_token}` },
    });
    await BiliIndex.setSession(data.access_token, me);
    return me;
  } catch (e) {
    throw new Error(BiliIndex.humanizeApiError(e, base));
  }
};

BiliIndex.fetchMe = async () => {
  const me = await BiliIndex.apiFetch("/api/user/me");
  await BiliIndex.storageSet({ user: me });
  return me;
};

BiliIndex.fetchMembership = () => BiliIndex.apiFetch("/api/user/membership");

BiliIndex.exchangeMembership = (plan = "points_member") =>
  BiliIndex.apiFetch("/api/user/points/exchange", {
    method: "POST",
    body: JSON.stringify({ plan }),
  });

BiliIndex.invalidateTagCache = async () => {
  await BiliIndex.storageSet({
    tagTree: null,
    quickTags: null,
    tagsCachedAt: 0,
    tagsRevision: "",
  });
};

/** 从 API 拉取完整标签树 + 快捷打标列表并缓存 */
BiliIndex.refreshTagCaches = async (force = false) => {
  const { tagTree, quickTags, tagsCachedAt, tagsRevision } = await BiliIndex.storageGet([
    "tagTree",
    "quickTags",
    "tagsCachedAt",
    "tagsRevision",
  ]);
  let revision = tagsRevision || "";
  if (!force && BiliIndex.cacheValid(tagsCachedAt, tagTree) && BiliIndex.cacheValid(tagsCachedAt, quickTags)) {
    try {
      const revRes = await BiliIndex.fetchPublic("/api/tag/revision");
      revision = revRes.revision || revision;
      if (revision && revision === tagsRevision) {
        return { tree: tagTree, quick: quickTags };
      }
    } catch {
      return { tree: tagTree, quick: quickTags };
    }
  }

  const [treeRes, quickRes, emojiRes] = await Promise.all([
    BiliIndex.fetchPublic("/api/tag/tree"),
    BiliIndex.fetchPublic("/api/tag/quick-list"),
    BiliIndex.fetchPublic("/api/tag/quick-list?kind=emoji"),
  ]);
  revision = treeRes.revision || revision;
  let tree = treeRes.items || [];
  let quick = quickRes.items || [];
  const emojiItems = emojiRes.items || [];

  const byCode = new Map(tree.map((t) => [t.code, t]));
  for (const t of emojiItems) {
    const prev = byCode.get(t.code);
    const icon = BiliIndex.resolveTagIcon(t);
    if (prev) {
      if (icon) prev.icon = icon;
      prev.tag_kind = "emoji";
    } else {
      byCode.set(t.code, { ...t, icon, tag_kind: "emoji" });
    }
  }
  tree = [...byCode.values()];

  if (!quick.length && tree.length) {
    quick = tree.filter((t) => {
      const kind =
        t.tag_kind ||
        (t.level === 1 ? "category" : t.icon ? "emoji" : t.level === 3 ? "style" : "sub");
      return kind !== "category";
    });
  }

  if (!revision) {
    try {
      const revRes = await BiliIndex.fetchPublic("/api/tag/revision");
      revision = revRes.revision || "";
    } catch {
      revision = "";
    }
  }

  await BiliIndex.storageSet({
    tagTree: tree,
    quickTags: quick,
    tagsCachedAt: Date.now(),
    tagsRevision: revision,
  });
  return { tree, quick };
};

BiliIndex.getTagTree = async (force = false) => {
  const { tree } = await BiliIndex.refreshTagCaches(force);
  return tree;
};

BiliIndex.getQuickTags = async (force = false) => {
  const { quick } = await BiliIndex.refreshTagCaches(force);
  return quick;
};

BiliIndex.PASSERBY_CODE = "passerby";

/** 路人标签：仅用于展示/扫描，不出现在插件可选标签界面 */
BiliIndex.isPasserbyTag = (t) => {
  if (!t) return false;
  const code = String(t.code || "");
  const name = String(t.name || "").trim();
  return code === BiliIndex.PASSERBY_CODE || name === "路人";
};

/** 按类型分组，供弹窗打标界面使用 */
BiliIndex.groupTags = (items) => {
  const groups = {
    category: [],
    sub: [],
    style: [],
    emoji: [],
    emoji_cat: [],
  };
  for (const t of items || []) {
    if (BiliIndex.isPasserbyTag(t)) continue;
    if (t.code === "super_hate" || t.tag_kind === "super_hate") continue;
    let kind =
      t.tag_kind ||
      (t.level === 1 ? "category" : t.icon ? "emoji" : t.level === 3 ? "style" : "sub");
    if (t.tag_kind === "emoji_cat") kind = "emoji_cat";
    else if (BiliIndex.isEmojiTag(t)) kind = "emoji";
    if (groups[kind]) groups[kind].push(t);
    else groups.sub.push(t);
  }
  const sortFn = (a, b) =>
    (a.sort_order ?? 0) - (b.sort_order ?? 0) ||
    (Number(a.id) || 0) - (Number(b.id) || 0);
  Object.keys(groups).forEach((k) => groups[k].sort(sortFn));
  return groups;
};

BiliIndex.checkTagFrequency = (up_mid, slot = "tag1") => {
  const q = new URLSearchParams({
    up_mid: String(up_mid || ""),
    slot: slot || "tag1",
  });
  return BiliIndex.apiFetch(`/api/tag/check-frequency?${q}`);
};

BiliIndex.quickTag = (up_mid, tag_code, meta = {}) =>
  BiliIndex.apiFetch("/api/tag/quick-submit", {
    method: "POST",
    body: JSON.stringify({
      up_mid,
      tag_code,
      slot: meta.slot || "tag1",
      nickname: meta.nickname || null,
      fan_count: meta.fan_count ?? null,
    }),
  });

BiliIndex.submitTag = (up_mid, tag_id) =>
  BiliIndex.apiFetch("/api/tag/submit", {
    method: "POST",
    body: JSON.stringify({ up_mid, tag_ids: [tag_id] }),
  });

BiliIndex.fetchPasserbyScanStatus = () =>
  BiliIndex.apiFetch("/api/tag/passerby-scan/status");

BiliIndex.fetchPasserbyEcoScanStatus = () =>
  BiliIndex.apiFetch("/api/tag/passerby-eco-scan/status");

BiliIndex.passerbyEcoReport = (sightings) =>
  BiliIndex.apiFetch("/api/tag/passerby-eco-scan/report", {
    method: "POST",
    body: JSON.stringify({
      sightings: Array.isArray(sightings) ? sightings : [],
    }),
  });

BiliIndex.passerbyEcoBatchSubmit = (up_mids, nicknames = null) =>
  BiliIndex.apiFetch("/api/tag/passerby-eco-scan/submit", {
    method: "POST",
    body: JSON.stringify({
      up_mids: Array.isArray(up_mids) ? up_mids : [],
      nicknames: nicknames && typeof nicknames === "object" ? nicknames : undefined,
    }),
  });

BiliIndex.passerbyAutoScan = (up_mid, nickname) =>
  BiliIndex.apiFetch("/api/tag/passerby-auto-scan", {
    method: "POST",
    body: JSON.stringify({
      up_mid: String(up_mid || "").trim(),
      nickname: nickname || null,
    }),
  });

/** 本地时区次日 0:00 的时间戳（毫秒） */
BiliIndex.nextLocalMidnightMs = () => {
  const d = new Date();
  d.setDate(d.getDate() + 1);
  d.setHours(0, 0, 0, 0);
  return d.getTime();
};

BiliIndex.formatLocalMidnightHint = (ms) => {
  const d = new Date(ms || BiliIndex.nextLocalMidnightMs());
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getMonth() + 1}月${d.getDate()}日 ${p(d.getHours())}:${p(d.getMinutes())}`;
};

/** 今日扫描已满额：关闭开关并记录次日 0 点自动恢复 */
BiliIndex.pausePasserbyScanUntilMidnight = async () => {
  const until = BiliIndex.nextLocalMidnightMs();
  await BiliIndex.storageSet({
    passerbyScanEnabled: false,
    passerbyScanPausedUntil: until,
  });
  BiliIndex.schedulePasserbyScanResume();
  return until;
};

/**
 * 同步路人自动扫描开关：满额暂停至次日 0 点；到点后自动重新开启。
 * @returns {Promise<{enabled: boolean, pausedUntil?: number, resumed?: boolean}>}
 */
BiliIndex.reconcilePasserbyScanSchedule = async () => {
  const st = await BiliIndex.storageGet([
    "passerbyScanEnabled",
    "passerbyScanPausedUntil",
  ]);
  const now = Date.now();
  const pausedUntil = Number(st.passerbyScanPausedUntil) || 0;
  if (pausedUntil > now) {
    if (st.passerbyScanEnabled) {
      await BiliIndex.storageSet({ passerbyScanEnabled: false });
    }
    return { enabled: false, pausedUntil };
  }
  if (pausedUntil && now >= pausedUntil) {
    await BiliIndex.storageSet({
      passerbyScanEnabled: true,
      passerbyScanPausedUntil: null,
    });
    return { enabled: true, resumed: true };
  }
  return { enabled: !!st.passerbyScanEnabled };
};

/** 自动扫描路人说明（扩展弹窗内点击标题展开；视频页仍可读此常量作兜底） */
BiliIndex.PASSERBY_SCAN_HINT_HTML =
  "<strong>登录即可用。</strong>视频页每 4 秒处理 1 人：符合路人条件保留并 +1 积分。<strong>与节能扫描共用每日 100 积分</strong>，满额后次日 0:00 恢复。<strong>两种扫描只能二选一。</strong>（节能扫描需月卡会员）";

BiliIndex.PASSERBY_ECO_SCAN_HINT_HTML =
  "<strong>会员功能。</strong>在视频页汇总<strong>弹幕与评论区</strong> UID（本页自动去重），每提交 <strong>200 个不同 UID</strong> 至后台原始池得 <strong>1 积分</strong>；与「自动扫描路人」<strong>共用每日 100 积分</strong>，满额后次日 0:00 恢复。<strong>后台自动去重打标，两种扫描只能二选一。</strong>";

BiliIndex.rememberPasserbyMid = async (upMid) => {
  const mid = String(upMid || "").trim();
  if (!/^\d+$/.test(mid)) return;
  const d = new Date();
  const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  const { passerbyMarkedMids = {} } = await BiliIndex.storageGet(["passerbyMarkedMids"]);
  passerbyMarkedMids[mid] = { day, ts: Date.now() };
  await BiliIndex.storageSet({ passerbyMarkedMids });
};

BiliIndex.isPasserbyMidKnown = async (upMid) => {
  const mid = String(upMid || "").trim();
  const { passerbyMarkedMids = {} } = await BiliIndex.storageGet(["passerbyMarkedMids"]);
  const row = passerbyMarkedMids[mid];
  if (!row) return false;
  const d = new Date();
  const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
  return row.day === day;
};

BiliIndex._passerbyResumeTimer = null;

BiliIndex.clearPasserbyScanResumeTimer = () => {
  if (BiliIndex._passerbyResumeTimer != null) {
    clearTimeout(BiliIndex._passerbyResumeTimer);
    BiliIndex._passerbyResumeTimer = null;
  }
};

BiliIndex.schedulePasserbyScanResume = () => {
  if (typeof chrome === "undefined" || !chrome.storage?.local) return;
  BiliIndex.clearPasserbyScanResumeTimer();
  chrome.storage.local.get(["passerbyScanPausedUntil"], (st) => {
    void chrome.runtime?.lastError;
    const until = Number(st?.passerbyScanPausedUntil) || 0;
    const delay = until - Date.now();
    if (delay <= 0 || delay > 2147483647) return;
    BiliIndex._passerbyResumeTimer = setTimeout(() => {
      BiliIndex._passerbyResumeTimer = null;
      void BiliIndex.reconcilePasserbyScanSchedule();
    }, delay + 50);
  });
};

if (typeof window !== "undefined") {
  window.addEventListener("pagehide", () => {
    BiliIndex.clearPasserbyScanResumeTimer?.();
  });
}

/** 评论区宿主（勿对 document.body 做 subtree 深度扫描） */
BiliIndex.COMMENT_HOST_SELECTORS = [
  "bili-comments",
  "bili-comment-area",
  "#comment-app",
  "#comment-module",
  "#comment",
];

BiliIndex.COMMENT_LOADING_RE = /正在玩命加载|玩命加载|评论加载中/;

BiliIndex.openCommentShadowRoot = (el) => {
  if (!(el instanceof Element)) return null;
  try {
    if (chrome?.dom?.openOrClosedShadowRoot) {
      const sr = chrome.dom.openOrClosedShadowRoot(el);
      if (sr) return sr;
    }
  } catch {
    /* ignore */
  }
  try {
    return el.openOrClosedShadowRoot || el.shadowRoot || null;
  } catch {
    return null;
  }
};

/** B 站评论区仍在原生加载（此期间避免深度 DOM 扫描） */
BiliIndex.isCommentsSectionLoading = () => {
  if (typeof document === "undefined") return false;
  if (BiliIndex.queryInCommentHosts) {
    const ready = BiliIndex.queryInCommentHosts(
      "bili-comment-renderer, bili-comment-reply-renderer"
    ).length;
    if (ready > 0) return false;
  }
  let hasHost = false;
  let rendererCount = 0;
  for (const sel of BiliIndex.COMMENT_HOST_SELECTORS) {
    for (const host of document.querySelectorAll(sel)) {
      hasHost = true;
      const shadow = BiliIndex.openCommentShadowRoot(host);
      if (shadow) {
        try {
          rendererCount += shadow.querySelectorAll(
            "bili-comment-renderer, bili-comment-reply-renderer"
          ).length;
        } catch {
          /* ignore */
        }
      }
    }
  }
  if (rendererCount > 0) return false;
  if (!hasHost) return false;
  for (const sel of BiliIndex.COMMENT_HOST_SELECTORS) {
    for (const host of document.querySelectorAll(sel)) {
      const light = (host.textContent || "").slice(0, 5000);
      if (BiliIndex.COMMENT_LOADING_RE.test(light)) return true;
      const shadow = BiliIndex.openCommentShadowRoot(host);
      if (!shadow) continue;
      const dark = (shadow.textContent || "").slice(0, 5000);
      if (BiliIndex.COMMENT_LOADING_RE.test(dark)) return true;
      try {
        if (
          shadow.querySelector?.(
            ".loading, .loading-state, .comment-loading, bili-loading"
          )
        ) {
          return true;
        }
      } catch {
        /* ignore */
      }
    }
  }
  return false;
};

/** 播放页右侧推荐封面懒加载是否就绪（避免过早 DOM 扫描导致灰块） */
let _recoSidebarGateUntil = 0;
BiliIndex.isRecoSidebarReady = () => {
  if (typeof document === "undefined" || typeof location === "undefined") return true;
  if (!/\/video\/(BV|bv|av)/i.test(location.pathname || "")) return true;
  if (_recoSidebarGateUntil && Date.now() > _recoSidebarGateUntil) return true;
  const list = document.querySelector("#reco_list, .rcmd-list, .recommend-list-v1");
  if (!list) return document.readyState === "complete";
  const imgs = list.querySelectorAll(
    "img[src*='hdslb'], img[src*='bilibili'], img[data-src], picture img"
  );
  if (!imgs.length) return document.readyState === "complete";
  let loaded = 0;
  for (const img of imgs) {
    if (img instanceof HTMLImageElement && img.naturalWidth > 0) {
      loaded += 1;
      if (loaded >= 2) return true;
    }
  }
  if (!_recoSidebarGateUntil) _recoSidebarGateUntil = Date.now() + 5000;
  return false;
};

if (typeof window !== "undefined") {
  window.addEventListener("pageshow", () => {
    _recoSidebarGateUntil = 0;
  });
}

/** 仅在评论区宿主内深度查询（不扫全页） */
BiliIndex.queryInCommentHosts = (selector) => {
  const out = [];
  const seen = new Set();
  const hostRe = /^BILI-COMMENT|^BILI-EMOJI$/;
  function walk(node) {
    if (!node || seen.has(node)) return;
    seen.add(node);
    let scope = node;
    if (node instanceof Element) {
      const tag = (node.tagName || "").toUpperCase();
      if (hostRe.test(tag) || /^BILI-/i.test(tag)) {
        const shadow = BiliIndex.openCommentShadowRoot(node) || BiliIndex.openShadowRoot?.(node);
        if (shadow) {
          walk(shadow);
          scope = shadow;
        }
      }
    }
    try {
      scope.querySelectorAll?.(selector).forEach((el) => {
        if (!seen.has(el)) {
          seen.add(el);
          out.push(el);
        }
      });
    } catch {
      /* ignore */
    }
    const kids = scope.children;
    if (kids) for (const c of kids) walk(c);
  }
  for (const sel of BiliIndex.COMMENT_HOST_SELECTORS) {
    try {
      document.querySelectorAll(sel).forEach((host) => walk(host));
    } catch {
      /* ignore */
    }
  }
  return out;
};

let _commentsReadyTimer = null;
let _commentsReadyStartedAt = 0;
const _commentsReadyQueue = [];
const COMMENTS_READY_MAX_WAIT_MS = 14000;

BiliIndex.whenCommentsReady = (fn) => {
  if (typeof fn !== "function") return;
  if (!BiliIndex.isCommentsSectionLoading()) {
    try {
      fn();
    } catch {
      /* ignore */
    }
    return;
  }
  _commentsReadyQueue.push(fn);
  if (_commentsReadyTimer) return;
  _commentsReadyStartedAt = Date.now();
  const flushReady = () => {
    _commentsReadyTimer = null;
    _commentsReadyStartedAt = 0;
    const batch = _commentsReadyQueue.splice(0);
    for (const f of batch) {
      try {
        f();
      } catch {
        /* ignore */
      }
    }
  };
  const pump = () => {
    const timedOut = Date.now() - _commentsReadyStartedAt >= COMMENTS_READY_MAX_WAIT_MS;
    if (!timedOut && BiliIndex.isCommentsSectionLoading()) {
      _commentsReadyTimer = setTimeout(pump, 500);
      return;
    }
    flushReady();
  };
  _commentsReadyTimer = setTimeout(pump, 400);
};

/** 从 API 拉取后台配置的插件图标并应用到工具栏 */
BiliIndex.syncExtensionIcon = async (opts = {}) => {
  if (typeof chrome === "undefined" || !chrome.action?.setIcon) return { ok: false };
  const base = (opts.apiBase || (await BiliIndex.getApiBase?.()) || BiliIndex.DEFAULT_API_BASE)
    .replace(/\/$/, "");
  try {
    const meta = await BiliIndex.fetchPublic("/api/content/extension-icon/meta");
    if (!meta?.configured || !meta.version) return { ok: true, skipped: true };
    const stored = await BiliIndex.storageGet(["extensionIconVersion"]);
    if (!opts.force && stored.extensionIconVersion === meta.version) {
      return { ok: true, cached: true };
    }
    const v = encodeURIComponent(meta.version);
    const sizes = [16, 48, 128];
    const imageData = {};
    for (const size of sizes) {
      const url = `${base}/api/content/extension-icon/${size}?v=${v}`;
      const res = await fetch(url, { cache: "no-store" });
      if (!res.ok) throw new Error(`icon ${size} ${res.status}`);
      const blob = await res.blob();
      const bitmap = await createImageBitmap(blob);
      const canvas = new OffscreenCanvas(size, size);
      const ctx = canvas.getContext("2d");
      if (!ctx) throw new Error("canvas unavailable");
      ctx.drawImage(bitmap, 0, 0, size, size);
      imageData[size] = ctx.getImageData(0, 0, size, size);
    }
    await chrome.action.setIcon({ imageData });
    await BiliIndex.storageSet({ extensionIconVersion: meta.version });
    return { ok: true, version: meta.version };
  } catch (e) {
    console.warn("[BiliIndex] 同步插件图标失败", e?.message || e);
    return { ok: false, error: String(e?.message || e) };
  }
};
