// Runs the whole surf loop from the extension's background context: picks
// the next site, navigates a real tab straight to its URL (never an
// iframe — see README for why), tracks the dwell timer, heartbeats,
// confirms, and moves on. This is the direct-navigation counterpart to
// public/assets/js/surf.js's loadNext()/resumeHit()/performConfirm() loop —
// same server contract, same anti-fraud checks, different transport.
if (typeof importScripts === 'function') {
  // Chrome/Edge MV3 service worker — Firefox loads these same two files via
  // manifest.firefox.json's background.scripts array instead, since
  // Firefox's background page isn't a worker and has no importScripts().
  importScripts('browser-shim.js', 'api-client.js');
}

const STORAGE_KEY = 'surfState';
const HEARTBEAT_MS = 5000; // mirrors Settings::heartbeat_interval_seconds' default — see README on why this can't be made fully MV3-suspend-proof
const CONFIRM_SAFETY_ALARM = 'surf-confirm-safety';
const RETRY_ALARM = 'surf-retry';

let heartbeatTimer = null;
let confirmTimer = null;

// PTC (bidding-mode campaigns, 2026-07-23) — a separate, parallel
// one-shot flow, not folded into the surf loop above: different storage
// key, different tab (clickTabId, never surfTabId — a visitor could
// plausibly have both a surf run and a campaign click going at once, the
// server enforces its own independent one-pending-hit-per-user gate for
// each), different timers/alarm name so neither flow's setInterval/
// setTimeout can stomp the other's. Unlike surfing, there's no continuous
// "run" here — one campaign, one hit, done; ClickCampaign visits are
// member-initiated per click, not an auto-rotating queue.
const CLICK_STORAGE_KEY = 'clickState';
const CLICK_CONFIRM_SAFETY_ALARM = 'click-confirm-safety';
let clickHeartbeatTimer = null;
let clickConfirmTimer = null;

function defaultState() {
  return {
    username: null,
    running: false,
    paused: false,
    surfTabId: null,
    hit: null, // {hit_id, token, site, banner, required_seconds, credits_per_hit}
    startedAt: null, // ms epoch
    totalEarned: 0,
    totalVisited: 0,
    lastError: null,
    lastMessage: null,
  };
}

async function loadState() {
  const stored = await api.storage.local.get(STORAGE_KEY);
  return { ...defaultState(), ...(stored[STORAGE_KEY] || {}) };
}

async function saveState(patch) {
  const current = await loadState();
  const next = { ...current, ...patch };
  await api.storage.local.set({ [STORAGE_KEY]: next });
  broadcastState(next);
  return next;
}

function broadcastState(state) {
  // Best-effort — fails harmlessly (caught) if no popup is currently open
  // to receive it; the popup also pulls state fresh via GET_STATE on open.
  api.runtime.sendMessage({ type: 'STATE_UPDATE', state }).catch(() => {});
}

function clearTimers() {
  if (heartbeatTimer) clearInterval(heartbeatTimer);
  if (confirmTimer) clearTimeout(confirmTimer);
  heartbeatTimer = null;
  confirmTimer = null;
}

// ---------------------------------------------------------------- lifecycle

async function startSurfing(username) {
  clearTimers();
  await saveState({
    username, running: true, paused: false, hit: null, startedAt: null,
    totalEarned: 0, totalVisited: 0, lastError: null, lastMessage: 'Starting…',
  });
  await loadNext();
}

async function stopSurfing() {
  clearTimers();
  const state = await loadState();
  if (state.hit) {
    // Best-effort — don't block stopping on this succeeding.
    CortalyApi.skip(state.hit.hit_id, state.hit.token).catch(() => {});
  }
  await api.alarms.clear(CONFIRM_SAFETY_ALARM);
  await api.alarms.clear(RETRY_ALARM);
  await saveState({ running: false, paused: false, hit: null, startedAt: null, lastMessage: 'Stopped.' });
}

async function togglePause() {
  const state = await loadState();
  const paused = !state.paused;
  await saveState({ paused, lastMessage: paused ? 'Paused.' : 'Resuming…' });
  if (!paused && state.running && !state.hit) {
    // Was waiting between sites when paused — pick up where it left off.
    await loadNext();
  }
}

