// Heresy Lister — Background Service Worker
// Auto cookie sync with Eldorado.gg

const HERESY_PORT = 3000;
const PORT_SCAN_RANGE = 20;
const WATCH_COOKIES = ['__Host-EldoradoIdToken', '__Host-EldoradoRefreshToken'];
let _lastSync = 0;
const SYNC_COOLDOWN = 10000;

async function discoverPort(preferred) {
  const tryPort = async (p) => {
    try {
      const r = await fetch('http://127.0.0.1:' + p + '/eldorado/api/ext/ping', { method: 'GET', cache: 'no-store' });
      if (!r.ok) return false;
      const j = await r.json();
      return j && j.app === 'heresy-lister';
    } catch { return false; }
  };
  if (preferred && await tryPort(preferred)) return preferred;
  for (let i = 0; i < PORT_SCAN_RANGE; i++) {
    const p = HERESY_PORT + i;
    if (p === preferred) continue;
    if (await tryPort(p)) {
      await chrome.storage.local.set({ heresy_port: p });
      return p;
    }
  }
  return null;
}

// ── Cookie Sync ──────────────────────────────────────────────
chrome.cookies.onChanged.addListener(async (change) => {
  if (!change.cookie.domain.includes('eldorado')) return;
  if (!WATCH_COOKIES.includes(change.cookie.name)) return;
  if (change.removed) return;
  const now = Date.now();
  if (now - _lastSync < SYNC_COOLDOWN) return;

  // Only sync when BOTH auth cookies are present — Eldorado rejects requests with idToken alone
  const all = await chrome.cookies.getAll({ domain: 'eldorado.gg' });
  const names = new Set(all.map(c => c.name));
  if (!WATCH_COOKIES.every(n => names.has(n))) {
    console.log('[Heresy] Auth cookie changed but still waiting for the pair — names:', [...names].join(','));
    return;
  }

  _lastSync = now;
  console.log('[Heresy] Both auth cookies present — syncing...');
  await syncCookies();
});

async function syncCookies() {
  const cfg = await chrome.storage.local.get(['heresy_token', 'heresy_port']);
  const token = cfg.heresy_token;
  if (!token) { console.log('[Heresy] No token set'); return false; }

  const cookies = await chrome.cookies.getAll({ domain: 'eldorado.gg' });
  if (!cookies.length) return false;
  const names = new Set(cookies.map(c => c.name));
  if (!WATCH_COOKIES.every(n => names.has(n))) return false;
  const cookieStr = cookies.map(c => c.name + '=' + c.value).join('; ');
  const userAgent = navigator.userAgent;

  const port = await discoverPort(cfg.heresy_port || HERESY_PORT);
  if (!port) { console.log('[Heresy] App not found on ports 3000-3019'); return false; }

  try {
    const extVersion = (chrome.runtime.getManifest && chrome.runtime.getManifest().version) || '';
    const r = await fetch('http://127.0.0.1:' + port + '/eldorado/api/ext/cookies', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ cookies: cookieStr, token, userAgent, version: extVersion }),
    });
    const data = await r.json();
    if (data.ok) {
      console.log('[Heresy] Sync OK —', data.nick);
      chrome.action.setBadgeText({ text: '✓' });
      chrome.action.setBadgeBackgroundColor({ color: '#34d399' });
      setTimeout(() => chrome.action.setBadgeText({ text: '' }), 5000);
      return true;
    } else {
      console.error('[Heresy] Sync error:', data.error);
      return false;
    }
  } catch (e) {
    return false;
  }
}

// ── Auto-sync: periodically check if Eldorado is open and sync ──
async function autoSync() {
  const cfg = await chrome.storage.local.get(['heresy_token']);
  if (!cfg.heresy_token) return;
  const tabs = await chrome.tabs.query({ url: 'https://*.eldorado.gg/*' });
  if (!tabs.length) return;
  const now = Date.now();
  if (now - _lastSync < SYNC_COOLDOWN) return;
  _lastSync = now;
  await syncCookies();
}

setInterval(autoSync, 30000);
chrome.runtime.onStartup.addListener(() => setTimeout(autoSync, 5000));
chrome.runtime.onInstalled.addListener(() => setTimeout(autoSync, 3000));

// ── Message Handler ──────────────────────────────────────────
chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
  if (msg.type === 'sync') {
    syncCookies().then(() => reply({ ok: true })).catch(e => reply({ ok: false, error: e.message }));
    return true;
  }
});

