const DEFAULTS = {
  enabled: true,
  blockPopups: true,
  removeOverlays: true,
  detectVideos: true,
  skipAds: true,
  showFoundNotice: true,
  blockLevel: "balanced",
  autoOpenDetected: true,
  aggressiveMode: false,
  allowlist: [],
};

const mediaByTab = new Map();
const probedByTab = new Map();
const providersByTab = new Map();
const blockedByTab = new Map();
const autoOpenedByTab = new Set();
const protectedTabs = new Set();
const pendingProtectedChildren = new Map();
const smartAttempts = new Map();
const HIRO_API = "https://hiroplay.lat/api/v1";
const PROTECTED_AD_HOSTS = [
  "doubleclick.net",
  "popads.net",
  "popcash.net",
  "propellerads.com",
  "onclicka.com",
  "adsterra.com",
  "exoclick.com",
  "trafficjunky.net",
];

const SMART_SOURCE_TIMEOUT_MS = 10000;
const SMART_VALIDATION_TIMEOUT_MS = 12000;
const SMART_GLOBAL_TIMEOUT_MS = 45000;
const PROVIDER_RULES = [
  ['streamwish', /streamwish/i],
  ['filemoon', /filemoon/i],
  ['vidhide', /vidhide/i],
  ['voe', /(voesx|voe(?=[._\/-]))/i],
  ['doodstream', /(doodstream|dood(?=[._\/-]))/i],
  ['medixiru', /medixiru/i],
  ['streamtape', /streamtape/i],
  ['mixdrop', /mixdrop/i],
  ['filelions', /filelions/i],
  ['lulustream', /lulustream/i],
];
const STABLE_PROVIDER_KEYS = new Set(['streamwish','filemoon','vidhide','voe','doodstream','medixiru']);