// ------------------------------------------------------------------- tabs

async function ensureSurfTab(url) {
  const state = await loadState();
  if (state.surfTabId) {
    try {
      const tab = await api.tabs.get(state.surfTabId);
      if (tab) {
        await api.tabs.update(state.surfTabId, { url });
        return state.surfTabId;
      }
    } catch (e) {
      // Tab was closed by the user — fall through and open a new one.
    }
  }
  const tab = await api.tabs.create({ url, active: false });
  await saveState({ surfTabId: tab.id });
  return tab.id;
}

// ------------------------------------------------------- click campaigns

function defaultClickState() {
  return {
    hit: null, // {hit_id, token, campaign, required_seconds, seconds_remaining, heartbeat_interval_seconds}
    startedAt: null, // ms epoch
    clickTabId: null,
    lastError: null,
    lastMessage: null,
  };
}

async function loadClickState() {
  const stored = await api.storage.local.get(CLICK_STORAGE_KEY);
  return { ...defaultClickState(), ...(stored[CLICK_STORAGE_KEY] || {}) };
}

async function saveClickState(patch) {
  const current = await loadClickState();
  const next = { ...current, ...patch };
  await api.storage.local.set({ [CLICK_STORAGE_KEY]: next });
  api.runtime.sendMessage({ type: 'CLICK_STATE_UPDATE', state: next }).catch(() => {});
  return next;
}

function clearClickTimers() {
  if (clickHeartbeatTimer) clearInterval(clickHeartbeatTimer);
  if (clickConfirmTimer) clearTimeout(clickConfirmTimer);
  clickHeartbeatTimer = null;
  clickConfirmTimer = null;
}

async function ensureClickTab(url) {
  const state = await loadClickState();
  if (state.clickTabId) {
    try {
      const tab = await api.tabs.get(state.clickTabId);
      if (tab) {
        // Unlike ensureSurfTab() (deliberately backgrounded — a continuous
        // rotation the visitor isn't meant to watch), a click campaign is a
        // one-shot action the visitor just triggered on purpose (clicked
        // "Click & Earn" or landed via auto-detect.js on click-view.php,
        // which is itself their current active tab) — bringing it to the
        // front gives immediate visible confirmation something happened,
        // which the injected overlay (see injectClickOverlay()) then keeps
        // showing progress on.
        await api.tabs.update(state.clickTabId, { url, active: true });
        return state.clickTabId;
      }
    } catch (e) {
      // Tab was closed by the user — fall through and open a new one.
    }
  }
  const tab = await api.tabs.create({ url, active: true });
  await saveClickState({ clickTabId: tab.id });
  return tab.id;
}

async function startClickCampaign(campaignId) {
  clearClickTimers();
  await saveClickState({ hit: null, startedAt: null, lastError: null, lastMessage: 'Starting…' });
  const res = await CortalyApi.clickStart(campaignId);
  if (!res.ok) {
    await handleClickStartError(res);
    return;
  }
  await resumeClickHit(res.hit, 'Visiting');
}

// Shared by a fresh start and the already_clicking resume path below — both
// end up with an identical {hit} to pick the timers back up from.
async function resumeClickHit(hit, verb) {
  // Routed through click-go.php rather than navigating straight to
  // hit.campaign.target_url: chrome.tabs.update()'s navigation carries NO
  // Referer header at all (unlike a link click or an iframe load), so a
  // direct tabs.update() made every extension-driven click look like a
  // referrer-less/direct visit to the advertiser — confirmed live,
  // rotate4all.com flagged real Cortaly traffic as "Unknown referrer/direct
  // visit". click-go.php's own server-side redirect fixes this: the
  // browser sets the NEXT hop's Referer from the page that issued the
  // redirect (this cortaly.com URL), regardless of how the browser got
  // here. See click-go.php's own comment for the full reasoning.
  const goUrl = `${CORTALY_BASE}/click-go.php?campaign_id=${hit.campaign.id}`;
  const tabId = await ensureClickTab(goUrl);
  const startedAt = Date.now() - (hit.required_seconds - hit.seconds_remaining) * 1000;
  await saveClickState({ hit, startedAt, lastError: null, lastMessage: `${verb}: ${hit.campaign.title}` });
  scheduleClickHeartbeat(hit);
  scheduleClickConfirm(hit, startedAt);
  runClickEnvCheck(hit, tabId).catch(() => {});
  injectClickOverlay(tabId, hit).catch(() => {});
}

