// AdBlockMaxxer background — works as a Chromium service worker AND a Firefox event page
// (the manifest picks the right key per browser; this file uses only APIs common to both).
//
// Responsibilities:
//   1. Live toolbar badge counter (content scripts report kills here).
//   2. Per-site + per-category stats accumulation for the dashboard.
//   3. Snooze: pause blocking on this device for N minutes, then auto-resume.
//   4. Keyboard-command relay to the active tab (panel toggle, zap mode) + snooze.
//   5. Toggle the big EasyList ruleset on/off without losing core blocking.
//   6. Custom filter lists -> declarativeNetRequest *dynamic* rules (user-added in options).
//   7. Check the upstream Twitch fix version on a schedule and flag the popup when a
//      newer one exists. (MV3 forbids running fetched remote code, so we DETECT updates
//      and tell you to run the one-click rebuild — we never silently inject remote JS.)

const TWITCH_SRC =
  "https://raw.githubusercontent.com/pixeltris/TwitchAdSolutions/master/video-swap-new/video-swap-new.user.js";

// Self-update manifest. We're off the Web Store so the browser never auto-updates the
// extension code; this lets us DETECT a newer build and flag the popup with a one-click
// download. We never fetch+run remote code (MV3 forbids it) — we only compare versions.
const VERSION_MANIFEST = "https://dl.maxxtopia.com/adblockmaxxer-version.json";
const DOWNLOAD_FALLBACK = "https://dl.maxxtopia.com/adblockmaxxer-chrome.zip";

const DYNAMIC_RULE_CAP = 5000; // safe across Chrome + Firefox dynamic-rule ceilings

// ---- badge ----------------------------------------------------------------
function paintBadge(n, snoozed) {
  try {
    if (snoozed) {
      chrome.action.setBadgeText({ text: "zZ" });
      chrome.action.setBadgeBackgroundColor({ color: "#ffb454" });
      return;
    }
    const text = !n ? "" : n > 9999 ? Math.floor(n / 1000) + "k" : String(n);
    chrome.action.setBadgeText({ text });
    chrome.action.setBadgeBackgroundColor({ color: "#b14cff" });
  } catch (e) {}
}
function refreshBadge() {
  chrome.storage.local.get(["adsSkipped", "snoozeUntil"], (r) => {
    const snoozed = (r.snoozeUntil || 0) > Date.now();
    paintBadge((r && r.adsSkipped) || 0, snoozed);
  });
}
refreshBadge();

// ---- stats ----------------------------------------------------------------
function recordKill(n, category, host) {
  chrome.storage.local.get(["adsSkipped", "stats"], (r) => {
    const inc = n || 1;
    const total = ((r && r.adsSkipped) || 0) + inc;
    const stats = (r && r.stats) || { byCategory: {}, bySite: {} };
    if (!stats.byCategory) stats.byCategory = {};
    if (!stats.bySite) stats.bySite = {};
    if (category) stats.byCategory[category] = (stats.byCategory[category] || 0) + inc;
    if (host) stats.bySite[host] = (stats.bySite[host] || 0) + inc;
    chrome.storage.local.set({ adsSkipped: total, stats });
    refreshBadge();
  });
}

// ---- snooze ---------------------------------------------------------------
function snooze(minutes) {
  const ms = Math.max(1, minutes || 5) * 60 * 1000;
  const until = Date.now() + ms;
  chrome.storage.local.set({ snoozeUntil: until }, refreshBadge);
  try {
    // alarms only accept whole-minute granularity reliably; round up.
    chrome.alarms.create("snooze-end", { delayInMinutes: Math.max(1, minutes || 5) });
  } catch (e) {}
}
function endSnooze() {
  chrome.storage.local.set({ snoozeUntil: 0 }, refreshBadge);
}