// ── API Relay ───────────────────────────────────────────────────────────────
// Long-poll the Lister for outbound API jobs and execute them in the user's
// real browser. The browser already holds a working cf_clearance + Chrome TLS,
// so Cloudflare doesn't challenge — solving the entire "bridge can't pass CF"
// problem by relaying through this extension instead of the hidden Electron
// window. The Lister's eldoradoFetch picks this path whenever it sees recent
// poll activity from us.

let _relayActive = false;          // single-flight guard so only one poll loop runs
let _relayCooldownUntil = 0;        // backoff after errors

async function relayPollOnce(port, token) {
  // 30s timeout matches the Lister's POLL_TIMEOUT_MS (25s) + a safety margin.
  // Service workers can't use AbortController across yields reliably, so rely
  // on the server-side timeout to bound the wait.
  const r = await fetch('http://127.0.0.1:' + port + '/eldorado/api/ext/poll-request', {
    method: 'GET',
    headers: { 'X-Edgar-Token': token },
    cache: 'no-store',
  });
  if (r.status === 204) return null; // no work, re-poll immediately
  if (!r.ok) throw new Error('poll status ' + r.status);
  return await r.json();
}

// Auto-recovery state for tab management. The relay tries to keep at least
// one eldorado.gg tab alive, and reloads tabs when CF challenges hit so the
// user never has to F5 manually.
let _lastTabReload = 0;
const TAB_RELOAD_COOLDOWN_MS = 30_000;

function _isCfChallengeResult(r) {
  if (!r) return false;
  if (r.status !== 403 && r.status !== 503) return false;
  const raw = r.data && r.data._raw;
  return typeof raw === 'string' && (raw.includes('Just a moment') || raw.includes('challenge-platform') || raw.includes('_cf_chl_opt'));
}

async function _ensureEldoradoTab() {
  let tabs = await chrome.tabs.query({ url: ['https://*.eldorado.gg/*'] });
  if (tabs && tabs.length) return tabs;
  // No eldorado.gg tab → open one in the background. Background tab
  // (active:false) doesn't steal user focus.
  console.log('[Heresy SW] no eldorado.gg tab — opening background tab');
  try {
    const newTab = await chrome.tabs.create({ url: 'https://www.eldorado.gg/', active: false });
    // Give it time to load + CF challenge to resolve. 8s is conservative.
    await new Promise(r => setTimeout(r, 8000));
    return [newTab];
  } catch (e) {
    console.log('[Heresy SW] tab create failed:', e.message);
    return [];
  }
}

async function _reloadTabAndWait(tabId) {
  if (Date.now() - _lastTabReload < TAB_RELOAD_COOLDOWN_MS) {
    console.log('[Heresy SW] reload cooldown active — skipping');
    return false;
  }
  console.log('[Heresy SW] CF challenge detected — auto-reloading tab ' + tabId);
  _lastTabReload = Date.now();
  try {
    await chrome.tabs.reload(tabId, { bypassCache: false });
    // CF challenge solve takes ~4-6s. 8s pad.
    await new Promise(r => setTimeout(r, 8000));
    return true;
  } catch (e) {
    console.log('[Heresy SW] reload failed:', e.message);
    return false;
  }
}