async function injectClickOverlay(tabId, hit) {
  // Same "let the page settle first" delay as surf's injectOverlay(), plus
  // this specific tab is mid-redirect (click-go.php -> the real target_url)
  // when this call starts, so the extra time also covers that hop landing.
  await new Promise((resolve) => setTimeout(resolve, 1500));
  const state = await loadClickState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return; // hit already ended during the delay
  try {
    await api.scripting.executeScript({ target: { tabId }, files: ['browser-shim.js', 'content-script-click.js'] });
  } catch (e) {
    // Restricted URL, or the tab navigated away already — not fatal, the
    // hit itself still verifies/pays out normally without an overlay.
  }
}

async function handleClickStartError(res) {
  if (res.error === 'already_clicking' && res.hit) {
    // A pending hit already exists (e.g. a second click-view tab opened
    // while one was still running) — resume it rather than erroring, same
    // spirit as click.js's own already_clicking handling.
    await resumeClickHit(res.hit, 'Resuming');
    return;
  }
  const messages = {
    not_found: 'That campaign was not found.',
    not_available: 'This campaign is no longer available to click.',
    own_campaign: "You can't click your own campaign.",
    on_cooldown: "You've already earned from this campaign recently — check back later.",
    unauthenticated: 'Please log in to cortaly.com first.',
  };
  await saveClickState({ lastError: res.error, lastMessage: messages[res.error] || `Error: ${res.error}` });
}

function scheduleClickHeartbeat(hit) {
  clickHeartbeatTimer = setInterval(() => doClickHeartbeat(hit), HEARTBEAT_MS);
}

async function doClickHeartbeat(hit) {
  const state = await loadClickState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return; // stale timer from a previous hit
  if (state.clickTabId) {
    try {
      const tab = await api.tabs.get(state.clickTabId);
      if (!tab || !tab.url || !tab.url.startsWith(new URL(hit.campaign.target_url).origin)) {
        return;
      }
    } catch (e) {
      return;
    }
  }
  CortalyApi.clickHeartbeat(hit.hit_id, hit.token).catch(() => {});
}

function scheduleClickConfirm(hit, startedAt) {
  const remainingMs = Math.max(0, hit.required_seconds * 1000 - (Date.now() - startedAt));
  clickConfirmTimer = setTimeout(() => performClickConfirm(hit), remainingMs);
  const safetyDelayMs = Math.max(remainingMs + 10000, 30000);
  api.alarms.create(CLICK_CONFIRM_SAFETY_ALARM, { when: Date.now() + safetyDelayMs });
}

async function performClickConfirm(hit) {
  const state = await loadClickState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return; // already handled
  clearClickTimers();
  await api.alarms.clear(CLICK_CONFIRM_SAFETY_ALARM);

  // Question-Link answering isn't supported from the extension yet (no UI
  // surface for it here) — confirms with no answer, same as simply not
  // answering on the website: the click itself still pays out normally,
  // only the optional bonus is left unclaimed.
  const res = await CortalyApi.clickConfirm(hit.hit_id, hit.token);
  await saveClickState({
    hit: null,
    startedAt: null,
    lastMessage: res.ok
      ? `Confirmed — +${res.credits_earned} credits.`
      : 'That click could not be confirmed.',
  });
}

async function runClickEnvCheck(hit, tabId) {
  if (!tabId) return;
  await new Promise((resolve) => setTimeout(resolve, 2000));
  let results;
  try {
    const injection = await api.scripting.executeScript({ target: { tabId }, func: checkAdblockInPage });
    results = injection && injection[0] && injection[0].result;
  } catch (e) {
    return;
  }
  if (!results) return;
  const state = await loadClickState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return;
  CortalyApi.clickEnvCheck(hit.hit_id, hit.token, results.blocked, results.speedKbps).catch(() => {});
  if (results.blocked) {
    await saveClickState({ lastMessage: 'Ad blocker detected on this page — that click will not verify.' });
  }
}