// ---- messages from content scripts / popup / options ----------------------
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (!msg) return;

  if (msg.type === "kill") {
    recordKill(msg.n || 1, msg.category, msg.host);
    return;
  }

  if (msg.type === "snooze") {
    snooze(msg.minutes || 5);
    return;
  }
  if (msg.type === "unsnooze") {
    endSnooze();
    return;
  }

  if (msg.type === "openOptions") {
    try { chrome.runtime.openOptionsPage(); } catch (e) {}
    return;
  }

  if (msg.type === "setRuleset") {
    const opt = msg.enabled
      ? { enableRulesetIds: [msg.id] }
      : { disableRulesetIds: [msg.id] };
    chrome.declarativeNetRequest
      .updateEnabledRulesets(opt)
      .then(() => sendResponse({ ok: true }))
      .catch((e) => sendResponse({ ok: false, error: String(e) }));
    return true; // async response
  }

  if (msg.type === "rebuildDynamic") {
    rebuildDynamicRules()
      .then((r) => sendResponse(r))
      .catch((e) => sendResponse({ ok: false, error: String(e) }));
    return true;
  }

  if (msg.type === "checkTwitch") {
    checkTwitchUpdate().then((r) => sendResponse(r));
    return true;
  }

  if (msg.type === "checkSelfUpdate") {
    checkSelfUpdate().then((r) => sendResponse(r));
    return true;
  }

  if (msg.type === "twitchStatus") {
    twitchStatus(msg.logins).then(sendResponse);
    return true; // async
  }

  // YouTube server-side ad-enforcement state (reported by youtube.js). Drives the popup notice;
  // the actual pause/auto-retry lives in the page's localStorage (synchronous, per-origin).
  if (msg.type === "ytEnforced") {
    const set = { ytEnforced: true, ytEnforcedAt: Date.now() };
    if (msg.until) set.ytEnforcedUntil = msg.until;
    chrome.storage.local.set(set);
    return;
  }
  if (msg.type === "ytRecovered") {
    chrome.storage.local.set({ ytEnforced: false, ytRecoveredAt: Date.now() });
    return;
  }

  // YouTube Reclaim: proxy the companion calls through the worker. A web page (esp. on Brave)
  // can't reach 127.0.0.1 directly — mixed-content / PNA / Brave's localhost block all bite —
  // but the worker has the extension origin + host permissions, so its fetch is allowed.
  if (msg.type === "companion") {
    const port = msg.port || 47823;
    const base = "http://127.0.0.1:" + port;
    const path = msg.path === "resolve"
      ? "/resolve?v=" + encodeURIComponent(msg.v || "") + (msg.fresh ? "&fresh=1" : "")
      : "/health";
    fetch(base + path, { cache: "no-store" })
      .then((r) => r.json())
      .then((d) => sendResponse({ ok: true, data: d }))
      .catch((e) => {
        // Companion unreachable — it may just not be running. Ask the browser to
        // launch it so the next probe (Reclaim re-polls health) succeeds.
        ensureCompanionLaunched(true);
        sendResponse({ ok: false, error: String((e && e.message) || e) });
      });
    return true; // async
  }
});

// ---- Twitch live-status (for pinned streamers) ----------------------------
// Routed through the worker so it isn't blocked by twitch.tv's strict page CSP, and
// gets the extension's cross-origin privileges. Uses Twitch's public web Client-ID
// (the same one the site itself + FFZ/BTTV use) — read-only public stream data, no login.
const TWITCH_GQL = "https://gql.twitch.tv/gql";
const TWITCH_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko";
async function twitchStatus(logins) {
  const list = (Array.isArray(logins) ? logins : []).filter(Boolean).slice(0, 50);
  if (!list.length) return { users: [] };
  const query =
    "query($logins:[String!]){users(logins:$logins){login displayName stream{ id type viewersCount game{displayName} }}}";
  try {
    const res = await fetch(TWITCH_GQL, {
      method: "POST",
      headers: { "Client-ID": TWITCH_CLIENT_ID, "Content-Type": "application/json" },
      body: JSON.stringify({ query, variables: { logins: list } }),
    });
    if (!res.ok) return { error: "HTTP " + res.status };
    const data = await res.json();
    const users = (data && data.data && data.data.users) || [];
    return {
      users: users.filter(Boolean).map((u) => ({
        login: u.login,
        name: u.displayName || u.login,
        live: !!u.stream,
        viewers: u.stream ? u.stream.viewersCount || 0 : 0,
        game: u.stream && u.stream.game ? u.stream.game.displayName : "",
      })),
    };
  } catch (e) {
    return { error: String(e) };
  }
}