// Inject and execute the fetch directly into an eldorado.gg tab. Using
// chrome.scripting.executeScript (vs static-content-script messaging) means we
// don't depend on the bridge.js content script being the latest version in
// already-open tabs — we run a fresh inline function in the tab's renderer on
// every job. The function runs with the page's TLS handshake so Cloudflare
// accepts the cf_clearance cookie and we get real responses.
//
// Auto-recovery flow:
//   1. No tab open?       → create a background tab, wait for CF solve.
//   2. Job returns CF HTML → reload the tab, retry the job once.
// Either way, the user never needs to manually F5.
// P78 — URL allowlist. Job URLs arrive over 127.0.0.1 from the local app; without this a rogue
// local listener (that wins the port race) or a malformed job could point the relay at ANY origin
// and we'd attach the page's Eldorado XSRF token + credentials to it (token exfiltration). The
// relay is Eldorado-only by design (server-side apiFetch is only ever called with eldorado URLs);
// talkjs.com is included defensively since it's a legitimate app origin in host_permissions.
function _isEldoradoJobUrl(url) {
  try {
    const u = new URL(String(url));
    if (u.protocol !== 'https:') return false;
    const h = u.hostname.toLowerCase();
    return h === 'eldorado.gg' || h.endsWith('.eldorado.gg') || h === 'talkjs.com' || h.endsWith('.talkjs.com');
  } catch (_) { return false; }
}
async function relayExecuteJob(job) {
  // URL allowlist (P78): reject any non-eldorado/talkjs target before it can receive the XSRF token.
  if (!_isEldoradoJobUrl(job && job.url)) {
    console.warn('[Heresy SW] blocked non-allowlisted relay url:', job && job.url);
    return { id: job && job.id, error: 'blocked non-allowlisted url' };
  }
  let tabs;
  try {
    tabs = await _ensureEldoradoTab();
  } catch (e) {
    return { id: job.id, error: 'tabs query failed: ' + e.message };
  }
  if (!tabs || !tabs.length) return { id: job.id, error: 'no eldorado.gg tab and could not open one' };

  // Function executed inside the tab for plain JSON/text fetches. Kept
  // self-contained — chrome.scripting serializes args, no closures.
  // XSRF override: same logic as pageUpload — page cookie is the source of
  // truth, the backend's stored XSRF can be stale.
  const pageFetch = async (method, url, headers, body) => {
    // P78 defense-in-depth: re-check the allowlist IN-PAGE so this credentialed, XSRF-bearing fetch
    // fails closed even if a future caller ever reaches it without relayExecuteJob's outer gate. The
    // predicate is duplicated from _isEldoradoJobUrl on purpose — chrome.scripting serializes this
    // function into the page with no access to closures.
    try {
      const _u = new URL(String(url));
      const _h = _u.hostname.toLowerCase();
      if (!(_u.protocol === 'https:' && (_h === 'eldorado.gg' || _h.endsWith('.eldorado.gg') || _h === 'talkjs.com' || _h.endsWith('.talkjs.com')))) {
        return { error: 'blocked non-allowlisted url (in-page)' };
      }
    } catch (_) { return { error: 'blocked non-allowlisted url (in-page)' }; }
    const finalHeaders = Object.assign({}, headers || {});
    const xsrfMatch = (document.cookie || '').match(/(?:^|;\s*)(?:__Host-)?XSRF-TOKEN=([^;]+)/);
    if (xsrfMatch) finalHeaders['X-XSRF-TOKEN'] = decodeURIComponent(xsrfMatch[1]);
    else delete finalHeaders['X-XSRF-TOKEN'];
    const init = { method, headers: finalHeaders, credentials: 'include', cache: 'no-store', redirect: 'follow' };
    if (body) init.body = body;
    try {
      const r = await fetch(url, init);
      const text = await r.text();
      let data;
      try { data = JSON.parse(text); } catch { data = { _raw: text.slice(0, 1000) }; }
      return { status: r.status, ok: r.ok, data };
    } catch (e) {
      return { error: String(e && e.message || e) };
    }
  };

  // Function executed inside the tab for multipart uploads. Reconstructs a
  // Blob from base64 and posts it via FormData. Uploads were the last endpoint
  // still using the bridge (which Cloudflare 403'd on multipart) — running
  // this in the page's context means the request inherits the tab's TLS and
  // cf_clearance, so CF accepts it.
  //
  // X-XSRF-TOKEN: prefer the value from document.cookie over whatever the
  // backend sent us. The Lister's stored copy can be stale (page refreshes
  // its XSRF independently), and Eldorado validates that header matches the
  // cookie — mismatch returns 400 "invalid values: image" or similar.
  const pageUpload = async (url, headers, imageB64, fieldName, filename, mimetype) => {
    try {
      // P78 defense-in-depth: same in-page allowlist re-check as pageFetch — this is also a
      // credentialed, XSRF-bearing site. Fails closed even if a future caller skips the outer gate.
      const u = new URL(String(url));
      const h = u.hostname.toLowerCase();
      if (!(u.protocol === 'https:' && (h === 'eldorado.gg' || h.endsWith('.eldorado.gg') || h === 'talkjs.com' || h.endsWith('.talkjs.com')))) {
        return { error: 'blocked non-allowlisted url (in-page)' };
      }
      const xsrfMatch = (document.cookie || '').match(/(?:^|;\s*)(?:__Host-)?XSRF-TOKEN=([^;]+)/);
      const finalHeaders = Object.assign({}, headers || {});
      if (xsrfMatch) finalHeaders['X-XSRF-TOKEN'] = decodeURIComponent(xsrfMatch[1]);
      else delete finalHeaders['X-XSRF-TOKEN']; // don't ship a stale token

      const bin = atob(imageB64);
      const bytes = new Uint8Array(bin.length);
      for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
      const blob = new Blob([bytes], { type: mimetype });
      const fd = new FormData();
      fd.append(fieldName, blob, filename);
      const r = await fetch(url, {
        method: 'POST',
        headers: finalHeaders,
        body: fd,
        credentials: 'include',
        cache: 'no-store',
      });
      const text = await r.text();
      let data;
      try { data = JSON.parse(text); } catch { data = { _raw: text.slice(0, 1000) }; }
      return { status: r.status, ok: r.ok, data };
    } catch (e) {
      return { error: String(e && e.message || e) };
    }
  };

  const isUpload = !!job.upload;
  const argsFor = (j) => isUpload
    ? [j.url, j.headers || {}, j.imageB64 || '', j.fieldName || 'file', j.filename || 'upload.png', j.mimetype || 'image/png']
    : [j.method || 'GET', j.url, j.headers || {}, j.body || null];
  const runOnce = async (tabId) => {
    const results = await chrome.scripting.executeScript({
      target: { tabId },
      func: isUpload ? pageUpload : pageFetch,
      args: argsFor(job),
    });
    return results && results[0] && results[0].result;
  };

  let lastErr = 'all tabs failed';
  for (const tab of tabs) {
    try {
      let r = await runOnce(tab.id);
      if (!r) { lastErr = 'tab ' + tab.id + ' returned empty'; continue; }
      if (r.error) { lastErr = r.error; continue; }
      // CF challenge → auto-reload tab and retry once. After the reload, the
      // tab has a fresh cf_clearance and the retry typically succeeds.
      if (_isCfChallengeResult(r)) {
        const reloaded = await _reloadTabAndWait(tab.id);
        if (reloaded) {
          try {
            r = await runOnce(tab.id);
            if (r && !r.error && !_isCfChallengeResult(r)) {
              return { id: job.id, status: r.status, ok: r.ok, data: r.data };
            }
          } catch (_) {}
        }
        // Reload failed or retry still hits CF — fall through to next tab.
        lastErr = 'tab ' + tab.id + ': CF challenge persisted after reload';
        continue;
      }
      return { id: job.id, status: r.status, ok: r.ok, data: r.data };
    } catch (e) {
      lastErr = 'tab ' + tab.id + ': ' + e.message;
    }
  }
  return { id: job.id, error: lastErr };
}