api.tabs.onRemoved.addListener(async (closedTabId) => {
  const state = await loadState();
  if (state.surfTabId === closedTabId) {
    // The managed tab was closed out from under us — treat like a skip so
    // the run doesn't just hang waiting on a confirm/heartbeat that can
    // never happen again, matching surf.js's own "abandon, don't crash"
    // stance for any non-fraud interruption.
    clearTimers();
    if (state.hit) {
      CortalyApi.skip(state.hit.hit_id, state.hit.token).catch(() => {});
    }
    await saveState({ surfTabId: null, hit: null, startedAt: null, lastMessage: 'Surf tab was closed.' });
    if (state.running && !state.paused) {
      await loadNext();
    }
  }
  const clickState = await loadClickState();
  if (clickState.clickTabId === closedTabId) {
    clearClickTimers();
    if (clickState.hit) {
      CortalyApi.clickSkip(clickState.hit.hit_id, clickState.hit.token).catch(() => {});
    }
    await api.alarms.clear(CLICK_CONFIRM_SAFETY_ALARM);
    await saveClickState({ clickTabId: null, hit: null, startedAt: null, lastMessage: 'Click tab was closed.' });
  }
});

// -------------------------------------------------------------- the loop

async function loadNext() {
  const state = await loadState();
  if (!state.running || state.paused) return;

  const res = await CortalyApi.next(state.username);
  if (!res.ok) {
    await handleNextError(res.error);
    return;
  }

  const hit = res.hit;
  const tabId = await ensureSurfTab(hit.site.url);
  const startedAt = Date.now() - (hit.required_seconds - hit.seconds_remaining) * 1000;
  await saveState({ hit, startedAt, lastError: null, lastMessage: `Surfing: ${hit.site.title || hit.site.url}` });

  scheduleHeartbeat(hit);
  scheduleConfirm(hit, startedAt);
  runEnvCheck(hit).catch(() => {});
  injectOverlay(tabId, hit).catch(() => {});
}

async function injectOverlay(tabId, hit) {
  // Give the page a moment to finish its own load before laying our
  // overlay on top of it, so a heavy page's own late DOM changes don't
  // just bury/reflow it immediately.
  await new Promise((resolve) => setTimeout(resolve, 1500));
  // A hit that ended (confirmed/skipped) during this delay — e.g. from
  // rapid Skip clicks — must not inject a fresh overlay instance on top of
  // whatever hit is current by now; mirrors the same staleness guard
  // doHeartbeat()/runEnvCheck() already use.
  const state = await loadState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return;
  try {
    await api.scripting.executeScript({ target: { tabId }, files: ['browser-shim.js', 'content-script.js'] });
  } catch (e) {
    // Restricted URL (chrome://, an extension gallery page, a PDF viewer,
    // etc.) or the tab navigated away already — not fatal, the run
    // continues without an overlay on this particular hit.
  }
}

async function handleNextError(error) {
  const RETRYABLE_DELAYS_MS = { no_sites: 8000, ip_busy: 8000, default: 5000 };
  const messages = {
    no_sites: 'No sites available right now — retrying automatically.',
    rate_limited: 'Rate limited for now. Will keep trying.',
    ip_busy: 'Another surf session is active on this connection — retrying.',
    invalid_link: 'That username was not found or is not active. Surfing stopped.',
    disabled: 'Surfing is temporarily disabled site-wide. Will keep checking.',
    vpn_blocked: 'A VPN/proxy was detected on this connection — surfing paused. Disable it and press Start again.',
  };
  await saveState({ lastError: error, lastMessage: messages[error] || `Error: ${error}` });

  if (error === 'invalid_link' || error === 'vpn_blocked') {
    await stopSurfing();
    return;
  }
  const delay = RETRYABLE_DELAYS_MS[error] ?? RETRYABLE_DELAYS_MS.default;
  await api.alarms.create(RETRY_ALARM, { when: Date.now() + delay });
}

function scheduleHeartbeat(hit) {
  heartbeatTimer = setInterval(() => doHeartbeat(hit), HEARTBEAT_MS);
}