function providerKey(url = "") {
  // Reconocer proveedores SOLO por hostname. Nunca por query/path del wrapper.
  let host = "";
  try { host = new URL(String(url)).hostname.toLowerCase(); } catch { return ""; }
  return PROVIDER_RULES.find(([, pattern]) => pattern.test(host))?.[0] || "";
}
function isForbiddenIntermediarySource(url = "") {
  try {
    const host = new URL(String(url)).hostname.toLowerCase();
    return host === "player.repelis24.life" || host.endsWith(".player.repelis24.life");
  } catch { return false; }
}
function providerHost(url = "") {
  try { return new URL(url).hostname.toLowerCase(); } catch { return ""; }
}
function isKnownProviderUrl(url = "") { return /^https?:/i.test(url) && !isForbiddenIntermediarySource(url) && Boolean(providerKey(url)); }
function siteFamily(host = "") {
  const parts = String(host).toLowerCase().split(".").filter(Boolean);
  return parts.length >= 2 ? parts.slice(-2).join(".") : parts.join(".");
}
function sameSiteFamily(left = "", right = "") {
  try { return siteFamily(new URL(left).hostname) === siteFamily(new URL(right).hostname); } catch { return false; }
}
function isPlayableMedia(url = "", type = "") {
  return (/\.(mp4|webm|m3u8)(?:[?#]|$)/i.test(url) || /^(video\/|application\/(?:vnd\.apple\.mpegurl|x-mpegurl))/i.test(type)) && !isLikelyAd(url, type);
}
function isIntermediaryUrl(url = "", site = "") {
  if (!/^https?:/i.test(url)) return false;
  if (isForbiddenIntermediarySource(url)) return true;
  if (isKnownProviderUrl(url) || isMedia(url) || isLikelyAd(url)) return false;
  try {
    const parsed = new URL(url), marker = `${parsed.hostname}${parsed.pathname}`;
    return /(?:^|[._\/-])(player|embed|watch|stream|video|play)(?:[._\/-]|$)/i.test(marker);
  } catch { return false; }
}
function sourceLabel(url = "") { return providerKey(url) || providerHost(url) || "directo"; }

async function syncProtectedNetworkRules() {
  const old = await chrome.declarativeNetRequest.getSessionRules(),
    removeRuleIds = old
      .filter((rule) => rule.id >= 20000 && rule.id < 20100)
      .map((rule) => rule.id),
    tabIds = [...protectedTabs];
  const addRules = tabIds.length
    ? PROTECTED_AD_HOSTS.map((host, index) => ({
        id: 20000 + index,
        priority: 10,
        action: { type: "block" },
        condition: {
          urlFilter: `||${host}^`,
          tabIds,
          resourceTypes: [
            "script",
            "image",
            "sub_frame",
            "xmlhttprequest",
            "media",
          ],
        },
      }))
    : [];
  await chrome.declarativeNetRequest.updateSessionRules({
    removeRuleIds,
    addRules,
  });
}
chrome.storage.session
  .get({ protectedTabIds: [] })
  .then(({ protectedTabIds }) => {
    protectedTabIds.forEach((id) => protectedTabs.add(id));
    return syncProtectedNetworkRules();
  })
  .catch(() => {});
async function persistProtectedTabs() {
  await chrome.storage.session.set({ protectedTabIds: [...protectedTabs] });
  await syncProtectedNetworkRules();
}
function isAllowedPlayerUrl(url = "") {
  return /^https?:/i.test(url) && !isForbiddenIntermediarySource(url) && (isKnownProviderUrl(url) || isPlayableMedia(url));
}

chrome.runtime.onInstalled.addListener(async (details) => {
  const stored = await chrome.storage.local.get(DEFAULTS);
  if (details.reason === "update") {
    stored.blockLevel = "balanced";
    stored.aggressiveMode = false;
  }
  await chrome.storage.local.set(stored);
  await syncAllowlistRules(stored.allowlist || []);
  await syncBlockLevel(stored.blockLevel || "balanced");
  await chrome.storage.session.set({ showResumeSuggestion: true });
  await ensureInstallId().catch(()=>{});
  reportExtensionPresence(true).catch(()=>{});
});
chrome.runtime.onStartup.addListener(() => {
  chrome.storage.session.set({ showResumeSuggestion: true });
  ensureInstallId().then(()=>reportExtensionPresence()).catch(()=>{});
});

async function syncAllowlistRules(domains) {
  const old = await chrome.declarativeNetRequest.getDynamicRules();
  const removeRuleIds = old
    .filter((rule) => rule.id >= 10000 && rule.id < 11000)
    .map((rule) => rule.id);
  const addRules = domains.slice(0, 1000).map((domain, index) => ({
    id: 10000 + index,
    priority: 100,
    action: { type: "allowAllRequests" },
    condition: {
      initiatorDomains: [domain],
      resourceTypes: ["main_frame", "sub_frame"],
    },
  }));
  await chrome.declarativeNetRequest.updateDynamicRules({
    removeRuleIds,
    addRules,
  });
}

chrome.storage.onChanged.addListener((changes) => {
  if (changes.allowlist)
    syncAllowlistRules(changes.allowlist.newValue || []).catch(() => {});
  if (changes.blockLevel)
    syncBlockLevel(changes.blockLevel.newValue).catch(() => {});
});

async function syncBlockLevel(level = "balanced") {
  const enableRulesetIds =
    level === "maximum"
      ? ["strict", "maximum"]
      : level === "strict"
        ? ["strict"]
        : [];
  const disableRulesetIds = ["strict", "maximum"].filter(
    (id) => !enableRulesetIds.includes(id),
  );
  await chrome.declarativeNetRequest.updateEnabledRulesets({
    enableRulesetIds,
    disableRulesetIds,
  });
}

chrome.tabs.onRemoved.addListener(async (tabId) => {
  mediaByTab.delete(tabId);
  probedByTab.delete(tabId);
  providersByTab.delete(tabId);
  blockedByTab.delete(tabId);
  autoOpenedByTab.delete(tabId);
  const smart = smartAttempts.get(tabId);
  if (smart?.timer) clearTimeout(smart.timer);
  smartAttempts.delete(tabId);
  const { scanTabs = {} } = await chrome.storage.session.get({ scanTabs: {} });
  delete scanTabs[tabId];
  await chrome.storage.session.set({ scanTabs });
  if (protectedTabs.delete(tabId)) await persistProtectedTabs();
  const { protectedMeta = {} } = await chrome.storage.session.get({
    protectedMeta: {},
  });
  delete protectedMeta[tabId];
  await chrome.storage.session.set({ protectedMeta });
});

async function updateLocalSourceHealth(url, outcome, elapsedMs = 0) {
  const key = providerKey(url) || providerHost(url) || String(url);
  if (!key) return;
  const stored = await chrome.storage.local.get({ hiroSourceHealth: {} }), health = stored.hiroSourceHealth || {}, row = health[key] || { attempts: 0, successes: 0, failures: 0, timeouts: 0, avgMs: 0, lastSuccess: 0, lastFailure: 0 };
  if (outcome === "attempt") row.attempts += 1;
  if (outcome === "success") {
    row.successes += 1; row.lastSuccess = Date.now();
    if (elapsedMs > 0) row.avgMs = row.avgMs > 0 ? Math.round((row.avgMs * Math.max(0, row.successes - 1) + elapsedMs) / row.successes) : elapsedMs;
  }
  if (outcome === "failure") { row.failures += 1; row.lastFailure = Date.now(); }
  if (outcome === "timeout") { row.timeouts += 1; row.failures += 1; row.lastFailure = Date.now(); }
  health[key] = row;
  await chrome.storage.local.set({ hiroSourceHealth: health });
}
async function reportSourceOutcome(attempt, outcome, mediaUrl = "") {
  const source = attempt?.currentSource || "";
  if (!source) return;
  const elapsed = Math.max(0, Date.now() - (attempt.currentStartedAt || Date.now()));
  updateLocalSourceHealth(source, outcome, elapsed).catch(() => {});
  hiroApi('/sources/result', { method: 'POST', body: { provider_url: source, media_url: mediaUrl || attempt.mediaUrl || '', content_url: attempt.postUrl || '', source_kind: isPlayableMedia(source) ? 'direct' : 'provider', outcome, elapsed_ms: elapsed } }).catch(() => {});
}
async function rankSmartSources(sources) {
  const unique = [...new Set((sources || []).map(String))].filter((value) => /^https?:/i.test(value) && !isForbiddenIntermediarySource(value) && (isKnownProviderUrl(value) || isPlayableMedia(value)));
  const { hiroSourceHealth = {} } = await chrome.storage.local.get({ hiroSourceHealth: {} });
  const score = (url) => {
    if (isPlayableMedia(url)) return 10000;
    const key = providerKey(url) || providerHost(url), row = hiroSourceHealth[key] || {};
    const stable = STABLE_PROVIDER_KEYS.has(providerKey(url)) ? 75 : 55;
    const attempts = Number(row.attempts || 0), successes = Number(row.successes || 0), failures = Number(row.failures || 0);
    const rate = attempts > 0 ? successes / attempts : 0.5;
    const speed = row.avgMs ? Math.max(-20, 25 - Number(row.avgMs) / 500) : 0;
    const recent = row.lastSuccess && Date.now() - row.lastSuccess < 86400000 ? 10 : 0;
    return stable + rate * 60 + speed + recent - failures * 0.35;
  };
  return unique.sort((a, b) => score(b) - score(a)).slice(0, 10);
}
async function finishSmartAttempt(tabId, message = 'No encontramos un reproductor disponible que funcione en este momento.') {
  const attempt = smartAttempts.get(tabId);
  if (!attempt) return;
  if (attempt.timer) clearTimeout(attempt.timer);
  attempt.finished = true;
  smartAttempts.delete(tabId);
  if (attempt.playerTabId != null) {
    chrome.tabs.sendMessage(attempt.playerTabId, { type: 'SMART_PLAYER_FAILED', text: message, retry: { urls: attempt.sources, title: attempt.title || 'Video', image: attempt.image || '', postUrl: attempt.postUrl || '', series: attempt.series || null } }).catch(() => {});
  }
  await chrome.tabs.remove(tabId).catch(() => {});
}
async function runNextSmartSource(tabId, reason = 'timeout') {
  const attempt = smartAttempts.get(tabId);
  if (!attempt || attempt.finished) return;
  if (attempt.timer) clearTimeout(attempt.timer);
  if (attempt.currentSource && reason !== 'start') await reportSourceOutcome(attempt, reason === 'timeout' ? 'timeout' : 'failure');
  const elapsedGlobal = Date.now() - attempt.globalStartedAt;
  if (elapsedGlobal >= SMART_GLOBAL_TIMEOUT_MS) return finishSmartAttempt(tabId);
  attempt.index += 1;
  if (attempt.index >= attempt.sources.length) return finishSmartAttempt(tabId);
  const next = attempt.sources[attempt.index];
  attempt.currentSource = next;
  attempt.currentStartedAt = Date.now();
  attempt.mediaUrl = '';
  attempt.waitingValidation = false;
  updateLocalSourceHealth(next, 'attempt').catch(() => {});
  autoOpenedByTab.delete(tabId);
  mediaByTab.delete(tabId);
  providersByTab.set(tabId, isKnownProviderUrl(next) ? [next] : []);
  const { protectedMeta = {} } = await chrome.storage.session.get({ protectedMeta: {} });
  protectedMeta[tabId] = { ...(protectedMeta[tabId] || {}), title: attempt.title || '', image: attempt.image || '', postUrl: attempt.postUrl || '', series: attempt.series || null, providerUrl: next, sourceIndex: attempt.index, sources: attempt.sources, playerTabId: attempt.playerTabId };
  await chrome.storage.session.set({ protectedMeta });
  const remaining = Math.max(1000, SMART_GLOBAL_TIMEOUT_MS - (Date.now() - attempt.globalStartedAt));
  if (isPlayableMedia(next)) {
    if (attempt.playerTabId != null) {
      const contextId = crypto.randomUUID(), { playerContexts = {} } = await chrome.storage.session.get({ playerContexts: {} });
      playerContexts[contextId] = { ...protectedMeta[tabId], createdAt: Date.now() };
      await chrome.storage.session.set({ playerContexts });
      const target = chrome.runtime.getURL(`player.html?src=${encodeURIComponent(next)}&title=${encodeURIComponent(attempt.title || 'Video')}&image=${encodeURIComponent(attempt.image || '')}&post=${encodeURIComponent(attempt.postUrl || '')}&context=${encodeURIComponent(contextId)}&attempt=${encodeURIComponent(tabId)}`);
      await chrome.tabs.update(attempt.playerTabId, { url: target, active: true }).catch(() => {});
    }
    attempt.mediaUrl = next;
    attempt.waitingValidation = true;
    attempt.timer = setTimeout(() => runNextSmartSource(tabId, 'timeout'), Math.min(SMART_VALIDATION_TIMEOUT_MS, remaining));
    return;
  }
  if (!isKnownProviderUrl(next)) return runNextSmartSource(tabId, 'invalid');
  if (attempt.playerTabId != null) {
    const searchUrl = chrome.runtime.getURL(`player.html?search=1&title=${encodeURIComponent(attempt.title || 'Video')}&image=${encodeURIComponent(attempt.image || '')}&post=${encodeURIComponent(attempt.postUrl || '')}`);
    await chrome.tabs.update(attempt.playerTabId, { url: searchUrl, active: true }).catch(() => {});
  }
  await chrome.tabs.update(tabId, { url: next, active: false }).catch(() => {});
  attempt.timer = setTimeout(() => runNextSmartSource(tabId, 'timeout'), Math.min(SMART_SOURCE_TIMEOUT_MS, remaining));
}

function startSmartSourceTimer(tabId, data) {
  const previous = smartAttempts.get(tabId);
  if (previous?.timer) clearTimeout(previous.timer);
  const attempt = { ...data, index: -1, finished: false, currentSource: '', currentStartedAt: 0, mediaUrl: '', globalStartedAt: Date.now(), timer: null };
  smartAttempts.set(tabId, attempt);
  runNextSmartSource(tabId, 'start').catch(() => finishSmartAttempt(tabId));
}
chrome.tabs.onUpdated.addListener((tabId, info) => {
  if (info.status === "loading") {
    mediaByTab.delete(tabId);
    providersByTab.delete(tabId);
    autoOpenedByTab.delete(tabId);
    blockedByTab.delete(tabId);
  }
  if (protectedTabs.has(tabId) && info.status === "complete") {
    chrome.scripting
      .executeScript({
        target: { tabId, allFrames: true },
        world: "MAIN",
        func: () => {
          if (window.__cleanstreamProtected) return;
          window.__cleanstreamProtected = true;
          const original = window.open;
          window.open = function (url) {
            try {
              const value = String(url || "");
              if (
                /(player|embed|streamwish|filemoon|vidhide|voesx|voe(?=[._\/-])|doodstream|medixiru|streamtape|mixdrop|filelions|lulustream)/i.test(
                  value,
                )
              ) {
                location.href = value;
              }
            } catch {}
            return null;
          };
        },
      })
      .catch(() => {});
    chrome.scripting
      .executeScript({ target: { tabId, allFrames: true }, files: ["content.js"] })
      .then(() => chrome.tabs.sendMessage(tabId, { type: "ENABLE_PROTECTED_MODE" }))
      .catch(() => {});
  }
});
chrome.tabs.onCreated.addListener((tab) => {
  if (tab.openerTabId != null && protectedTabs.has(tab.openerTabId)) {
    pendingProtectedChildren.set(tab.id, Date.now());
    setTimeout(() => {
      if (pendingProtectedChildren.has(tab.id)) {
        pendingProtectedChildren.delete(tab.id);
        chrome.tabs.remove(tab.id).catch(() => {});
      }
    }, 2500);
  }
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
  if (
    !pendingProtectedChildren.has(tabId) ||
    !changeInfo.url ||
    !/^https?:/i.test(changeInfo.url)
  )
    return;
  pendingProtectedChildren.delete(tabId);
  if (isAllowedPlayerUrl(changeInfo.url)) {
    protectedTabs.add(tabId);
    persistProtectedTabs();
  } else chrome.tabs.remove(tabId).catch(() => {});
});

function isMedia(url, type = "") {
  return (
    /\.(mp4|webm|m3u8|mpd)(?:[?#]|$)/i.test(url) ||
    /^(video\/|application\/(?:vnd\.apple\.mpegurl|x-mpegurl|dash\+xml))/i.test(
      type,
    )
  );
}

function isHls(url, type = "") {
  return (
    /\.m3u8(?:[?#]|$)/i.test(url) ||
    /(?:vnd\.apple\.mpegurl|x-mpegurl)/i.test(type)
  );
}

function isLikelyAd(url, type = "") {
  const value = `${url} ${type}`.toLowerCase();
  return (
    /(?:^|[./?&=_-])(vast|vmap|preroll|midroll|postroll|video[-_]?ad|advert|ads?|promo|sponsor)(?:[./?&=_-]|$)/i.test(
      value,
    ) ||
    /doubleclick|googlesyndication|googleadservices|adnxs|exoclick|trafficjunky|adsterra|propellerads|popads|popcash/i.test(
      value,
    )
  );
}

function decodeCandidate(value, base) {
  try {
    const cleaned = value
      .replace(/\\u002[fF]/g, "/")
      .replace(/\\\//g, "/")
      .replace(/&amp;/g, "&")
      .replace(/^["']|["']$/g, "");
    const url = new URL(cleaned, base);
    return /^https?:$/.test(url.protocol) ? url.href : null;
  } catch {
    return null;
  }
}

function urlsFromText(text, base) {
  text = text.replace(/\\u002[fF]/g, "/").replace(/\\\//g, "/");
  const found = new Set(),
    patterns = [
      /https?:\/\/[^\s"'<>]+/gi,
      /["']([^"']+\.(?:mp4|webm|m3u8|mpd)(?:\?[^"']*)?)["']/gi,
      /(?:file|src|source|playlist|manifest|videoUrl|contentUrl)\s*[:=]\s*["']([^"']+)["']/gi,
    ];
  for (const pattern of patterns) {
    let match;
    while ((match = pattern.exec(text)) && found.size < 150) {
      const raw = match[1] || match[0],
        url = decodeCandidate(raw, base);
      if (url) found.add(url);
    }
  }
  return [...found];
}

function snapshotPage() {
  const base = location.href,
    values = new Set(),
    add = (raw) => {
      if (!raw) return;
      try {
        const u = new URL(raw, base);
        if (/^https?:$/.test(u.protocol)) values.add(u.href);
      } catch {}
    };
  document
    .querySelectorAll(
      "video,source,a[href],iframe[src],embed[src],object[data],[data-src],[data-video],[data-url]",
    )
    .forEach((el) =>
      add(
        el.currentSrc ||
          el.src ||
          el.href ||
          el.data ||
          el.dataset?.src ||
          el.dataset?.video ||
          el.dataset?.url ||
          "",
      ),
    );
  const scripts = [...document.scripts]
    .map((s) => s.textContent || "")
    .join("\n")
    .slice(0, 1500000)
    .replace(/\\u002[fF]/g, "/")
    .replace(/\\\//g, "/");
  const patterns = [
    /https?:\/\/[^\s"'<>]+/gi,
    /["']([^"']+\.(?:mp4|webm|m3u8|mpd)(?:\?[^"']*)?)["']/gi,
    /(?:file|src|source|playlist|manifest|videoUrl|contentUrl)\s*[:=]\s*["']([^"']+)["']/gi,
  ];
  for (const pattern of patterns) {
    let match;
    while ((match = pattern.exec(scripts)) && values.size < 200)
      add(
        (match[1] || match[0])
          .replace(/\\u002[fF]/g, "/")
          .replace(/\\\//g, "/"),
      );
  }
  const episodes = [],
    seasons = [];
  document.querySelectorAll("a[href]").forEach((a) => {
    const label = (
        a.textContent ||
        a.title ||
        a.getAttribute("aria-label") ||
        ""
      )
        .trim()
        .replace(/\s+/g, " "),
      href = a.href,
      seasonMatch = label.match(/(?:#?temporada|season)\s*(\d+)/i);
    if (seasonMatch && href)
      seasons.push({ season: Number(seasonMatch[1]), url: href, label });
    if (
      !href ||
      !/(cap[ií]tulo|episodio|episode|temporada|season|\b\d+\s*x\s*\d+\b|\bT\d+\s*[Ex]\s*\d+\b|\bS\d+\s*E\d+\b)/i.test(
        `${label} ${href}`,
      )
    )
      return;
    const pair = label.match(/\b(\d+)\s*x\s*(\d+)\b/i),
      season = Number(
        pair?.[1] ||
          label.match(/(?:temporada|season|\bT|\bS)\s*(\d+)/i)?.[1] ||
          href.match(/\/serie\/\d+-(\d+)-(\d+)/i)?.[1] ||
          href.match(/(?:temporada|season|[-_/]t|[-_/]s)(\d+)/i)?.[1] ||
          1,
      ),
      episode = Number(
        pair?.[2] ||
          label.match(/(?:cap[ií]tulo|episodio|episode|[Ex])\s*(\d+)/i)?.[1] ||
          href.match(/\/serie\/\d+-(\d+)-(\d+)/i)?.[2] ||
          href.match(/(?:capitulo|episodio|episode|[-_/]e)(\d+)/i)?.[1] ||
          0,
      );
    if (episode) episodes.push({ url: href, label, season, episode });
  });
  const nextScript = document.getElementById("__NEXT_DATA__");
  if (nextScript?.textContent)
    try {
      const data = JSON.parse(nextScript.textContent),
        post =
          data?.props?.pageProps?.context?.post || data?.props?.pageProps?.post,
        slug =
          post?.slug?.name ||
          location.pathname.match(/\/series\/([^/]+)/i)?.[1];
      if (slug && Array.isArray(post?.seasons))
        for (const seasonData of post.seasons) {
          const season = Number(seasonData?.number);
          if (!Number.isFinite(season) || !Array.isArray(seasonData.episodes))
            continue;
          for (const item of seasonData.episodes) {
            const episode = Number(item?.number ?? item?.slug?.episode),
              itemSeason = Number(item?.slug?.season ?? season);
            if (
              !Number.isFinite(episode) ||
              episode < 1 ||
              !Number.isFinite(itemSeason)
            )
              continue;
            const url = new URL(
                `/series/${slug}/temporadas/${itemSeason}/episodios/${episode}`,
                location.origin,
              ).href,
              label =
                item?.title || `Temporada ${itemSeason} · Capítulo ${episode}`;
            episodes.push({
              url,
              label,
              season: itemSeason,
              episode,
              image: item?.image || "",
            });
          }
        }
    } catch {}
  document.querySelectorAll("select").forEach((select) =>
    [...select.options].forEach((option) => {
      const match = (option.textContent || "").match(
          /(?:temporada|season)\s*(\d+)/i,
        ),
        season = Number(match?.[1]),
        raw = option.value;
      if (!match || !Number.isFinite(season) || !raw) return;
      try {
        const url = new URL(raw, location.href);
        if (/^https?:$/.test(url.protocol))
          seasons.push({
            season,
            url: url.href,
            label: (option.textContent || "").trim(),
          });
      } catch {}
    }),
  );
  const cleanMeta = (value, title = false) => {
    let result = String(value ?? "")
      .replace(/&#(?:x([0-9a-f]+)|(\d+));/gi, (_, hex, dec) => {
        const code = Number.parseInt(hex || dec, hex ? 16 : 10);
        return Number.isFinite(code) && code > 0 && code <= 0x10ffff
          ? String.fromCodePoint(code)
          : " ";
      })
      .replace(
        /&(?:amp|quot|apos);/gi,
        (entity) =>
          ({ "&amp;": "&", "&quot;": '"', "&apos;": "'" })[
            entity.toLowerCase()
          ] || " ",
      )
      .replace(/<[^>]+>/g, " ")
      .replace(/\s+/g, " ")
      .trim();
    if (/^(?:undefined|null)$/i.test(result)) return "";
    result = result
      .replace(/\b(?:online|gratis|gratuito|free|en\s+línea)\b/gi, " ")
      .replace(/\b(?:repelis\s*24?|cuevana\s*3?|pelisplus|pelisflix)\b/gi, " ");
    if (title) result = result.replace(/^\s*(?:ver|watch)\s+/i, " ");
    return result
      .replace(/\s{2,}/g, " ")
      .replace(/^\s*[-|·:]\s*|\s*[-|·:]\s*$/g, "")
      .trim();
  };
  return {
    frameUrl: location.href,
    title: cleanMeta(
      document.querySelector('meta[property="og:title"]')?.content ||
        document.title,
      true,
    ),
    description: cleanMeta(
      document.querySelector(
        'meta[property="og:description"],meta[name="description"]',
      )?.content || "",
    ),
    image:
      document.querySelector(
        'meta[property="og:image"],meta[name="twitter:image"]',
      )?.content || "",
    urls: [...values],
    episodes,
    seasons,
  };
}

function activateLatinoTab() {
  const candidates = [
    ...document.querySelectorAll(
      'button,a,[role="tab"],[data-bs-toggle="tab"],[data-toggle="tab"]',
    ),
  ].filter((el) =>
    /\blatino\b/i.test((el.textContent || "").trim().replace(/\s+/g, " ")),
  );
  for (const el of candidates) {
    try {
      el.click();
    } catch {}
  }
  return candidates.map((el) => (el.textContent || "").trim());
}

function discoverSeasonControls() {
  const found = [];
  [...document.querySelectorAll("select")].forEach((select, index) => {
    const context = `${select.getAttribute("aria-label") || ""} ${select.name || ""} ${select.id || ""} ${select.previousElementSibling?.textContent || ""} ${[...select.options].map((x) => x.textContent).join(" ")}`;
    if (!/(temporada|season)/i.test(context)) return;
    [...select.options].forEach((option, optionIndex) => {
      const season = Number(
        (option.textContent || option.value || "").match(/(\d+)/)?.[1],
      );
      if (season)
        found.push({
          kind: "select",
          index,
          optionIndex,
          season,
          label: (option.textContent || "").trim(),
        });
    });
  });
  const selector =
    'button,a,[role="tab"],[data-bs-toggle="tab"],[data-toggle="tab"],[data-season],[data-temporada]';
  [...document.querySelectorAll(selector)].forEach((element, index) => {
    const label = (
        element.textContent ||
        element.getAttribute("aria-label") ||
        element.dataset?.season ||
        element.dataset?.temporada ||
        ""
      )
        .trim()
        .replace(/\s+/g, " "),
      match = label.match(/(?:temporada|season)\s*(\d+)/i);
    if (!match) return;
    if (element.tagName === "A") {
      const href = element.getAttribute("href") || "";
      if (
        href &&
        !href.startsWith("#") &&
        !element.matches(
          '[role="tab"],[data-bs-toggle="tab"],[data-toggle="tab"]',
        )
      ) {
        found.push({
          kind: "url",
          url: element.href,
          season: Number(match[1]),
          label,
        });
        return;
      }
    }
    found.push({ kind: "click", index, season: Number(match[1]), label });
  });
  return found;
}
function activateSeasonControl(control) {
  if (control.kind === "select") {
    const select = document.querySelectorAll("select")[control.index];
    if (!select || !select.options[control.optionIndex]) return false;
    select.selectedIndex = control.optionIndex;
    select.value = select.options[control.optionIndex].value;
    select.dispatchEvent(new Event("input", { bubbles: true }));
    select.dispatchEvent(new Event("change", { bubbles: true }));
    return true;
  }
  const selector =
      'button,a,[role="tab"],[data-bs-toggle="tab"],[data-toggle="tab"],[data-season],[data-temporada]',
    element = document.querySelectorAll(selector)[control.index];
  if (!element) return false;
  element.click();
  return true;
}

async function saveScan(scanId, patch) {
  const { scanSessions = {} } = await chrome.storage.session.get({
    scanSessions: {},
  });
  scanSessions[scanId] = {
    ...(scanSessions[scanId] || {}),
    ...patch,
    updatedAt: Date.now(),
  };
  await chrome.storage.session.set({ scanSessions });
}

async function buildInlineProviderCatalog(tab) {
  const before = await chrome.scripting.executeScript({
    target: { tabId: tab.id, allFrames: true },
    func: snapshotPage,
  });
  await chrome.scripting
    .executeScript({
      target: { tabId: tab.id, allFrames: true },
      func: activateLatinoTab,
    })
    .catch(() => []);
  await new Promise((resolve) => setTimeout(resolve, 900));
  const after = await chrome.scripting.executeScript({
    target: { tabId: tab.id, allFrames: true },
    func: snapshotPage,
  });
  const frames = [...before, ...after],
    top =
      after.find((x) => x.frameId === 0)?.result ||
      before.find((x) => x.frameId === 0)?.result ||
      {},
    allUrls = new Set();
  for (const frame of frames)
    for (const raw of frame.result?.urls || [])
      try {
        allUrls.add(new URL(raw, frame.result?.frameUrl || tab.url).href);
      } catch {}
  const providerPattern = /(streamwish|filemoon|vidhide|voesx|voe(?=[._-])|doodstream|medixiru|streamtape|mixdrop|filelions|lulustream)/i,
    unique = new Map(),
    add = (url, target = unique) => {
      const match = url.match(providerPattern);
      if (match && !isMedia(url) && !target.has(url))
        target.set(url, { url, provider: match[1] });
    };
  allUrls.forEach((url) => add(url));
  const intermediates = [...allUrls]
    .filter(
      (url) =>
        /(player|embed)/i.test(url) &&
        !providerPattern.test(url) &&
        !isMedia(url),
    )
    .slice(0, 30);
  await Promise.allSettled(
    intermediates.map(async (url) => {
      const parsed = new URL(url);
      if (
        /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(
          parsed.hostname,
        )
      )
        return;
      const response = await fetch(url, {
        method: "GET",
        redirect: "follow",
        credentials:
          parsed.origin === new URL(tab.url).origin ? "include" : "omit",
        cache: "no-store",
      });
      add(response.url);
      const type = response.headers.get("content-type") || "";
      if (type && !/text\/(?:html|plain)|javascript|json/i.test(type)) return;
      const text = (await response.text()).slice(0, 1500000);
      for (const found of urlsFromText(text, response.url)) add(found);
    }),
  );
  const episodeMap = new Map();
  for (const frame of frames)
    for (const ep of frame.result?.episodes || []) {
      try {
        const url = new URL(ep.url, tab.url);
        if (url.origin !== new URL(tab.url).origin || episodeMap.has(url.href))
          continue;
        episodeMap.set(url.href, { ...ep, url: url.href, options: [] });
      } catch {}
    }
  const episodes = [...episodeMap.values()].slice(0, 80);
  for (let i = 0; i < episodes.length; i += 8)
    await Promise.allSettled(
      episodes.slice(i, i + 8).map(async (ep) => {
        const response = await fetch(ep.url, {
          credentials: "include",
          cache: "no-store",
        });
        const text = (await response.text()).slice(0, 1500000),
          found = new Map();
        for (const url of urlsFromText(text, response.url)) add(url, found);
        ep.options = [...found.values()];
        const title =
          text.match(
            /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)/i,
          )?.[1] || text.match(/<title[^>]*>([^<]+)/i)?.[1];
        if (title) ep.label = title.replace(/&amp;/g, "&").trim();
        const image = text.match(
          /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)/i,
        )?.[1];
        if (image)
          try {
            ep.image = new URL(image, response.url).href;
          } catch {}
      }),
    );
  episodes.sort(
    (a, b) => a.season - b.season || (a.episode || 999) - (b.episode || 999),
  );
  return {
    title: top.title || tab.title,
    description: top.description || "",
    image: top.image || "",
    sourceUrl: tab.url,
    options: [...unique.values()],
    series:
      episodes.length >= 2
        ? { title: top.title || tab.title, image: top.image || "", episodes }
        : null,
  };
}

async function enrichSeriesCatalog(catalog, tab) {
  if (!catalog.series?.episodes?.length) return catalog;
  const providerPattern = /(streamwish|filemoon|vidhide|voesx|voe(?=[._-])|doodstream|medixiru|streamtape|mixdrop|filelions|lulustream)/i;
  const add = (url, map) => {
    const match = url.match(providerPattern);
    if (match && !isMedia(url) && !map.has(url))
      map.set(url, { url, provider: match[1] });
  };
  for (let i = 0; i < catalog.series.episodes.length; i += 6) {
    await Promise.allSettled(
      catalog.series.episodes.slice(i, i + 6).map(async (episode) => {
        if (episode.options?.length) return;
        const response = await fetch(episode.url, {
            credentials: "include",
            cache: "no-store",
          }),
          text = (await response.text()).slice(0, 1500000),
          found = new Map(),
          urls = urlsFromText(text, response.url);
        urls.forEach((url) => add(url, found));
        const players = urls
          .filter(
            (url) =>
              /(player|embed)/i.test(url) &&
              !providerPattern.test(url) &&
              !isMedia(url),
          )
          .slice(0, 4);
        await Promise.allSettled(
          players.map(async (playerUrl) => {
            const parsed = new URL(playerUrl);
            if (
              /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(
                parsed.hostname,
              )
            )
              return;
            const playerResponse = await fetch(playerUrl, {
              redirect: "follow",
              credentials:
                parsed.origin === new URL(tab.url).origin ? "include" : "omit",
              cache: "no-store",
            });
            add(playerResponse.url, found);
            const playerText = (await playerResponse.text()).slice(0, 1200000);
            urlsFromText(playerText, playerResponse.url).forEach((url) =>
              add(url, found),
            );
          }),
        );
        episode.options = [...found.values()];
      }),
    );
  }
  return catalog;
}

function episodesFromHtml(text, base, forcedSeason) {
  const episodes = [],
    pattern = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
  let match;
  while ((match = pattern.exec(text)) && episodes.length < 150) {
    const label = match[2]
        .replace(/<[^>]+>/g, " ")
        .replace(/&(?:nbsp|amp);/g, " ")
        .replace(/\s+/g, " ")
        .trim(),
      href = match[1],
      pair = label.match(/\b(\d+)\s*x\s*(\d+)\b/i),
      pathPair = href.match(/\/serie\/\d+-(\d+)-(\d+)/i);
    if (
      !/(cap[ií]tulo|episodio|episode|\b\d+\s*x\s*\d+\b|\/serie\/\d+-\d+-\d+)/i.test(
        `${label} ${href}`,
      )
    )
      continue;
    let url;
    try {
      url = new URL(href, base).href;
    } catch {
      continue;
    }
    const detectedSeason = Number(pair?.[1] ?? pathPair?.[1] ?? 0),
      season = forcedSeason ?? detectedSeason,
      episode = Number(
        pair?.[2] ??
          pathPair?.[2] ??
          label.match(/(?:cap[ií]tulo|episodio|episode)\s*(\d+)/i)?.[1] ??
          href.match(/(?:capitulo|episodio|episode)[-_\/]?(\d+)/i)?.[1] ??
          0,
      );
    if (!episode) continue;
    episodes.push({ url, label, season, episode, options: [] });
  }
  return episodes;
}
function episodesFromNextData(text, base) {
  const raw = text.match(
    /<script[^>]+id=["']__NEXT_DATA__["'][^>]*>([\s\S]*?)<\/script>/i,
  )?.[1];
  if (!raw) return [];
  let data;
  try {
    data = JSON.parse(raw.replace(/&quot;/g, '"').replace(/&amp;/g, "&"));
  } catch {
    return [];
  }
  const post =
    data?.props?.pageProps?.context?.post || data?.props?.pageProps?.post;
  if (!post || !Array.isArray(post.seasons)) return [];
  let seriesBase;
  try {
    const current = new URL(base);
    const slug =
      post.slug?.name || current.pathname.match(/\/series\/([^/]+)/i)?.[1];
    if (!slug) return [];
    seriesBase = new URL(`/series/${slug}/`, current.origin);
  } catch {
    return [];
  }
  const episodes = [];
  for (const seasonData of post.seasons) {
    const season = Number(seasonData?.number);
    if (!Number.isFinite(season) || !Array.isArray(seasonData.episodes))
      continue;
    for (const item of seasonData.episodes) {
      const episode = Number(item?.number ?? item?.slug?.episode);
      if (!Number.isFinite(episode) || episode < 1) continue;
      const itemSeason = Number(item?.slug?.season ?? season),
        finalSeason = Number.isFinite(itemSeason) ? itemSeason : season;
      let url;
      try {
        url = new URL(
          `temporadas/${finalSeason}/episodios/${episode}`,
          seriesBase,
        ).href;
      } catch {
        continue;
      }
      episodes.push({
        url,
        label: cleanImportedText(
          item?.title || `Temporada ${finalSeason} · Capítulo ${episode}`,
          base,
          true,
        ),
        season: finalSeason,
        episode,
        image: item?.image || "",
        options: [],
      });
    }
  }
  return episodes;
}

async function expandAllSeasons(tab) {
  const catalog = await buildInlineProviderCatalog(tab),
    existing = catalog.series?.episodes || [];
  if (new Set(existing.map((episode) => episode.season ?? 1)).size > 1)
    return enrichSeriesCatalog(catalog, tab);
  const directSnapshots = await chrome.scripting
      .executeScript({
        target: { tabId: tab.id, allFrames: true },
        func: snapshotPage,
      })
      .catch(() => []),
    discovered = await chrome.scripting
      .executeScript({
        target: { tabId: tab.id, allFrames: true },
        func: discoverSeasonControls,
      })
      .catch(() => []),
    controls = [];
  for (const frame of discovered)
    for (const control of frame.result || []) {
      const key = `${frame.frameId}:${control.kind}:${control.index ?? ""}:${control.optionIndex ?? ""}:${control.url || ""}:${control.season || ""}`;
      if (!controls.some((x) => x.key === key))
        controls.push({ key, frameId: frame.frameId, control });
    }
  for (const frame of directSnapshots)
    for (const season of frame.result?.seasons || []) {
      const key = `direct:${season.season}:${season.url}`;
      if (!controls.some((x) => x.key === key))
        controls.push({
          key,
          frameId: frame.frameId,
          control: {
            kind: "url",
            season: season.season,
            url: season.url,
            label: season.label,
          },
        });
    }
  const collected = [];
  for (const item of controls.slice(0, 16)) {
    if (item.control.kind === "url") {
      try {
        const response = await fetchWithTimeout(item.control.url, {
            credentials:
              new URL(item.control.url).origin === new URL(tab.url).origin
                ? "include"
                : "omit",
            cache: "no-store",
          }, 8000),
          text = (await response.text()).slice(0, 1800000);
        collected.push(
          ...episodesFromHtml(text, response.url, item.control.season),
        );
      } catch {}
      continue;
    }
    await chrome.scripting
      .executeScript({
        target: { tabId: tab.id, frameIds: [item.frameId] },
        func: activateSeasonControl,
        args: [item.control],
      })
      .catch(() => []);
    await new Promise((resolve) => setTimeout(resolve, 650));
    const snapshots = await chrome.scripting
      .executeScript({
        target: { tabId: tab.id, frameIds: [item.frameId] },
        func: snapshotPage,
      })
      .catch(() => []);
    for (const snapshot of snapshots)
      for (const episode of snapshot.result?.episodes || [])
        collected.push({
          ...episode,
          season: item.control.season ?? episode.season ?? 1,
          options: [],
        });
  }
  const merged = new Map();
  for (const episode of [...existing, ...collected]) {
    let url;
    try {
      url = new URL(episode.url, tab.url).href;
    } catch {
      continue;
    }
    const key = `${episode.season ?? 1}:${url}`,
      previous = merged.get(key);
    merged.set(key, {
      ...previous,
      ...episode,
      url,
      options: episode.options?.length
        ? episode.options
        : previous?.options || [],
    });
  }
  const episodes = [...merged.values()].sort(
    (a, b) =>
      (a.season ?? 1) - (b.season ?? 1) ||
      (a.episode || 999) - (b.episode || 999),
  );
  if (episodes.length >= 2)
    catalog.series = {
      ...(catalog.series || {}),
      title: catalog.series?.title || catalog.title,
      image: catalog.series?.image || catalog.image,
      episodes,
    };
  return enrichSeriesCatalog(catalog, tab);
}

const siteIndexes = new Map(),
  siteMetaCache = new Map();
let catalogBuildPromise = null;
function withTimeout(
  promise,
  ms = 15000,
  message = "La solicitud tardó demasiado",
) {
  let timer;
  return Promise.race([
    promise,
    new Promise((_, reject) => {
      timer = setTimeout(() => reject(Error(message)), ms);
    }),
  ]).finally(() => clearTimeout(timer));
}
async function fetchWithTimeout(url, options = {}, ms = 12000) {
  const controller = new AbortController(),
    timer = setTimeout(() => controller.abort(), ms);
  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}
function safeSiteUrl(value) {
  try {
    const url = new URL(value);
    if (
      !/^https?:$/.test(url.protocol) ||
      /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?$)/i.test(
        url.hostname,
      )
    )
      return null;
    return url;
  } catch {
    return null;
  }
}
function cleanText(value = "") {
  return value
    .replace(/<[^>]*>/g, " ")
    .replace(/&#(?:x([0-9a-f]+)|(\d+));/gi, (_, hex, dec) => {
      const code = Number.parseInt(hex || dec, hex ? 16 : 10);
      return Number.isFinite(code) && code > 0 && code <= 0x10ffff
        ? String.fromCodePoint(code)
        : " ";
    })
    .replace(
      /&(nbsp|amp|quot|apos|lt|gt);/gi,
      (_, name) =>
        ({ nbsp: " ", amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" })[
          name.toLowerCase()
        ] || " ",
    )
    .replace(/\s+/g, " ")
    .trim();
}
function cleanImportedText(value, url, title = false) {
  if (value == null || /^(?:undefined|null)$/i.test(String(value).trim()))
    return "";
  const host = new URL(url).hostname
      .replace(/^www\./, "")
      .split(".")[0]
      .replace(/[-_]+/g, " "),
    escaped = host.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  let result = cleanText(String(value))
    .replace(new RegExp(`\\b${escaped}\\b`, "ig"), " ")
    .replace(/\b(?:repelis\s*24?|cuevana\s*3?|pelisplus|pelisflix)\b/gi, " ")
    .replace(/\b(?:online|gratis|gratuito|free|en\s+línea)\b/gi, " ")
    .replace(/\b(?:película|serie)\s+completa\b/gi, " ");
  if (title) result = result.replace(/^\s*(?:ver|watch)\s+/i, " ");
  return result
    .replace(/\s{2,}/g, " ")
    .replace(/^\s*[-|·:]\s*|\s*[-|·:]\s*$/g, "")
    .trim();
}
function metaFromHtml(text, url) {
  const get = (name) =>
    text.match(
      new RegExp(
        `<meta[^>]+(?:property|name)=["']${name}["'][^>]+content=["']([^"']+)`,
        `i`,
      ),
    )?.[1] ||
    text.match(
      new RegExp(
        `<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']${name}["']`,
        `i`,
      ),
    )?.[1] ||
    "";
  const rawTitle =
    get("og:title") ||
    cleanText(text.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] || "") ||
    decodeURIComponent(
      new URL(url).pathname.split("/").filter(Boolean).pop() || "Contenido",
    ).replace(/[-_]+/g, " ");
  let image = get("og:image") || get("twitter:image");
  try {
    image = image ? new URL(image, url).href : "";
  } catch {
    image = "";
  }
  return {
    url,
    title: cleanImportedText(rawTitle, url, true) || "Contenido",
    image,
    description: cleanImportedText(
      get("og:description") || get("description"),
      url,
    ),
    type: /\/series?\//i.test(new URL(url).pathname) ? "Serie" : "Película",
  };
}
function htmlUrls(text, base) {
  const found = new Set(urlsFromText(text, base)),
    pattern = /(?:href|src|data-src|data-url)\s*=\s*["']([^"']+)["']/gi;
  let match;
  while ((match = pattern.exec(text)) && found.size < 600) {
    const url = decodeCandidate(match[1], base);
    if (url) found.add(url);
  }
  return [...found];
}
function htmlFrameUrls(text, base) {
  const found = new Set(), pattern = /<(?:iframe|embed|object)\b[^>]*(?:src|data)\s*=\s*["']([^"']+)["'][^>]*>/gi;
  let match;
  while ((match = pattern.exec(text)) && found.size < 80) {
    const url = decodeCandidate(match[1], base);
    if (url) found.add(url);
  }
  return [...found];
}
function isEpisodeOrSeasonUrl(url) {
  const path = new URL(url).pathname;
  return (
    /\/serie\/\d+-\d+(?:-\d+)?(?:\/|$)/i.test(path) ||
    /(?:capitulo|episodio|episode)[-_\/]?\d+/i.test(path) ||
    /(?:temporada|season)[-_\/]?\d+/i.test(path)
  );
}
function isMainContentUrl(url) {
  try {
    const path = new URL(url).pathname;
    return (
      !isEpisodeOrSeasonUrl(url) &&
      /(?:\/peliculas?\/|\/movies?\/|\/series?\/|\/tv\/)/i.test(path) &&
      path.split("/").filter(Boolean).length >= 2
    );
  } catch {
    return false;
  }
}
async function buildSiteIndex(site) {
  const root = safeSiteUrl(site);
  if (!root) throw Error("Sitio no válido");
  const cached = siteIndexes.get(root.origin);
  if (cached && Date.now() - cached.updatedAt < 1800000) return cached;
  const queue = [
      new URL("/sitemap.xml", root).href,
      new URL("/sitemap_index.xml", root).href,
      new URL("/wp-sitemap.xml", root).href,
    ],
    seenMaps = new Set(),
    urls = new Set();
  while (queue.length && seenMaps.size < 24 && urls.size < 10000) {
    const sitemap = queue.shift();
    if (seenMaps.has(sitemap)) continue;
    seenMaps.add(sitemap);
    try {
      const response = await fetchWithTimeout(sitemap, { cache: "no-store" }, 8000);
      if (!response.ok) continue;
      const text = (await response.text()).slice(0, 12000000),
        locs = [...text.matchAll(/<loc[^>]*>([\s\S]*?)<\/loc>/gi)].map((x) =>
          cleanText(x[1]),
        );
      for (const loc of locs) {
        const parsed = safeSiteUrl(loc);
        if (!parsed || parsed.origin !== root.origin) continue;
        if (/(?:sitemap|\.xml)(?:[?#]|$)/i.test(parsed.href))
          queue.push(parsed.href);
        else if (isMainContentUrl(parsed.href)) urls.add(parsed.href);
      }
    } catch {}
  }
  if (!urls.size) {
    try {
      const response = await fetchWithTimeout(root.href, { cache: "no-store" }, 10000),
        text = await response.text();
      for (const url of htmlUrls(text, response.url))
        if (new URL(url).origin === root.origin && isMainContentUrl(url))
          urls.add(url);
    } catch {}
  }
  const items = [...urls].map((url) => {
    const slug = decodeURIComponent(
      new URL(url).pathname.split("/").filter(Boolean).pop() || "",
    ).replace(/[-_]+/g, " ");
    return {
      url,
      search: slug.toLowerCase(),
      title: slug.replace(/\b\w/g, (c) => c.toUpperCase()),
      image: "",
      type: /\/serie\//i.test(new URL(url).pathname) ? "Serie" : "Película",
    };
  });
  const index = { site: root.origin, items, updatedAt: Date.now() };
  siteIndexes.set(root.origin, index);
  return index;
}
async function hydrateSearchResults(index, query, limit = 24) {
  const normalized = query
      .normalize("NFD")
      .replace(/[\u0300-\u036f]/g, "")
      .toLowerCase()
      .trim(),
    words = normalized.split(/\s+/).filter(Boolean);
  const matches = index.items
    .filter((item) => {
      const hay = item.search.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
      return words.every((word) => hay.includes(word));
    })
    .slice(0, limit);
  for (let i = 0; i < matches.length; i += 6)
    await Promise.allSettled(
      matches.slice(i, i + 6).map(async (item) => {
        let meta = siteMetaCache.get(item.url);
        if (!meta) {
          const response = await fetchWithTimeout(item.url, { cache: "no-store" }, 10000),
            text = (await response.text()).slice(0, 1800000);
          meta = metaFromHtml(text, response.url);
          siteMetaCache.set(item.url, meta);
        }
        Object.assign(item, meta);
      }),
    );
  return matches;
}
async function buildSiteIndexComplete(site) {
  const root = safeSiteUrl(site);
  if (!root) throw Error("Sitio no válido");
  const cacheKey = `complete:${root.origin}`,
    cached = siteIndexes.get(cacheKey);
  if (cached && Date.now() - cached.updatedAt < 1800000) return cached;
  const paths = [
      "/sitemap.xml",
      "/sitemap_index.xml",
      "/wp-sitemap.xml",
      "/series-sitemap.xml",
      "/serie-sitemap.xml",
      "/sitemap-series.xml",
      "/sitemap-serie.xml",
      "/wp-sitemap-posts-series-1.xml",
      "/wp-sitemap-posts-serie-1.xml",
      "/peliculas-sitemap.xml",
      "/movies-sitemap.xml",
    ],
    queue = paths.map((path) => new URL(path, root).href),
    seenMaps = new Set(),
    movies = new Set(),
    series = new Set();
  while (queue.length && seenMaps.size < 60) {
    const sitemap = queue.shift();
    if (seenMaps.has(sitemap)) continue;
    seenMaps.add(sitemap);
    try {
      const response = await fetchWithTimeout(sitemap, { cache: "no-store" }, 8000);
      if (!response.ok) continue;
      const text = (await response.text()).slice(0, 16000000),
        locs = [...text.matchAll(/<loc[^>]*>([\s\S]*?)<\/loc>/gi)].map((x) =>
          cleanText(x[1]),
        );
      for (const loc of locs) {
        const parsed = safeSiteUrl(loc);
        if (!parsed || parsed.origin !== root.origin) continue;
        if (/(?:sitemap|\.xml(?:\.gz)?)(?:[?#]|$)/i.test(parsed.href)) {
          if (!seenMaps.has(parsed.href)) queue.push(parsed.href);
          continue;
        }
        if (!isMainContentUrl(parsed.href)) continue;
        if (/\/series?\//i.test(parsed.pathname)) {
          if (series.size < 15000) series.add(parsed.href);
        } else if (movies.size < 15000) movies.add(parsed.href);
      }
    } catch {}
  }
  if (!series.size || !movies.size) {
    try {
      const response = await fetchWithTimeout(root.href, { cache: "no-store" }, 10000),
        text = (await response.text()).slice(0, 2500000);
      for (const url of htmlUrls(text, response.url)) {
        if (new URL(url).origin !== root.origin || !isMainContentUrl(url))
          continue;
        if (/\/series?\//i.test(new URL(url).pathname)) series.add(url);
        else movies.add(url);
      }
    } catch {}
  }
  const make = (url) => {
      const parsed = new URL(url),
        slug = decodeURIComponent(
          parsed.pathname.split("/").filter(Boolean).pop() || "",
        ).replace(/[-_]+/g, " "),
        type = /\/series?\//i.test(parsed.pathname) ? "Serie" : "Película";
      return {
        url,
        search: slug.toLowerCase(),
        title: slug.replace(/\b\w/g, (c) => c.toUpperCase()),
        image: "",
        type,
      };
    },
    items = [...series, ...movies].map(make),
    index = {
      site: root.origin,
      items,
      seriesCount: series.size,
      movieCount: movies.size,
      updatedAt: Date.now(),
    };
  siteIndexes.set(cacheKey, index);
  return index;
}
async function stableSiteIndex(site, force = false) {
  const root = safeSiteUrl(site);
  if (!root) throw Error("Sitio no válido");
  const memoryKey = `complete:${root.origin}`,
    memory = siteIndexes.get(memoryKey);
  if (!force && memory && Date.now() - memory.updatedAt < 21600000)
    return memory;
  const storageKey = "cleanstreamCatalogIndexV3",
    stored = (await chrome.storage.local.get({ [storageKey]: null }))[
      storageKey
    ],
    usable =
      stored?.site === root.origin &&
      Array.isArray(stored.items) &&
      stored.items.length;
  if (!force && usable && Date.now() - (stored.updatedAt || 0) < 21600000) {
    const restored = { ...stored };
    siteIndexes.set(memoryKey, restored);
    return restored;
  }
  try {
    if (force) siteIndexes.delete(memoryKey);
    if (!catalogBuildPromise)
      catalogBuildPromise = withTimeout(
        buildSiteIndexComplete(root.href),
        30000,
        "El sitio tardó demasiado en responder",
      ).finally(() => {
        catalogBuildPromise = null;
      });
    const fresh = await catalogBuildPromise;
    if (!fresh?.items?.length) throw Error("El índice llegó vacío");
    const compact = {
      site: fresh.site,
      items: fresh.items.map(({ url, search, title, image, type }) => ({
        url,
        search,
        title,
        image: image || "",
        type,
      })),
      seriesCount: fresh.seriesCount || 0,
      movieCount: fresh.movieCount || 0,
      updatedAt: Date.now(),
    };
    await chrome.storage.local.set({ [storageKey]: compact });
    siteIndexes.set(memoryKey, compact);
    return compact;
  } catch (error) {
    if (usable) {
      const restored = { ...stored };
      siteIndexes.set(memoryKey, restored);
      return restored;
    }
    throw error;
  }
}
async function searchSiteComplete(index, query, limit = 24) {
  let items = await hydrateSearchResults(index, query, limit);
  if (items.length >= Math.min(6, limit)) return items;
  try {
    const searchUrl = new URL("/", index.site);
    searchUrl.searchParams.set("s", query);
    const response = await fetchWithTimeout(
        searchUrl,
        { cache: "no-store" },
        12000,
      ),
      text = (await response.text()).slice(0, 3000000),
      known = new Set(index.items.map((x) => x.url));
    for (const url of htmlUrls(text, response.url)) {
      if (
        new URL(url).origin !== new URL(index.site).origin ||
        !isMainContentUrl(url) ||
        known.has(url)
      )
        continue;
      const parsed = new URL(url),
        slug = decodeURIComponent(
          parsed.pathname.split("/").filter(Boolean).pop() || "",
        ).replace(/[-_]+/g, " ");
      index.items.unshift({
        url,
        search: slug.toLowerCase(),
        title: slug.replace(/\b\w/g, (c) => c.toUpperCase()),
        image: "",
        type: /\/series?\//i.test(parsed.pathname) ? "Serie" : "Película",
      });
      known.add(url);
    }
    items = await hydrateSearchResults(index, query, limit);
  } catch {}
  return items;
}
const DEFAULT_EXPLORE_SITE = "https://repelis24.life/inicio";
async function prepareExploreCatalog(force = false) {
  const index = await stableSiteIndex(DEFAULT_EXPLORE_SITE, force);
  if (index.latestPrepared && !force) return index;
  try {
    const response = await fetchWithTimeout(
        DEFAULT_EXPLORE_SITE,
        { cache: "no-store" },
        12000,
      ),
      text = (await response.text()).slice(0, 5000000),
      latest = [],
      seen = new Set();
    for (const url of htmlUrls(text, response.url)) {
      const parsed = safeSiteUrl(url);
      if (
        !parsed ||
        parsed.origin !== new URL(index.site).origin ||
        !isMainContentUrl(parsed.href) ||
        seen.has(parsed.href)
      )
        continue;
      seen.add(parsed.href);
      latest.push(parsed.href);
    }
    if (latest.length) {
      const rank = new Map(latest.map((url, i) => [url, i])),
        original = new Map(index.items.map((item, i) => [item.url, i])),
        newest = index.items
          .filter((item) => rank.has(item.url))
          .sort((a, b) => rank.get(a.url) - rank.get(b.url)),
        rest = index.items
          .filter((item) => !rank.has(item.url))
          .sort(
            (a, b) => (original.get(a.url) || 0) - (original.get(b.url) || 0),
          );
      index.items = [...newest, ...rest];
    }
  } catch {}
  index.latestPrepared = true;
  return index;
}
async function hydrateCatalogItems(items) {
  for (let i = 0; i < items.length; i += 4)
    await Promise.allSettled(
      items.slice(i, i + 4).map(async (item) => {
        let meta = siteMetaCache.get(item.url);
        if (!meta) {
          const response = await fetchWithTimeout(
            item.url,
            { cache: "no-store" },
            10000,
          );
          if (!response.ok) throw Error(`HTTP ${response.status}`);
          const text = (await response.text()).slice(0, 1800000);
          meta = metaFromHtml(text, response.url);
          siteMetaCache.set(item.url, meta);
        }
        Object.assign(item, meta);
      }),
    );
  return items;
}
async function getExplorePage(filter = "all", page = 1, pageSize = 20) {
  const normalized =
      filter === "Serie" ? "Serie" : filter === "Película" ? "Película" : "all",
    requested = Math.max(1, Number(page) || 1),
    cacheId = `${normalized}:${requested}`,
    saved =
      (await chrome.storage.local.get({ cleanstreamCatalogPagesV3: {} }))
        .cleanstreamCatalogPagesV3 || {};
  try {
    const index = await prepareExploreCatalog(),
      filtered =
        normalized === "all"
          ? index.items
          : index.items.filter((item) => item.type === normalized),
      pages = Math.max(1, Math.ceil(filtered.length / pageSize)),
      current = Math.min(pages, requested),
      start = (current - 1) * pageSize,
      items = await withTimeout(
        hydrateCatalogItems(filtered.slice(start, start + pageSize)),
        25000,
        "No se pudieron completar las imágenes",
      );
    const data = {
      items,
      total: filtered.length,
      page: current,
      pages,
      filter: normalized,
      movieCount: index.movieCount,
      seriesCount: index.seriesCount,
      cached: false,
    };
    saved[cacheId] = { ...data, updatedAt: Date.now() };
    for (const key of Object.keys(saved).slice(40)) delete saved[key];
    await chrome.storage.local.set({ cleanstreamCatalogPagesV3: saved });
    return data;
  } catch (error) {
    if (saved[cacheId]?.items?.length)
      return {
        ...saved[cacheId],
        cached: true,
        warning: "Mostrando la última copia disponible",
      };
    throw error;
  }
}
async function getHomeFeatures() {
  const fallback = [
      ["accion", "Acción"],
      ["animacion", "Animación"],
      ["crimen", "Crimen"],
      ["familia", "Familia"],
      ["misterio", "Misterio"],
      ["suspenso", "Suspenso"],
      ["aventura", "Aventura"],
      ["ciencia-ficcion", "Ciencia ficción"],
      ["drama", "Drama"],
      ["fantasia", "Fantasía"],
      ["romance", "Romance"],
      ["terror", "Terror"],
    ],
    genreMap = new Map(fallback.map(([slug, name]) => [slug, { slug, name }]));
  try {
    const response = await fetchWithTimeout(
        DEFAULT_EXPLORE_SITE,
        { cache: "no-store" },
        12000,
      ),
      text = (await response.text()).slice(0, 5000000);
    for (const match of text.matchAll(
      /<a\b[^>]*href=["']([^"']*\/generos\/([^"'/?#]+)[^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi,
    )) {
      const slug = decodeURIComponent(match[2]).toLowerCase(),
        name = cleanText(match[3]);
      if (name && slug) genreMap.set(slug, { slug, name });
    }
    const start = text.search(/Pel[ií]culas:\s*Top de la semana/i),
      end =
        start >= 0
          ? text.slice(start + 1).search(/Series:\s*Top de la semana/i)
          : -1,
      segment =
        start >= 0
          ? text.slice(
              start,
              end > 0 ? start + 1 + end : Math.min(text.length, start + 900000),
            )
          : text,
      urls = [],
      seen = new Set();
    for (const url of htmlUrls(segment, response.url)) {
      const parsed = safeSiteUrl(url);
      if (
        !parsed ||
        parsed.origin !== new URL(response.url).origin ||
        !/\/peliculas?\//i.test(parsed.pathname) ||
        isEpisodeOrSeasonUrl(parsed.href) ||
        seen.has(parsed.href)
      )
        continue;
      seen.add(parsed.href);
      urls.push(parsed.href);
      if (urls.length >= 20) break;
    }
    const weekly = await hydrateCatalogItems(
        urls.map((url) => ({ url, type: "Película" })),
      ),
      data = { genres: [...genreMap.values()], weekly, updatedAt: Date.now() };
    await chrome.storage.local.set({ cleanstreamHomeFeaturesV3: data });
    return data;
  } catch {
    const cached = (
      await chrome.storage.local.get({ cleanstreamHomeFeaturesV3: null })
    ).cleanstreamHomeFeaturesV3;
    return { genres: [...genreMap.values()], weekly: cached?.weekly || [] };
  }
}
async function getGenrePage(genre, page = 1) {
  const slug = String(genre || "")
    .toLowerCase()
    .replace(/[^a-z0-9-]/g, "");
  if (!slug) throw Error("Género no válido");
  const current = Math.max(1, Number(page) || 1),
    url = new URL(
      `/generos/${slug}${current > 1 ? `/pagina/${current}` : ""}`,
      DEFAULT_EXPLORE_SITE,
    ),
    response = await fetchWithTimeout(url, { cache: "no-store" }, 12000);
  if (!response.ok) throw Error("No se pudo cargar el género");
  const text = (await response.text()).slice(0, 4000000),
    urls = [],
    seen = new Set();
  for (const found of htmlUrls(text, response.url)) {
    const parsed = safeSiteUrl(found);
    if (
      !parsed ||
      parsed.origin !== new URL(response.url).origin ||
      !isMainContentUrl(parsed.href) ||
      seen.has(parsed.href)
    )
      continue;
    seen.add(parsed.href);
    urls.push(parsed.href);
  }
  let pages = current;
  for (const match of text.matchAll(
    new RegExp(`/generos/${slug}/pagina/(\\d+)`, "gi"),
  ))
    pages = Math.max(pages, Number(match[1]) || 1);
  const items = await withTimeout(
    hydrateCatalogItems(
      urls
        .slice(0, 20)
        .map((url) => ({
          url,
          type: /\/series?\//i.test(new URL(url).pathname)
            ? "Serie"
            : "Película",
        })),
    ),
    25000,
  );
  return {
    items,
    total: pages * 20,
    page: current,
    pages,
    filter: `genre:${slug}`,
    movieCount: items.filter((x) => x.type === "Película").length,
    seriesCount: items.filter((x) => x.type === "Serie").length,
  };
}
async function refreshExploreCatalog() {
  siteIndexes.clear();
  siteMetaCache.clear();
  const index = await prepareExploreCatalog(true);
  await chrome.storage.local.remove([
    "cleanstreamCatalogPagesV3",
    "cleanstreamHomeFeaturesV3",
  ]);
  const features = await getHomeFeatures();
  return {
    count: index.items.length,
    movieCount: index.movieCount || 0,
    seriesCount: index.seriesCount || 0,
    ...features,
  };
}

async function hiroApi(path, { method = "GET", body, auth = true } = {}) {
  const stored = await chrome.storage.local.get({ hiroAuthToken: "" });
  const headers = { Accept: "application/json" };
  if (body !== undefined) headers["Content-Type"] = "application/json";
  if (auth && stored.hiroAuthToken)
    headers.Authorization = `Bearer ${stored.hiroAuthToken}`;
  const response = await fetchWithTimeout(
    `${HIRO_API}${path}`,
    { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" },
    15000,
  );
  let data = {};
  try { data = await response.json(); } catch {}
  if (response.status === 401 || response.status === 403) {
    if (data.code === "USER_DELETED") {
      await chrome.storage.local.set({ hiroAccountNotice: "USER_DELETED" });
      await chrome.storage.local.remove(["hiroAuthToken", "hiroUser"]);
      chrome.tabs.query({ url: chrome.runtime.getURL("*") }).then((tabs) => tabs.forEach((tab) => chrome.tabs.sendMessage(tab.id, { type: "HIRO_ACCOUNT_DELETED" }).catch(() => {}))).catch(() => {});
    } else if (response.status === 401) {
      await chrome.storage.local.remove(["hiroAuthToken", "hiroUser"]);
    }
    throw Object.assign(new Error(data.error || "Debes conectar tu cuenta de HIRO PLAY."), { code: data.code || "AUTH_REQUIRED" });
  }
  if (!response.ok || data.ok === false)
    throw new Error(data.error || `HIRO PLAY respondió ${response.status}`);
  return data;
}

async function hiroAuthState(verify = false) {
  const state = await chrome.storage.local.get({ hiroAuthToken: "", hiroUser: null, hiroAccountNotice: "" });
  if (state.hiroAccountNotice === "USER_DELETED") return { authenticated: false, deleted: true, notice: state.hiroAccountNotice };
  if (!state.hiroAuthToken) return { authenticated: false };
  if (!verify) return { authenticated: true, user: state.hiroUser };
  try {
    const data = await hiroApi("/account");
    await chrome.storage.local.set({ hiroUser: data.user, hiroAccountNotice: "" });
    reportExtensionPresence().catch(()=>{});
    return { authenticated: true, user: data.user };
  } catch (error) {
    if (error.code === "USER_DELETED") return { authenticated: false, deleted: true, notice: "USER_DELETED" };
    if (error.code === "AUTH_REQUIRED") return { authenticated: false };
    return { authenticated: Boolean(state.hiroAuthToken), user: state.hiroUser, offline: true };
  }
}

async function startHiroPairing() {
  const data = await hiroApi("/device/start", { method: "POST", auth: false, body: { extension_version: chrome.runtime.getManifest().version } });
  const pairing = { code: data.code, secret: data.secret, expiresAt: Date.now() + 10 * 60 * 1000 };
  await chrome.storage.local.set({ hiroPairing: pairing, hiroAccountNotice: "" });
  return { ok: true, connectUrl: data.connect_url || `https://hiroplay.lat/conectar?code=${encodeURIComponent(data.code)}` };
}

async function pollHiroPairing() {
  const { hiroPairing } = await chrome.storage.local.get({ hiroPairing: null });
  if (!hiroPairing || Date.now() > hiroPairing.expiresAt) return { authenticated: false };
  const data = await hiroApi(`/device/status?code=${encodeURIComponent(hiroPairing.code)}&secret=${encodeURIComponent(hiroPairing.secret)}`, { auth: false });
  if (!data.authorized || !data.token) return { authenticated: false, pending: true };
  await chrome.storage.local.set({ hiroAuthToken: data.token, hiroUser: data.user, hiroAccountNotice: "" });
  await chrome.storage.local.remove("hiroPairing");
  reportExtensionPresence(true).catch(()=>{});
  return { authenticated: true, user: data.user };
}

async function getHiroCatalogPage(filter = "all", page = 1, pageSize = 20) {
  const type = filter === "Serie" ? "series" : filter === "Película" ? "movies" : "all";
  const cacheKey = `${type}:${Math.max(1, Number(page) || 1)}`;
  const saved = (await chrome.storage.local.get({ hiroCatalogCacheV2: {} })).hiroCatalogCacheV2 || {};
  try {
    const data = await hiroApi(`/catalog?type=${type}&page=${encodeURIComponent(page)}&per_page=${pageSize}`);
    const result = { items: data.items || [], total: data.total || 0, page: data.page || 1, pages: data.pages || 1, movieCount: data.movie_count || 0, seriesCount: data.series_count || 0, cached: false };
    saved[cacheKey] = { ...result, updatedAt: Date.now() };
    await chrome.storage.local.set({ hiroCatalogCacheV2: saved });
    return result;
  } catch (error) {
    if (saved[cacheKey]?.items?.length) return { ...saved[cacheKey], cached: true, warning: "Mostrando la última copia disponible" };
    throw error;
  }
}

async function searchHiroCatalog(query, limit = 12) {
  const data = await hiroApi(`/catalog?query=${encodeURIComponent(String(query || "").trim())}&per_page=${Math.min(36, Math.max(6, Number(limit) || 12))}`);
  return data.items || [];
}

async function refreshHiroCatalog() {
  const data = await hiroApi("/catalog?type=all&page=1&per_page=20");
  await chrome.storage.local.remove("hiroCatalogCacheV2");
  return { count: data.total || 0, movieCount: data.movie_count || 0, seriesCount: data.series_count || 0 };
}

async function ensureInstallId(){const state=await chrome.storage.local.get({hiroInstallId:""});if(state.hiroInstallId)return state.hiroInstallId;const id=crypto.randomUUID();await chrome.storage.local.set({hiroInstallId:id});return id;}
async function reportExtensionPresence(force=false){const state=await chrome.storage.local.get({hiroLastPresence:0,hiroAuthToken:""});if(!state.hiroAuthToken)return;if(!force&&Date.now()-(state.hiroLastPresence||0)<6*60*60*1000)return;const installId=await ensureInstallId();await hiroApi('/extension/presence',{method:'POST',body:{install_id:installId,version:chrome.runtime.getManifest().version}});await chrome.storage.local.set({hiroLastPresence:Date.now()});}
function safeDownloadName(title,url){let ext='mp4';try{const path=new URL(url).pathname,match=path.match(/\.([a-z0-9]{2,5})$/i);if(match&&['mp4','webm','m4v','mov','ogg'].includes(match[1].toLowerCase()))ext=match[1].toLowerCase();}catch{}const name=String(title||'Contenido').replace(/[\\/:*?"<>|]+/g,' ').replace(/\s+/g,' ').trim().slice(0,110)||'Contenido';return`HIRO PLAY/${name}.${ext}`;}
async function startPcDownload(message){const url=String(message.url||'');if(!/^https?:/i.test(url)||/\.m3u8(?:[?#]|$)|\.mpd(?:[?#]|$)/i.test(url))throw Error('Esta fuente no ofrece un archivo directo descargable.');const id=await chrome.downloads.download({url,filename:safeDownloadName(message.title,url),conflictAction:'uniquify',saveAs:false});const entry={downloadId:String(id),title:String(message.title||'Contenido'),image:String(message.image||''),postUrl:String(message.postUrl||''),sourceUrl:url,startedAt:Date.now()};const{hiroPcDownloads=[]}=await chrome.storage.local.get({hiroPcDownloads:[]});await chrome.storage.local.set({hiroPcDownloads:[entry,...hiroPcDownloads.filter(x=>String(x.downloadId)!==String(id))].slice(0,300)});let host='';try{host=new URL(url).hostname;}catch{}hiroApi('/downloads/media',{method:'POST',body:{download_id:String(id),content_url:message.postUrl||url,title:entry.title,source_host:host}}).catch(()=>{});return{ok:true,downloadId:id};}
function compareVersions(a, b) {
  const left = String(a).split(".").map(Number),
    right = String(b).split(".").map(Number);
  for (let i = 0; i < Math.max(left.length, right.length); i++) {
    const diff = (left[i] || 0) - (right[i] || 0);
    if (diff) return diff;
  }
  return 0;
}
async function checkRemoteVersion() {
  const current = chrome.runtime.getManifest().version;
  try {
    const response = await fetchWithTimeout(
      `${HIRO_API}/version`,
      { cache: "no-store" },
      7000,
    );
    if (!response.ok) return { ok: true, current, available: false };
    const data = await response.json(),
      latest = String(data.version || current);
    return {
      ok: true,
      current,
      latest,
      available: compareVersions(latest, current) > 0,
      downloadUrl: String(data.downloadUrl || ""),
      helpUrl: String(data.helpUrl || "https://hiroplay.lat/instalar"),
      message: String(data.message || "Hay una nueva versión disponible."),
    };
  } catch {
    return { ok: true, current, available: false };
  }
}
async function remoteProviderOptions(pageUrl, text, site) {
  const options = new Map(), inventory = new Map(), queue = [], seen = new Set();
  const root = safeSiteUrl(site);
  const inventoryAdd = (url, kind, status = 'discovered') => {
    try {
      const host = new URL(url).hostname.toLowerCase();
      if (!host || isLikelyAd(url)) return;
      const key = `${kind}:${host}`;
      if (!inventory.has(key)) inventory.set(key, { host, kind, status });
    } catch {}
  };
  const addOption = (url, via = '') => {
    const parsed = safeSiteUrl(url);
    if (!parsed || isLikelyAd(parsed.href) || isForbiddenIntermediarySource(parsed.href)) return false;
    if (isPlayableMedia(parsed.href)) {
      if (!options.has(parsed.href)) options.set(parsed.href, { url: parsed.href, provider: providerHost(parsed.href) || 'directo', kind: 'direct', via });
      inventoryAdd(parsed.href, 'direct', 'compatible');
      return true;
    }
    const key = providerKey(parsed.href);
    if (key) {
      if (!options.has(parsed.href)) options.set(parsed.href, { url: parsed.href, provider: key, kind: 'provider', via });
      inventoryAdd(parsed.href, 'provider', STABLE_PROVIDER_KEYS.has(key) ? 'known' : 'candidate');
      return true;
    }
    return false;
  };
  const enqueue = (url, depth, reason = 'intermediary') => {
    const parsed = safeSiteUrl(url);
    if (!parsed || depth > 3 || seen.has(parsed.href) || isLikelyAd(parsed.href) || isMedia(parsed.href) || isKnownProviderUrl(parsed.href)) return;
    const plausible = isIntermediaryUrl(parsed.href, root?.href || site) || reason === 'frame';
    if (!plausible) return;
    queue.push({ url: parsed.href, depth });
    inventoryAdd(parsed.href, 'intermediary', 'inspect');
  };
  const inspectText = (body, base, depth) => {
    for (const url of htmlUrls(body, base)) {
      if (addOption(url, base)) continue;
      if (isIntermediaryUrl(url, root?.href || site)) enqueue(url, depth + 1);
      else if (/\.mpd(?:[?#]|$)/i.test(url)) inventoryAdd(url, 'dash', 'detected');
    }
    for (const frameUrl of htmlFrameUrls(body, base)) {
      if (!addOption(frameUrl, base)) enqueue(frameUrl, depth + 1, 'frame');
    }
  };

  inspectText(text, pageUrl, -1);
  while (queue.length && seen.size < 16) {
    const current = queue.shift();
    if (!current || seen.has(current.url)) continue;
    seen.add(current.url);
    try {
      const parsed = new URL(current.url), sameOrigin = root && parsed.origin === root.origin;
      const response = await fetchWithTimeout(current.url, { method: 'GET', redirect: 'follow', credentials: sameOrigin ? 'include' : 'omit', cache: 'no-store' }, 7000);
      const type = response.headers.get('content-type') || '';
      if (isPlayableMedia(response.url, type)) { addOption(response.url, current.url); continue; }
      if (isKnownProviderUrl(response.url)) { addOption(response.url, current.url); continue; }
      if (!/text\/(?:html|plain)|javascript|json/i.test(type) && type) continue;
      const body = (await response.text()).slice(0, 1800000);
      inspectText(body, response.url, current.depth);
    } catch {}
  }
  return { options: [...options.values()], inventory: [...inventory.values()] };
}
function seasonRefsFromHtml(text, base) {
  const refs = [],
    pattern = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
  let match;
  while ((match = pattern.exec(text)) && refs.length < 40) {
    const label = cleanText(match[2]),
      season = Number(label.match(/#?(?:temporada|season)\s*(\d+)/i)?.[1]);
    if (!Number.isFinite(season)) continue;
    try {
      refs.push({ season, url: new URL(match[1], base).href });
    } catch {}
  }
  return refs;
}
async function analyzeRemotePost(url, site) {
  const target = safeSiteUrl(url),
    root = safeSiteUrl(site);
  if (!target || !root || target.origin !== root.origin)
    throw Error("Resultado no válido");
  const response = await fetch(target.href, {
      credentials: "include",
      cache: "no-store",
    }),
    text = (await response.text()).slice(0, 2200000),
    meta = metaFromHtml(text, response.url),
    catalog = { ...meta, sourceUrl: response.url, options: [], series: null };
  if (meta.type === "Película") {
    const discovery = await remoteProviderOptions(response.url, text, root.origin);
    catalog.options = discovery.options;
    catalog.sourceInventory = discovery.inventory;
    return catalog;
  }
  const episodeMap = new Map();
  for (const episode of episodesFromNextData(text, response.url))
    episodeMap.set(`${episode.season}:${episode.url}`, episode);
  const refs = seasonRefsFromHtml(text, response.url);
  if (!refs.length && !episodeMap.size)
    refs.push({ season: null, url: response.url });
  for (const ref of refs.slice(0, 16)) {
    try {
      const seasonResponse =
          ref.url === response.url
            ? response
            : await fetch(ref.url, {
                credentials: "include",
                cache: "no-store",
              }),
        seasonText =
          ref.url === response.url
            ? text
            : (await seasonResponse.text()).slice(0, 2000000);
      for (const episode of episodesFromHtml(
        seasonText,
        seasonResponse.url,
        ref.season,
      ))
        episodeMap.set(`${episode.season}:${episode.url}`, episode);
    } catch {}
  }
  const episodes = [...episodeMap.values()]
    .slice(0, 150)
    .sort(
      (a, b) => a.season - b.season || (a.episode || 999) - (b.episode || 999),
    );
  for (let i = 0; i < episodes.length; i += 5)
    await Promise.allSettled(
      episodes.slice(i, i + 5).map(async (episode) => {
        const episodeResponse = await fetch(episode.url, {
            credentials: "include",
            cache: "no-store",
          }),
          episodeText = (await episodeResponse.text()).slice(0, 1800000),
          episodeMeta = metaFromHtml(episodeText, episodeResponse.url);
        episode.label = episodeMeta.title || episode.label;
        episode.image = episodeMeta.image || episode.image;
        const discovery = await remoteProviderOptions(episodeResponse.url, episodeText, root.origin);
        episode.options = discovery.options;
        episode.sourceInventory = discovery.inventory;
      }),
    );
  catalog.series = { title: meta.title, image: meta.image, episodes };
  const inventory = new Map();
  for (const episode of episodes) for (const item of episode.sourceInventory || []) inventory.set(`${item.kind}:${item.host}`, item);
  catalog.sourceInventory = [...inventory.values()];
  return catalog;
}

async function analyzeCurrentPage({ tabId, url, title }, scanId) {
  const gathered = new Map(),
    add = (candidate, source, base = url, type = "") => {
      const normalized = decodeCandidate(candidate, base);
      if (!normalized || isLikelyAd(normalized)) return;
      const direct = isMedia(normalized);
      const playerRef =
        /(player|embed|stream|watch|video|media|play|streamwish|filemoon|vidhide|voesx|voe(?=[._\/-])|doodstream|medixiru|streamtape|mixdrop|filelions|lulustream)/i.test(
          normalized,
        );
      if (direct || playerRef || isMedia(normalized, type))
        gathered.set(normalized, {
          url: normalized,
          source,
          type,
          direct: direct || isMedia(normalized, type),
          confidence:
            direct || isMedia(normalized, type) ? "alta" : "por verificar",
        });
    };
  for (const item of mediaByTab.get(tabId) || [])
    if (!item.isAd)
      add(item.url, item.source || "network", url, item.type || "");
  const frames = await chrome.scripting.executeScript({
    target: { tabId, allFrames: true },
    func: snapshotPage,
  });
  for (const frame of frames)
    for (const candidate of frame.result?.urls || [])
      add(candidate, "página", frame.result?.frameUrl || url);
  for (const candidate of providersByTab.get(tabId) || [])
    add(candidate, "proveedor compatible");
  const page = frames.find((frame) => frame.frameId === 0)?.result || {};
  await saveScan(scanId, {
    status: "verifying",
    candidates: [...gathered.values()],
    title: page.title || title,
    description: page.description || "",
    image: page.image || "",
    sourceUrl: url,
  });
  const references = [...gathered.values()]
    .filter((x) => !x.direct)
    .slice(0, 35);
  await Promise.allSettled(
    references.map(async (ref) => {
      const parsed = new URL(ref.url);
      if (
        /^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(
          parsed.hostname,
        )
      )
        return;
      const sameOrigin = parsed.origin === new URL(url).origin;
      const response = await fetch(ref.url, {
        method: "GET",
        redirect: "follow",
        credentials: sameOrigin ? "include" : "omit",
        cache: "no-store",
      });
      const type = response.headers.get("content-type") || "";
      if (isMedia(response.url, type)) {
        add(response.url, "reproductor verificado", response.url, type);
        return;
      }
      if (/text\/(?:html|plain)|javascript|json/i.test(type)) {
        const text = (await response.text()).slice(0, 1500000);
        for (const candidate of urlsFromText(text, response.url))
          add(candidate, "configuración del reproductor", response.url);
      }
    }),
  );
  const providerMode =
    [...gathered.keys()].some((value) =>
      /(streamwish|filemoon|vidhide|voesx|voe(?=[._\/-])|doodstream|medixiru|streamtape|mixdrop|filelions|lulustream)/i.test(
        value,
      ),
    ) || (providersByTab.get(tabId) || []).length > 0;
  const results = [...gathered.values()].filter(
    (x) =>
      x.direct &&
      !isLikelyAd(x.url) &&
      (!providerMode || isHls(x.url, x.type || "")),
  );
  await saveScan(scanId, {
    status: "complete",
    candidates: [...gathered.values()],
    results,
  });
}

chrome.webRequest.onHeadersReceived.addListener(
  (details) => {
    if (details.tabId < 0 || !protectedTabs.has(details.tabId)) return;
    const type =
      (details.responseHeaders || []).find(
        (h) => h.name.toLowerCase() === "content-type",
      )?.value || "";
    if (!isMedia(details.url, type)) return;
    const list = mediaByTab.get(details.tabId) || [];
    if (!list.some((item) => item.url === details.url)) {
      list.push({
        url: details.url,
        type,
        source: "network",
        isAd: isLikelyAd(details.url, type),
      });
      mediaByTab.set(details.tabId, list.slice(-30));
      chrome.action.setBadgeBackgroundColor({
        tabId: details.tabId,
        color: "#e63946",
      });
      chrome.action.setBadgeText({
        tabId: details.tabId,
        text: String(Math.min(list.length, 99)),
      });
      const safeAutoSource = isHls(details.url, type);
      const trustedVideoContext =
        protectedTabs.has(details.tabId) ||
        (providersByTab.get(details.tabId) || []).length > 0;
      if (
        safeAutoSource &&
        !isLikelyAd(details.url, type) &&
        trustedVideoContext &&
        !autoOpenedByTab.has(details.tabId)
      ) {
        chrome.storage.local
          .get({ autoOpenDetected: true })
          .then(async ({ autoOpenDetected }) => {
            if (!autoOpenDetected || autoOpenedByTab.has(details.tabId)) return;
            autoOpenedByTab.add(details.tabId);
            const { protectedMeta = {} } = await chrome.storage.session.get({
              protectedMeta: {},
            });
            const meta = protectedMeta[details.tabId] || {};
            const smart = smartAttempts.get(details.tabId);
            if (smart) {
              if (smart.timer) clearTimeout(smart.timer);
              smart.waitingValidation = true;
              smart.mediaUrl = details.url;
              const remaining = Math.max(1000, SMART_GLOBAL_TIMEOUT_MS - (Date.now() - smart.globalStartedAt));
              smart.timer = setTimeout(() => runNextSmartSource(details.tabId, 'timeout'), Math.min(SMART_VALIDATION_TIMEOUT_MS, remaining));
            }
            chrome.tabs
              .get(details.tabId)
              .then(async (tab) => {
                const contextId = crypto.randomUUID(),
                  { playerContexts = {} } = await chrome.storage.session.get({
                    playerContexts: {},
                  });
                playerContexts[contextId] = { ...meta, createdAt: Date.now() };
                for (const [key, value] of Object.entries(playerContexts))
                  if (Date.now() - (value.createdAt || 0) > 86400000)
                    delete playerContexts[key];
                await chrome.storage.session.set({ playerContexts });
                const target = chrome.runtime.getURL(
                  `player.html?src=${encodeURIComponent(details.url)}&title=${encodeURIComponent(meta.title || tab.title || "Video")}&image=${encodeURIComponent(meta.image || "")}&post=${encodeURIComponent(meta.postUrl || tab.url || "")}&context=${encodeURIComponent(contextId)}&attempt=${encodeURIComponent(smart ? details.tabId : "")}`,
                );
                if (meta.playerTabId != null)
                  await chrome.tabs.update(meta.playerTabId, {
                    url: target,
                    active: true,
                  });
                else await chrome.tabs.create({ url: target, active: true });
                chrome.tabs
                  .sendMessage(details.tabId, { type: "PAUSE_ORIGINAL" })
                  .catch(() => {});
                if (!smart && protectedTabs.has(details.tabId))
                  await chrome.tabs.remove(details.tabId).catch(() => {});
              })
              .catch(() => {});
          });
      }
    }
  },
  { urls: ["<all_urls>"] },
  ["responseHeaders"],
);

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === "GET_HIRO_AUTH_STATE") {
    hiroAuthState(Boolean(message.verify)).then(async (state) => {
      if (state.authenticated || state.deleted) return sendResponse(state);
      try { sendResponse(await pollHiroPairing()); } catch { sendResponse(state); }
    });
    return true;
  }
  if (message.type === "START_HIRO_PAIRING") {
    startHiroPairing().then(sendResponse).catch((error) => sendResponse({ ok: false, error: error.message }));
    return true;
  }
  if (message.type === "POLL_HIRO_PAIRING") {
    pollHiroPairing().then(sendResponse).catch(() => sendResponse({ authenticated: false }));
    return true;
  }
  if (message.type === "LOGOUT_HIRO") {
    chrome.storage.local.remove(["hiroAuthToken", "hiroUser", "hiroPairing", "hiroAccountNotice"]).then(() => sendResponse({ ok: true }));
    return true;
  }
  if (message.type === "GET_HIRO_AD") {
    hiroApi(`/ads?placement=${encodeURIComponent(message.placement || "extension_popup")}`).then(sendResponse).catch(() => sendResponse({ ok: false }));
    return true;
  }
  if (message.type === "REPORT_PLAY_START") {
    hiroApi("/watch/start", { method: "POST", body: message.payload || {} }).then(() => sendResponse({ ok: true })).catch((error) => sendResponse({ ok: false, error: error.message }));
    return true;
  }
  if (message.type === "DOWNLOAD_DIRECT_MEDIA") {
    startPcDownload(message).then(sendResponse).catch((error)=>sendResponse({ok:false,error:error.message||"No se pudo iniciar la descarga."}));
    return true;
  }
  if (message.type === "OPEN_DOWNLOADS") {
    chrome.tabs.create({url:chrome.runtime.getURL("downloads.html"),active:true}).then(()=>sendResponse({ok:true})).catch(()=>sendResponse({ok:false}));
    return true;
  }
  if (message.type === "REPORT_WATCH_SECONDS") {
    hiroApi("/watch/heartbeat", { method: "POST", body: message.payload || {} }).then(() => sendResponse({ ok: true })).catch((error) => sendResponse({ ok: false, error: error.message }));
    return true;
  }
  if (message.type === "SMART_PLAYBACK_CONFIRMED") {
    const tabId = Number(message.attemptTabId), attempt = smartAttempts.get(tabId);
    if (attempt) {
      if (attempt.timer) clearTimeout(attempt.timer);
      attempt.finished = true;
      reportSourceOutcome(attempt, 'success', String(message.mediaUrl || attempt.mediaUrl || '')).catch(() => {});
      smartAttempts.delete(tabId);
    }
    if (Number.isInteger(tabId)) chrome.tabs.remove(tabId).catch(() => {});
    sendResponse({ ok: true });
    return;
  }
  if (message.type === "SMART_SOURCE_FAILED") {
    const tabId = Number(message.attemptTabId);
    if (smartAttempts.has(tabId)) runNextSmartSource(tabId, 'failure').catch(() => {});
    sendResponse({ ok: true });
    return;
  }
  if (message.type === "INJECT_INTERFACE_STYLES" && sender.tab?.id != null) {
    chrome.scripting
      .insertCSS({
        target: { tabId: sender.tab.id, frameIds: [sender.frameId || 0] },
        files: [
          "content.css",
          "analyze-prompt.css",
          "inline-catalog.css",
          "series-catalog.css",
          "series-responsive.css",
          "season-menu.css",
          "resume-banner.css",
        ],
      })
      .then(() => sendResponse({ ok: true }))
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  if (message.type === "OPEN_SITE_EXPLORER") {
    hiroAuthState(false).then((state) => {
      if (!state.authenticated) return sendResponse({ ok: false, error: "Conecta tu cuenta de HIRO PLAY." });
      chrome.tabs.create({ url: chrome.runtime.getURL("explore.html"), active: true });
      sendResponse({ ok: true });
    });
    return true;
  }
  if (message.type === "GET_EXPLORE_PAGE") {
    getHiroCatalogPage(message.filter, message.page, 20)
      .then((data) => sendResponse({ ok: true, ...data }))
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudo cargar el catálogo",
        }),
      );
    return true;
  }
  if (message.type === "REFRESH_EXPLORE_CATALOG") {
    refreshHiroCatalog()
      .then((data) => sendResponse({ ok: true, ...data }))
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudo actualizar el catálogo",
        }),
      );
    return true;
  }
  if (message.type === "CHECK_REMOTE_VERSION") {
    checkRemoteVersion().then(sendResponse);
    return true;
  }
  if (message.type === "GET_HOME_FEATURES") {
    getHomeFeatures()
      .then((data) => sendResponse({ ok: true, ...data }))
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudieron cargar los destacados",
        }),
      );
    return true;
  }
  if (message.type === "GET_GENRE_PAGE") {
    getGenrePage(message.genre, message.page)
      .then((data) => sendResponse({ ok: true, ...data }))
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudo cargar el género",
        }),
      );
    return true;
  }
  if (message.type === "INDEX_EXPLORE_SITE") {
    stableSiteIndex(message.site)
      .then((index) =>
        sendResponse({
          ok: true,
          site: index.site,
          count: index.items.length,
          seriesCount: index.seriesCount,
          movieCount: index.movieCount,
        }),
      )
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudo indexar el sitio",
        }),
      );
    return true;
  }
  if (message.type === "SEARCH_EXPLORE_SITE") {
    searchHiroCatalog(message.query || "", message.limit)
      .then((items) => sendResponse({ ok: true, items }))
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudo buscar",
        }),
      );
    return true;
  }
  if (message.type === "ANALYZE_REMOTE_POST") {
    hiroAuthState(false).then((state) => {
      if (!state.authenticated) throw Error("Conecta tu cuenta de HIRO PLAY.");
      const site = message.site && message.site !== "https://hiroplay.lat" ? message.site : new URL(message.url).origin;
      return analyzeRemotePost(message.url, site);
    })
      .then((catalog) => {
        const options=[...(catalog.options||[]),...((catalog.series?.episodes||[]).flatMap(x=>x.options||[]))];
        const providers=options.map(x=>x.provider||'').filter(Boolean);
        if(providers.length)hiroApi('/catalog/source-status',{method:'POST',body:{source_url:catalog.sourceUrl||message.url,source_count:options.length,providers:[...new Set(providers)].join(', '),sources:options.map(x=>x.url).filter(Boolean)}}).catch(()=>{});
        if(catalog.sourceInventory?.length)hiroApi('/sources/discovery',{method:'POST',body:{content_url:catalog.sourceUrl||message.url,items:catalog.sourceInventory.slice(0,100)}}).catch(()=>{});
        sendResponse({ ok: true, catalog });
      })
      .catch((error) =>
        sendResponse({
          ok: false,
          error: error.message || "No se pudo analizar el resultado",
        }),
      );
    return true;
  }
  if (message.type === "GET_RESUME_SUGGESTION") {
    (async () => {
      const session = await chrome.storage.session.get({
        showResumeSuggestion: false,
      });
      if (!session.showResumeSuggestion) {
        sendResponse({ show: false });
        return;
      }
      await chrome.storage.session.set({ showResumeSuggestion: false });
      const { watchHistory = [], savedCatalog = [] } =
        await chrome.storage.local.get({ watchHistory: [], savedCatalog: [] });
      const unfinished = watchHistory
        .filter(
          (x) =>
            x.currentTime > 0 &&
            (!x.duration || x.currentTime < x.duration - 10),
        )
        .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
      sendResponse({
        show: Boolean(unfinished[0] || savedCatalog.length),
        last: unfinished[0] || null,
        hasCatalog: Boolean(savedCatalog.length || unfinished.length),
      });
    })().catch(() => sendResponse({ show: false }));
    return true;
  }
  if (message.type === "GET_PLAYER_CONTEXT") {
    chrome.storage.session
      .get({ playerContexts: {} })
      .then(({ playerContexts }) =>
        sendResponse(playerContexts[message.contextId] || null),
      );
    return true;
  }
  if (message.type === "OPEN_USER_CATALOG") {
    hiroAuthState(false).then((state) => {
      if (!state.authenticated) return sendResponse({ ok: false });
      chrome.tabs.create({ url: chrome.runtime.getURL("user-catalog.html"), active: true });
      sendResponse({ ok: true });
    });
    return true;
  }
  if (message.type === "CONTINUE_LAST_VIDEO" && message.item?.url) {
    const item = message.item,
      target = chrome.runtime.getURL(
        `player.html?src=${encodeURIComponent(item.url)}&title=${encodeURIComponent(item.title || "Video")}&image=${encodeURIComponent(item.image || "")}&post=${encodeURIComponent(item.postUrl || "")}`,
      );
    chrome.tabs.create({ url: target, active: true });
    sendResponse({ ok: true });
    return;
  }
  if (message.type === "OPEN_PROVIDER_CATALOG_DEEP" && sender.tab?.id != null) {
    withTimeout(
      expandAllSeasons(sender.tab),
      40000,
      "El análisis de reproductores tardó demasiado",
    )
      .then((catalog) => sendResponse({ ok: true, catalog }))
      .catch(() =>
        sendResponse({ ok: false, error: "No se pudo analizar esta página" }),
      );
    return true;
  }
  if (message.type === "OPEN_PROVIDER_CATALOG" && sender.tab?.id != null) {
    (async () => {
      const before = await chrome.scripting.executeScript({
        target: { tabId: sender.tab.id, allFrames: true },
        func: snapshotPage,
      });
      await chrome.scripting
        .executeScript({
          target: { tabId: sender.tab.id, allFrames: true },
          func: activateLatinoTab,
        })
        .catch(() => []);
      await new Promise((resolve) => setTimeout(resolve, 900));
      const after = await chrome.scripting.executeScript({
        target: { tabId: sender.tab.id, allFrames: true },
        func: snapshotPage,
      });
      const frames = [...before, ...after];
      const top =
        after.find((x) => x.frameId === 0)?.result ||
        before.find((x) => x.frameId === 0)?.result ||
        {};
      const unique = new Map();
      for (const frame of frames)
        for (const raw of frame.result?.urls || []) {
          let url;
          try {
            url = new URL(raw, frame.result?.frameUrl || sender.tab.url).href;
          } catch {
            continue;
          }
          const match = url.match(
            /(streamwish|filemoon|vidhide|voesx|voe(?=[._-])|doodstream|medixiru|streamtape|mixdrop|filelions|lulustream)/i,
          );
          if (match && !isMedia(url) && !unique.has(url))
            unique.set(url, { url, provider: match[1] });
        }
      sendResponse({
        ok: true,
        catalog: {
          title: top.title || sender.tab.title,
          description: top.description || "",
          image: top.image || "",
          sourceUrl: sender.tab.url,
          options: [...unique.values()],
        },
      });
    })().catch(() =>
      sendResponse({ ok: false, error: "No se pudo analizar esta página" }),
    );
    return true;
  }
  if (message.type === "OPEN_PROTECTED_PLAYER") {
    let url;
    try {
      url = new URL(message.url);
    } catch {
      sendResponse({ ok: false });
      return;
    }
    if (!/^https?:$/.test(url.protocol) || !isAllowedPlayerUrl(url.href)) {
      sendResponse({ ok: false });
      return;
    }
    const sourceTabId = sender.tab?.id,
      canCloseSource =
        message.closeSourceTab &&
        sender.tab?.url?.startsWith(chrome.runtime.getURL("player.html"));
    chrome.tabs
      .create({ url: "about:blank", active: true })
      .then(async (tab) => {
        protectedTabs.add(tab.id);
        await persistProtectedTabs();
        const { protectedMeta = {} } = await chrome.storage.session.get({
          protectedMeta: {},
        });
        protectedMeta[tab.id] = {
          title: message.title || "",
          image: message.image || "",
          postUrl: message.postUrl || "",
          providerUrl: url.href,
          series: message.series || null,
        };
        await chrome.storage.session.set({ protectedMeta });
        await chrome.tabs.update(tab.id, { url: url.href });
        if (canCloseSource && sourceTabId != null)
          await chrome.tabs.remove(sourceTabId).catch(() => {});
        sendResponse({ ok: true, tabId: tab.id });
      })
      .catch(() => sendResponse({ ok: false }));
    return true;
  }
  if (message.type === "OPEN_SMART_PLAYER") {
    (async () => {
      const sources = await rankSmartSources(message.urls || []);
      if (!sources.length) { sendResponse({ ok: false, error: 'No hay fuentes reproducibles verificadas.' }); return; }
      const auth = await hiroAuthState(false);
      if (!auth.authenticated) throw Error("AUTH_REQUIRED");
      const oldPlayerTabId = message.closeSourceTab && sender.tab?.url?.startsWith(chrome.runtime.getURL("player.html")) ? sender.tab.id : null;
      const searchUrl = chrome.runtime.getURL(`player.html?search=1&title=${encodeURIComponent(message.title || "Video")}&image=${encodeURIComponent(message.image || "")}&post=${encodeURIComponent(message.postUrl || "")}`);
      const playerTab = await chrome.tabs.create({ url: searchUrl, active: true });
      const providerTab = await chrome.tabs.create({ url: "about:blank", active: false });
      protectedTabs.add(providerTab.id);
      await persistProtectedTabs();
      const { protectedMeta = {} } = await chrome.storage.session.get({ protectedMeta: {} });
      protectedMeta[providerTab.id] = { title: message.title || "", image: message.image || "", postUrl: message.postUrl || "", providerUrl: "", sources, sourceIndex: -1, playerTabId: playerTab.id, series: message.series || null };
      await chrome.storage.session.set({ protectedMeta });
      startSmartSourceTimer(providerTab.id, { sources, playerTabId: playerTab.id, title: message.title || "Video", image: message.image || "", postUrl: message.postUrl || "", series: message.series || null });
      if (oldPlayerTabId != null) await chrome.tabs.remove(oldPlayerTabId).catch(() => {});
      sendResponse({ ok: true, playerTabId: playerTab.id, sources: sources.length });
    })().catch((error) => sendResponse({ ok: false, error: error?.message || 'No se pudo iniciar el reproductor.' }));
    return true;
  }
  if (message.type === "ADS_BLOCKED" && sender.tab?.id != null) {
    const total =
      (blockedByTab.get(sender.tab.id) || 0) +
      Math.max(1, Number(message.count) || 1);
    blockedByTab.set(sender.tab.id, total);
    sendResponse({ ok: true, total });
    return;
  }
  if (message.type === "GET_BLOCK_STATS") {
    sendResponse({ total: blockedByTab.get(message.tabId) || 0 });
    return;
  }
  if (message.type === "PROVIDERS_FOUND" && sender.tab?.id != null) {
    const list = providersByTab.get(sender.tab.id) || [];
    for (const url of message.urls || [])
      if (!list.includes(url)) list.push(url);
    providersByTab.set(sender.tab.id, list.slice(-30));
    chrome.action.setBadgeBackgroundColor({
      tabId: sender.tab.id,
      color: "#e63946",
    });
    chrome.action.setBadgeText({
      tabId: sender.tab.id,
      text: String(list.length),
    });
    sendResponse({ ok: true, count: list.length });
    return;
  }
  if (message.type === "FOUND_MEDIA" && sender.tab?.id != null) {
    const list = mediaByTab.get(sender.tab.id) || [];
    for (const item of message.items || []) {
      if (item.url && !list.some((x) => x.url === item.url))
        list.push({ ...item, source: item.source || "page" });
    }
    mediaByTab.set(sender.tab.id, list.slice(-30));
    sendResponse({ ok: true });
    return;
  }
  if (message.type === "GET_MEDIA") {
    const providerMode = (providersByTab.get(message.tabId) || []).length > 0;
    sendResponse({
      items: (mediaByTab.get(message.tabId) || []).filter(
        (item) => !item.isAd && (!providerMode || isHls(item.url, item.type)),
      ),
      providers: providersByTab.get(message.tabId) || [],
    });
    return;
  }
  if (message.type === "SCAN_CURRENT") {
    if (!message.tabId || !/^https?:/i.test(message.url || "")) {
      sendResponse({ ok: false, error: "Esta pestaña no se puede analizar" });
      return;
    }
    const scanId = `${Date.now()}-${message.tabId}`;
    saveScan(scanId, {
      status: "starting",
      sourceUrl: message.url,
      title: message.title,
      results: [],
      candidates: [],
    })
      .then(() =>
        chrome.tabs.create({
          url: chrome.runtime.getURL(
            `scanner-results.html?id=${encodeURIComponent(scanId)}`,
          ),
        }),
      )
      .then(() => analyzeCurrentPage(message, scanId))
      .catch((error) =>
        saveScan(scanId, {
          status: "error",
          error: error?.message || "No se pudo analizar la página",
        }),
      );
    sendResponse({ ok: true, scanId });
    return;
  }
  if (message.type === "GET_SCAN_RESULTS") {
    chrome.storage.session
      .get({ scanSessions: {} })
      .then(({ scanSessions }) =>
        sendResponse(scanSessions[message.scanId] || null),
      );
    return true;
  }
  if (message.type === "START_SCAN") {
    let url;
    try {
      url = new URL(message.url);
    } catch {
      sendResponse({ ok: false, error: "Dirección no válida" });
      return;
    }
    if (!/^https?:$/.test(url.protocol)) {
      sendResponse({ ok: false, error: "Utiliza un enlace http o https" });
      return;
    }
    (async () => {
      const tab = await chrome.tabs.create({
        url: "about:blank",
        active: true,
      });
      const { scanTabs = {} } = await chrome.storage.session.get({
        scanTabs: {},
      });
      scanTabs[tab.id] = { url: url.href, startedAt: Date.now() };
      await chrome.storage.session.set({ scanTabs });
      await chrome.tabs.update(tab.id, { url: url.href });
      sendResponse({ ok: true, tabId: tab.id });
    })().catch(() =>
      sendResponse({ ok: false, error: "No se pudo abrir el enlace" }),
    );
    return true;
  }
  if (message.type === "GET_SCAN_MODE" && sender.tab?.id != null) {
    chrome.storage.session
      .get({ scanTabs: {} })
      .then(({ scanTabs }) =>
        sendResponse({ deepScan: Boolean(scanTabs[sender.tab.id]) }),
      );
    return true;
  }
  if (message.type === "PROBE_CANDIDATES" && sender.tab?.id != null) {
    const tabId = sender.tab.id,
      seen = probedByTab.get(tabId) || new Set();
    probedByTab.set(tabId, seen);
    const pageOrigin = (() => {
      try {
        return new URL(sender.tab.url).origin;
      } catch {
        return "";
      }
    })();
    const candidates = (message.urls || [])
      .filter((url) => {
        if (seen.has(url)) return false;
        try {
          const parsed = new URL(url);
          return (
            /^https?:$/.test(parsed.protocol) &&
            !/^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?$)/i.test(
              parsed.hostname,
            )
          );
        } catch {
          return false;
        }
      })
      .slice(0, 20);
    candidates.forEach((url) => seen.add(url));
    Promise.allSettled(
      candidates.map(async (url) => {
        const credentials =
          new URL(url).origin === pageOrigin ? "include" : "omit";
        const response = await fetch(url, {
          method: "HEAD",
          redirect: "follow",
          credentials,
          cache: "no-store",
        });
        const type = response.headers.get("content-type") || "";
        if (!isMedia(response.url, type) || isLikelyAd(response.url, type))
          return;
        const list = mediaByTab.get(tabId) || [];
        if (!list.some((item) => item.url === response.url))
          list.push({
            url: response.url,
            type,
            source: "deep-scan",
            isAd: false,
          });
        mediaByTab.set(tabId, list.slice(-30));
      }),
    ).then(() => sendResponse({ ok: true }));
    return true;
  }
  if (message.type === "OPEN_PLAYER") {
    const target = chrome.runtime.getURL(
      `player.html?src=${encodeURIComponent(message.url)}&title=${encodeURIComponent(message.title || "Video")}&image=${encodeURIComponent(message.image || "")}&post=${encodeURIComponent(message.postUrl || "")}`,
    );
    chrome.tabs.create({ url: target });
  }
});