// ---- keyboard commands ----------------------------------------------------
// Snooze is handled here directly; the page-level commands are relayed to the
// active tab's content scripts (they own the panel + zap UI).
if (chrome.commands && chrome.commands.onCommand) {
  chrome.commands.onCommand.addListener((command) => {
    if (command === "snooze") {
      snooze(5);
      return;
    }
    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
      const id = tabs && tabs[0] && tabs[0].id;
      if (id == null) return;
      try { chrome.tabs.sendMessage(id, { type: "cmd", cmd: command }); } catch (e) {}
    });
  });
}

// ---- custom filter lists -> dynamic DNR rules -----------------------------
// A trimmed EasyList -> declarativeNetRequest converter (mirrors tools/build.mjs).
const TYPE_MAP = {
  script: "script", image: "image", stylesheet: "stylesheet", css: "stylesheet",
  object: "object", "object-subrequest": "object", xmlhttprequest: "xmlhttprequest",
  xhr: "xmlhttprequest", subdocument: "sub_frame", document: "main_frame", font: "font",
  media: "media", websocket: "websocket", ping: "ping", other: "other", beacon: "ping",
};
const UNSUPPORTED = new Set([
  "csp", "redirect", "redirect-rule", "removeparam", "replace", "cookie", "header",
  "inline-script", "inline-font", "genericblock", "generichide", "elemhide",
  "specifichide", "empty", "mp4", "webrtc", "popunder", "popup", "stealth",
  "urlblock", "content", "match-case", "rewrite", "permissions",
]);
function isAscii(s) {
  for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) > 127) return false;
  return true;
}
function convertList(text) {
  const block = [], allow = [], cosmetic = [];
  for (const raw of text.split("\n")) {
    const line = raw.trim();
    if (!line || line[0] === "!" || line[0] === "[") continue;
    // generic cosmetic rule (##selector, not domain-scoped) -> cosmetic.js
    const cm = line.match(/^##(.+)$/);
    if (cm && cm[1] && !/[#@?$]/.test(cm[1][0])) { cosmetic.push(cm[1].trim()); continue; }
    if (/(\#\#|\#\@\#|\#\?\#|\#\$\#)/.test(line)) continue; // other cosmetic forms: skip
    let isAllow = false, rule = line;
    if (rule.startsWith("@@")) { isAllow = true; rule = rule.slice(2); }
    if (rule.startsWith("/") && rule.lastIndexOf("/") > 0 && !rule.includes("$")) continue;
    let filter = rule, optionsStr = "";
    const dollar = rule.lastIndexOf("$");
    if (dollar !== -1 && !rule.startsWith("/")) { filter = rule.slice(0, dollar); optionsStr = rule.slice(dollar + 1); }
    if (!filter || !isAscii(filter) || filter.length > 480) continue;
    if (filter.startsWith("/") && filter.endsWith("/")) continue;
    const cond = {}, types = [], excTypes = [];
    let domainType = null, initiatorDomains = null, excludedInitiatorDomains = null, bad = false;
    if (optionsStr) {
      for (let opt of optionsStr.split(",")) {
        opt = opt.trim(); if (!opt) continue;
        let neg = false; if (opt.startsWith("~")) { neg = true; opt = opt.slice(1); }
        if (opt === "third-party" || opt === "3p") domainType = neg ? "firstParty" : "thirdParty";
        else if (opt === "first-party" || opt === "1p") domainType = neg ? "thirdParty" : "firstParty";
        else if (opt.startsWith("domain=")) {
          initiatorDomains = []; excludedInitiatorDomains = [];
          for (const d of opt.slice(7).split("|")) {
            if (!d) continue;
            if (d.startsWith("~")) excludedInitiatorDomains.push(d.slice(1)); else initiatorDomains.push(d);
          }
        } else if (TYPE_MAP[opt]) (neg ? excTypes : types).push(TYPE_MAP[opt]);
        else if (opt === "important" || opt === "all") { /* tolerated */ }
        else { bad = true; break; }
      }
    }
    if (bad) continue;
    cond.urlFilter = filter;
    if (domainType) cond.domainType = domainType;
    if (types.length) cond.resourceTypes = [...new Set(types)];
    const exc = new Set(excTypes);
    if (!types.length && !isAllow) exc.add("main_frame");
    if (exc.size) cond.excludedResourceTypes = [...exc];
    if (initiatorDomains && initiatorDomains.length) cond.initiatorDomains = initiatorDomains;
    if (excludedInitiatorDomains && excludedInitiatorDomains.length) cond.excludedInitiatorDomains = excludedInitiatorDomains;
    (isAllow ? allow : block).push({ action: { type: isAllow ? "allow" : "block" }, condition: cond });
  }
  return { block, allow, cosmetic };
}

async function rebuildDynamicRules() {
  const { customLists } = await chrome.storage.local.get("customLists");
  const lists = (customLists || []).filter((l) => l && l.enabled !== false && l.url);
  let block = [], allow = [];
  const cosmetic = new Set();
  const seen = new Set();
  const perList = {};
  for (const l of lists) {
    let count = 0;
    try {
      const res = await fetch(l.url, { cache: "no-store", headers: { "user-agent": "adblockmaxxer" } });
      if (!res.ok) { perList[l.url] = { error: "HTTP " + res.status }; continue; }
      const out = convertList(await res.text());
      for (const r of out.block) {
        const k = "b" + JSON.stringify(r.condition);
        if (seen.has(k)) continue; seen.add(k); block.push(r); count++;
      }
      for (const r of out.allow) {
        const k = "a" + JSON.stringify(r.condition);
        if (seen.has(k)) continue; seen.add(k); allow.push(r); count++;
      }
      for (const c of out.cosmetic) cosmetic.add(c);
      perList[l.url] = { rules: count, cosmetic: out.cosmetic.length };
    } catch (e) {
      perList[l.url] = { error: String(e && e.message || e) };
    }
  }
  if (block.length > DYNAMIC_RULE_CAP) block = block.slice(0, DYNAMIC_RULE_CAP - allow.length);
  const addRules = [
    ...allow.map((r) => ({ ...r, priority: 2 })),
    ...block.map((r) => ({ ...r, priority: 1 })),
  ].map((r, i) => ({ id: i + 1, ...r }));

  const existing = await chrome.declarativeNetRequest.getDynamicRules();
  await chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds: existing.map((r) => r.id),
    addRules,
  });
  await chrome.storage.local.set({
    customCosmetic: [...cosmetic],
    customListStatus: perList,
    customListCount: addRules.length,
  });
  return { ok: true, rules: addRules.length, cosmetic: cosmetic.size, perList };
}

// ---- Twitch update detection ----------------------------------------------
function parseVer(txt) {
  const m = txt && txt.match(/@version\s+([\d.]+)/);
  return m ? m[1] : "0";
}
function cmpVer(a, b) {
  const pa = a.split(".").map(Number);
  const pb = b.split(".").map(Number);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const d = (pa[i] || 0) - (pb[i] || 0);
    if (d) return d > 0 ? 1 : -1;
  }
  return 0;
}
async function checkTwitchUpdate() {
  try {
    const localTxt = await (await fetch(chrome.runtime.getURL("vendor/video-swap-new.user.js"))).text();
    const remoteTxt = await (await fetch(TWITCH_SRC, { cache: "no-store" })).text();
    const local = parseVer(localTxt);
    const remote = parseVer(remoteTxt);
    const available = cmpVer(remote, local) > 0;
    await chrome.storage.local.set({
      twitchLocalVer: local,
      twitchRemoteVer: remote,
      twitchUpdateAvailable: available,
      twitchCheckedAt: Date.now(),
    });
    return { local, remote, available };
  } catch (e) {
    return { error: String(e) };
  }
}

// ---- performance mode: suspend idle background tabs -----------------------
// Frees the RAM/CPU that backgrounded tabs hold. A suspended tab is frozen by the browser
// and reloads instantly when you click back to it. We never touch the active tab, audible
// tabs (music/calls), pinned tabs, or already-discarded ones. Off by default.
const PERF_DEFAULT_MINS = 10;
async function discardIdleTabs() {
  const { perfMode, perfSuspendMins } = await chrome.storage.local.get(["perfMode", "perfSuspendMins"]);
  if (!perfMode) return;
  const idleMs = Math.max(1, perfSuspendMins || PERF_DEFAULT_MINS) * 60 * 1000;
  // remember when each tab last played audio, so a music tab that's briefly silent
  // between songs (playlistmaxxing, a paused podcast, etc.) stays protected the same
  // way Spotify does — we only sleep it once it's had NO audio for the whole window.
  const now = Date.now();
  const audStore = await chrome.storage.session.get("lastAudible");
  const lastAudible = audStore.lastAudible || {};
  const cutoff = Date.now() - idleMs;
  let tabs;
  try { tabs = await chrome.tabs.query({}); } catch (e) { return; }
  const live = new Set();
  for (const t of tabs) {
    if (!t) continue;
    live.add(t.id);
    if (t.audible) lastAudible[t.id] = now;               // sample audibility each run
    if (t.active || t.audible || t.pinned || t.discarded) continue;
    // Only suspend a tab we can PROVE is idle. If lastAccessed is unavailable
    // (older browsers) or recent, leave it alone — never guess.
    if (typeof t.lastAccessed !== "number" || t.lastAccessed > cutoff) continue;
    // ...and never sleep a tab that played audio within the idle window.
    if (typeof lastAudible[t.id] === "number" && lastAudible[t.id] > cutoff) continue;
    try { chrome.tabs.discard(t.id); } catch (e) {}
  }
  for (const id of Object.keys(lastAudible)) if (!live.has(+id)) delete lastAudible[id]; // prune closed tabs
  try { await chrome.storage.session.set({ lastAudible }); } catch (e) {}
}
function syncPerfAlarm(on) {
  try {
    if (on) chrome.alarms.create("perf-discard", { periodInMinutes: 1, delayInMinutes: 1 });
    else chrome.alarms.clear("perf-discard");
  } catch (e) {}
}

// ---- YouTube Reclaim ruleset ----------------------------------------------
// The "youtube-reclaim" static ruleset strips YouTube's CSP so our in-page player can load
// media from the local companion (127.0.0.1). We only want that header removed while the
// Reclaim feature is on, so it's enabled/disabled with the ytReclaim toggle (default ON).
function syncReclaimRule(on) {
  const opt = on ? { enableRulesetIds: ["youtube-reclaim"] }
                 : { disableRulesetIds: ["youtube-reclaim"] };
  try { chrome.declarativeNetRequest.updateEnabledRulesets(opt).catch(() => {}); } catch (e) {}
}
function applyReclaimSetting() {
  chrome.storage.local.get("ytReclaim", (r) => {
    const on = !(r && r.ytReclaim === false); // default ON
    syncReclaimRule(on);
  });
}

// ---- companion auto-launch (native messaging) -----------------------------
// The companion is a local server the Reclaim player talks to. Rather than make the
// user start it by hand, we let the browser launch it on demand: connectNative pokes
// our registered native host, which boots the companion (detached) and exits. We only
// do this when Reclaim is on, and we throttle it so we never spawn-spam.
const NATIVE_HOST = "com.maxxtopia.abmcompanion";
let _companionPokeAt = 0;
function ensureCompanionLaunched(force) {
  chrome.storage.local.get("ytReclaim", (r) => {
    const on = !(r && r.ytReclaim === false); // default ON
    if (!on) return;
    const now = Date.now();
    if (!force && now - _companionPokeAt < 60000) return; // at most once a minute
    _companionPokeAt = now;
    try {
      // Connecting is enough to make the browser launch the host; the host spawns the
      // detached server then exits, so the port closes on its own. Swallow any error
      // (host not registered yet = companion has never run; degrades to no-op).
      const port = chrome.runtime.connectNative(NATIVE_HOST);
      port.onDisconnect.addListener(() => { void chrome.runtime.lastError; });
    } catch (e) {}
  });
}

// ---- extension self-update detection --------------------------------------
async function checkSelfUpdate() {
  const local = (chrome.runtime.getManifest && chrome.runtime.getManifest().version) || "0";
  try {
    const res = await fetch(VERSION_MANIFEST, { cache: "no-store" });
    if (!res.ok) throw new Error("HTTP " + res.status);
    const data = await res.json();
    const remote = (data && data.version) || "0";
    const available = cmpVer(remote, local) > 0;
    await chrome.storage.local.set({
      selfLocalVer: local,
      selfRemoteVer: remote,
      selfUpdateAvailable: available,
      selfUpdateUrl: (data && data.url) || DOWNLOAD_FALLBACK,
      selfUpdateNotes: (data && data.notes) || "",
      selfCheckedAt: Date.now(),
    });
    return { local, remote, available };
  } catch (e) {
    // a failed check must never claim an update — leave any prior state, just timestamp it
    await chrome.storage.local.set({ selfLocalVer: local, selfCheckErr: String(e), selfCheckedAt: Date.now() });
    return { local, error: String(e) };
  }
}

// ---- schedule -------------------------------------------------------------
chrome.runtime.onInstalled.addListener((details) => {
  chrome.alarms.create("twitch-update", { periodInMinutes: 720, delayInMinutes: 1 });
  chrome.alarms.create("self-update", { periodInMinutes: 720, delayInMinutes: 1 });
  checkTwitchUpdate();
  // On a fresh install OR an update, re-check so the "update available" flag clears
  // the moment the user has actually moved to the newest build.
  checkSelfUpdate();
  rebuildDynamicRules().catch(() => {});
  chrome.storage.local.get("perfMode", (r) => syncPerfAlarm(!!(r && r.perfMode)));
  // Default the YouTube Reclaim feature ON for new installs, then enable its CSP ruleset.
  chrome.storage.local.get("ytReclaim", (r) => {
    if (!r || r.ytReclaim === undefined) chrome.storage.local.set({ ytReclaim: true });
    applyReclaimSetting();
    ensureCompanionLaunched();
  });
  // First-run onboarding tab — opens once on a fresh install. There's no store
  // listing to explain the product, so the welcome page does the introducing.
  if (details && details.reason === "install") {
    try { chrome.tabs.create({ url: chrome.runtime.getURL("src/page/welcome.html") }); } catch (e) {}
  }
});
if (chrome.runtime.onStartup) {
  chrome.runtime.onStartup.addListener(() => {
    checkTwitchUpdate(); checkSelfUpdate(); refreshBadge();
    chrome.storage.local.get("perfMode", (r) => syncPerfAlarm(!!(r && r.perfMode)));
    applyReclaimSetting();
    ensureCompanionLaunched(); // boot the companion as soon as the browser opens
  });
}
// keep the suspend alarm in sync the instant the toggle flips
chrome.storage.onChanged.addListener((c) => {
  if (c.perfMode) { syncPerfAlarm(!!c.perfMode.newValue); if (c.perfMode.newValue) discardIdleTabs(); }
  if (c.ytReclaim) {
    const on = c.ytReclaim.newValue !== false;
    syncReclaimRule(on);
    if (on) ensureCompanionLaunched(true); // user just enabled Reclaim -> start companion now
  }
});
chrome.alarms.onAlarm.addListener((a) => {
  if (a.name === "twitch-update") checkTwitchUpdate();
  if (a.name === "self-update") checkSelfUpdate();
  if (a.name === "snooze-end") endSnooze();
  if (a.name === "perf-discard") discardIdleTabs();
});