async function doHeartbeat(hit) {
  const state = await loadState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return; // stale timer from a previous hit
  // Confirm the managed tab is still genuinely on the surfed site before
  // pinging — a heartbeat for a tab the visitor navigated away from (or
  // that never loaded) shouldn't count, mirroring surf.js's own
  // document.visibilityState check (the closest equivalent this
  // tab-driven model has — see README for the honest limits of this).
  if (state.surfTabId) {
    try {
      const tab = await api.tabs.get(state.surfTabId);
      if (!tab || !tab.url || !tab.url.startsWith(new URL(hit.site.url).origin)) {
        return;
      }
    } catch (e) {
      return;
    }
  }
  CortalyApi.heartbeat(hit.hit_id, hit.token).catch(() => {});
}

function scheduleConfirm(hit, startedAt) {
  const remainingMs = Math.max(0, hit.required_seconds * 1000 - (Date.now() - startedAt));
  confirmTimer = setTimeout(() => performConfirm(hit), remainingMs);
  // Safety net for if the service worker gets suspended and this setTimeout
  // never fires (see README) — a one-off alarm a little after the deadline
  // that confirms if the hit is still pending by then. Harmless no-op if
  // performConfirm() already ran normally.
  const safetyDelayMs = Math.max(remainingMs + 10000, 30000);
  api.alarms.create(CONFIRM_SAFETY_ALARM, { when: Date.now() + safetyDelayMs });
}

async function performConfirm(hit) {
  const state = await loadState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return; // already handled
  clearTimers();
  await api.alarms.clear(CONFIRM_SAFETY_ALARM);

  const res = await CortalyApi.confirm(hit.hit_id, hit.token);
  if (res.ok) {
    const earned = parseFloat(hit.credits_per_hit) || 0;
    await saveState({
      totalEarned: state.totalEarned + earned,
      totalVisited: state.totalVisited + 1,
      lastMessage: `Confirmed — +${hit.credits_per_hit} credits this hit.`,
    });
    if (res.jackpot_offered) {
      await offerJackpot(hit, res);
      return; // offerJackpot() continues the loop itself once resolved
    }
  } else {
    await saveState({ lastMessage: 'That hit could not be confirmed — moving on.' });
  }

  await settleAfterConfirm(hit);
}

// Shared by performConfirm() and offerJackpot()'s timeout: advances to the
// next hit when running, or — if paused/stopped by the time we get here —
// clears the now-stale confirmed hit so togglePause()'s own resume check
// (`!state.hit`) can pick the loop back up later. Deliberately NOT done
// unconditionally right after confirm(): that would null state.hit even on
// the common, non-paused path, making the still-live overlay blank out to
// its placeholder for the ~700ms gap until the next hit loads, for no
// reason (loadNext() is about to overwrite it anyway). Checking fresh state
// here — right before the decision — closes the stall for every timing
// window (paused before confirm, or paused during the gap after it),
// without that flicker.
async function settleAfterConfirm(hit) {
  const refreshed = await loadState();
  // If a different hit is already active by now, this call is a stale
  // straggler (e.g. offerJackpot()'s fallback setTimeout still firing after
  // the user already collected early via the popup and the loop moved on
  // via collectJackpotOffer()'s own loadNext()) — nothing for it to settle.
  // Without this guard it would schedule an extra loadNext() against a hit
  // it has no relation to, at best wasted (server-side IP gate rejects it
  // as ip_busy while the real hit is still active) and at worst
  // interrupting whatever's actually running once that hit ends.
  if (refreshed.hit && refreshed.hit.hit_id !== hit.hit_id) return;
  if (refreshed.running && !refreshed.paused) {
    setTimeout(loadNext, 700);
  } else if (refreshed.hit && refreshed.hit.hit_id === hit.hit_id) {
    await saveState({ hit: null, startedAt: null });
  }
}

api.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === RETRY_ALARM) {
    await loadNext();
  } else if (alarm.name === CONFIRM_SAFETY_ALARM) {
    const state = await loadState();
    if (state.hit) {
      await performConfirm(state.hit);
    }
  } else if (alarm.name === CLICK_CONFIRM_SAFETY_ALARM) {
    // Same MV3-suspend safety net as CONFIRM_SAFETY_ALARM above, for the
    // click flow.
    const clickState = await loadClickState();
    if (clickState.hit) {
      await performClickConfirm(clickState.hit);
    }
  }
});