async function relayPostResult(port, token, result) {
  await fetch('http://127.0.0.1:' + port + '/eldorado/api/ext/job-result', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-Edgar-Token': token },
    body: JSON.stringify(result),
  });
}

async function relayLoop() {
  if (_relayActive) return;
  _relayActive = true;
  console.log('[Heresy SW] relay loop started');
  try {
    while (true) {
      // Apply error backoff if a previous iteration failed.
      if (Date.now() < _relayCooldownUntil) {
        await new Promise(r => setTimeout(r, _relayCooldownUntil - Date.now()));
      }
      const cfg = await chrome.storage.local.get(['heresy_token', 'heresy_port']);
      const token = cfg.heresy_token;
      if (!token) {
        // No token configured — sleep and retry. Don't spin on the auth gap.
        await new Promise(r => setTimeout(r, 10000));
        continue;
      }
      const port = await discoverPort(cfg.heresy_port || HERESY_PORT);
      if (!port) {
        // Lister not running — back off 5s before re-discovering.
        _relayCooldownUntil = Date.now() + 5000;
        continue;
      }
      try {
        const job = await relayPollOnce(port, token);
        if (!job) continue; // 204, just loop again
        // Fire-and-forget the result: a slow result POST shouldn't block the next poll.
        const result = await relayExecuteJob(job);
        relayPostResult(port, token, result).catch(e => console.log('[Heresy] postResult err:', e.message));
      } catch (e) {
        // 401 → token wrong or removed. Stop the loop until the popup re-saves it.
        if (e.message && e.message.includes('401')) {
          console.log('[Heresy] relay auth failed, stopping loop');
          break;
        }
        console.log('[Heresy] relay poll err:', e.message);
        _relayCooldownUntil = Date.now() + 3000;
      }
    }
  } finally {
    _relayActive = false;
    console.log('[Heresy SW] relay loop stopped');
  }
}

// Boot the relay loop. The SW polls localhost (works) and dispatches the
// actual eldorado.gg fetch to a content script (which has the page's TLS).
// Alarm wakes the SW periodically in case Chrome evicts it while idle.
function bootRelay() {
  setTimeout(() => { relayLoop().catch(e => console.log('[Heresy SW] relay fatal:', e.message)); }, 1500);
}
bootRelay();
chrome.runtime.onStartup.addListener(bootRelay);
chrome.runtime.onInstalled.addListener(bootRelay);
try {
  chrome.alarms.create('heresy-relay-keepalive', { periodInMinutes: 1 });
  chrome.alarms.onAlarm.addListener((alarm) => {
    if (alarm.name === 'heresy-relay-keepalive' && !_relayActive) bootRelay();
  });
} catch (e) { console.log('[Heresy SW] alarms unavailable:', e.message); }