// ------------------------------------------------------------- env-check

async function runEnvCheck(hit) {
  const tabId = (await loadState()).surfTabId;
  if (!tabId) return;
  // Give the page a moment to actually load before probing it.
  await new Promise((resolve) => setTimeout(resolve, 2000));
  let results;
  try {
    const injection = await api.scripting.executeScript({
      target: { tabId },
      func: checkAdblockInPage,
    });
    results = injection && injection[0] && injection[0].result;
  } catch (e) {
    return; // page may not have finished loading, or is a restricted URL (chrome://, etc.) — skip, not fatal
  }
  if (!results) return;
  const state = await loadState();
  if (!state.hit || state.hit.hit_id !== hit.hit_id) return;
  CortalyApi.envCheck(hit.hit_id, hit.token, results.blocked, results.speedKbps).catch(() => {});
  if (results.blocked) {
    await saveState({ lastMessage: 'Ad blocker detected on this site — that hit will not verify.' });
  }
}

// Runs INSIDE the surfed page via chrome.scripting.executeScript — same
// bait-element technique as surf.js's own checkAdblock(), just invoked
// from the extension instead of a page-embedded script.
function checkAdblockInPage() {
  return new Promise((resolve) => {
    const bait = document.createElement('div');
    bait.className = 'ads ad ad-banner adsbox banner-ad textads adsystem';
    bait.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:2px;height:2px;';
    document.body.appendChild(bait);
    setTimeout(() => {
      const style = getComputedStyle(bait);
      const blocked = !bait.offsetParent || bait.offsetHeight === 0 || style.display === 'none' || style.visibility === 'hidden';
      bait.remove();
      resolve({ blocked, speedKbps: null });
    }, 150);
  });
}

// ---------------------------------------------------------------- jackpot

async function offerJackpot(hit, confirmResult) {
  const notifId = `jackpot-${hit.hit_id}`;
  api.notifications.create(notifId, {
    type: 'basic',
    iconUrl: 'icons/icon128.png',
    title: '🎰 Jackpot!',
    message: `Collect within ${confirmResult.jackpot_window_seconds}s to win ${confirmResult.jackpot_amount} credits!`,
    priority: 2,
  });
  await saveState({ lastMessage: `Jackpot offered: ${confirmResult.jackpot_amount} credits — click the extension icon to collect!`, jackpotOffer: { hitId: hit.hit_id, token: hit.token, amount: confirmResult.jackpot_amount } });

  const timeoutMs = confirmResult.jackpot_window_seconds * 1000;
  setTimeout(async () => {
    const state = await loadState();
    // Only auto-continue if it's still this same, uncollected offer — the
    // popup's "Collect" button clears jackpotOffer immediately on click.
    if (state.jackpotOffer && state.jackpotOffer.hitId === hit.hit_id) {
      await saveState({ jackpotOffer: null });
    }
    // Same paused/stopped stall risk as performConfirm() (see
    // settleAfterConfirm's own comment) — if the user paused during the
    // jackpot window, loadNext() must not fire yet, but the now-stale
    // confirmed hit still needs clearing so resuming later isn't stuck.
    await settleAfterConfirm(hit);
  }, timeoutMs + 500);
}

async function collectJackpotOffer() {
  const state = await loadState();
  if (!state.jackpotOffer) return;
  const { hitId, token } = state.jackpotOffer;
  const res = await CortalyApi.collectJackpot(hitId, token);
  await saveState({
    jackpotOffer: null,
    totalEarned: state.totalEarned + (res.jackpot_won || 0),
    lastMessage: res.ok ? `🎉 Won ${res.jackpot_won} credits!` : 'Too late — the jackpot moved on.',
  });
  const refreshed = await loadState();
  if (refreshed.running && !refreshed.paused) {
    await loadNext();
  }
}

// -------------------------------------------------------------- messages

api.runtime.onMessage.addListener((message, sender, sendResponse) => {
  (async () => {
    switch (message.type) {
      case 'START':
        await startSurfing(message.username);
        sendResponse({ ok: true });
        break;
      case 'AUTO_START': {
        // Sent by auto-detect.js the instant a cortaly.com/surf/username
        // page loads — takes over THAT tab (rather than opening a new one)
        // by seeding surfTabId before startSurfing()'s own loadNext() runs,
        // so ensureSurfTab()'s existing "reuse the known tab" branch picks
        // it up and navigates it straight to the first real site. A run
        // already in progress is left alone — a page load shouldn't yank an
        // active session over to a different tab/username underneath it.
        const current = await loadState();
        if (current.running) {
          sendResponse({ ok: true, alreadyRunning: true });
          break;
        }
        const tabId = sender.tab && sender.tab.id;
        if (tabId) await saveState({ surfTabId: tabId });
        await startSurfing(message.username);
        sendResponse({ ok: true });
        break;
      }
      case 'STOP':
        await stopSurfing();
        sendResponse({ ok: true });
        break;
      case 'TOGGLE_PAUSE':
        await togglePause();
        sendResponse({ ok: true });
        break;
      case 'SKIP': {
        const state = await loadState();
        if (state.hit) {
          clearTimers();
          await api.alarms.clear(CONFIRM_SAFETY_ALARM);
          CortalyApi.skip(state.hit.hit_id, state.hit.token).catch(() => {});
          await saveState({ hit: null, startedAt: null, lastMessage: 'Skipped.' });
          if (state.running && !state.paused) await loadNext();
        }
        sendResponse({ ok: true });
        break;
      }
      case 'COLLECT_JACKPOT':
        await collectJackpotOffer();
        sendResponse({ ok: true });
        break;
      case 'GET_STATE':
        sendResponse({ ok: true, state: await loadState() });
        break;
      case 'AUTO_START_CLICK': {
        // Sent by auto-detect.js the instant a PTC bidding-mode
        // campaign's click-view page loads — seeds clickTabId to THIS tab
        // first (same reasoning as AUTO_START above) so ensureClickTab()
        // reuses it instead of opening a second one. Unlike AUTO_START,
        // there's no "already running" pre-check: ClickService::
        // startClick() has its own server-side one-pending-click-per-user
        // gate and returns already_clicking (with the real pending hit) if
        // one exists — handleClickStartError() already resumes from that,
        // so re-requesting here is always safe.
        const tabId = sender.tab && sender.tab.id;
        if (tabId) await saveClickState({ clickTabId: tabId });
        await startClickCampaign(message.campaignId);
        sendResponse({ ok: true });
        break;
      }
      case 'GET_CLICK_STATE':
        sendResponse({ ok: true, state: await loadClickState() });
        break;
      case 'CLICK_SKIP': {
        // Sent by content-script-click.js's own Skip button — same shape as
        // the surf SKIP handler above, but one-shot: no loadNext()-style
        // continuation afterward, a skipped click campaign just ends.
        const clickState = await loadClickState();
        if (clickState.hit) {
          clearClickTimers();
          await api.alarms.clear(CLICK_CONFIRM_SAFETY_ALARM);
          CortalyApi.clickSkip(clickState.hit.hit_id, clickState.hit.token).catch(() => {});
          await saveClickState({ hit: null, startedAt: null, lastMessage: 'Skipped.' });
        }
        sendResponse({ ok: true });
        break;
      }
      default:
        sendResponse({ ok: false, error: 'unknown_message' });
    }
  })();
  return true; // keep the message channel open for the async sendResponse above
});

// On a cold start (service worker just woke up), reschedule timers from
// persisted state instead of assuming a fresh run — this is what makes an
// in-progress dwell survive the worker being suspended and restarted mid-hit.
(async () => {
  const state = await loadState();
  if (state.running && state.hit && state.startedAt) {
    scheduleHeartbeat(state.hit);
    scheduleConfirm(state.hit, state.startedAt);
  }
  const clickState = await loadClickState();
  if (clickState.hit && clickState.startedAt) {
    scheduleClickHeartbeat(clickState.hit);
    scheduleClickConfirm(clickState.hit, clickState.startedAt);
  }
})();
