// Pull in the classifier so every intercepted item gets a verdict
// (meaningful / noise / unknown) the moment it lands, and the verdict is
// echoed to the service-worker console as a one-line log.
try {
  importScripts("classify.js");
} catch (e) {
  console.warn("[CASINO] classify.js failed to load:", e);
}

// Pull in the hand pipeline so the service worker can run autoflush on a
// timer even when the History tab is closed. Order matters: sessionize
// + serialize define globals that flush.js reads at module-load time.
try {
  // `users.js` exposes CasinoUsers.buildIndex(messages) and is needed
  // by flush.js so the flush container can carry a `userId -> username`
  // mapping for the server-side per-player tree on statisticasino.
  importScripts("tableize.js", "serialize.js", "users.js", "flush.js");
} catch (e) {
  console.warn("[CASINO] flush pipeline failed to load:", e);
}
const Flush = (typeof self !== "undefined" && self.CasinoFlush) || null;

// The toolbar action no longer opens a popup; clicking the icon opens the
// History page as a full browser tab. If a History tab is already open we
// focus it instead of stacking duplicates.
chrome.action.onClicked.addListener(async () => {
  const url = chrome.runtime.getURL("history.html");
  try {
    const existing = await chrome.tabs.query({ url });
    if (existing && existing.length > 0) {
      const t = existing[0];
      await chrome.tabs.update(t.id, { active: true });
      if (t.windowId != null) await chrome.windows.update(t.windowId, { focused: true });
      return;
    }
  } catch (_) { /* fall through to create */ }
  await chrome.tabs.create({ url });
});

const CASINO_FILTER = { urls: ["https://*.casino.org/*"] };
const NETWORK_KEY = "network";
const PAGEDATA_KEY = "pageData";
const MESSAGES_KEY = "messages";
const SETTINGS_KEY = "settings";
const MAX_NETWORK = 500;
const MAX_PAGEDATA = 200;
const MAX_MESSAGES = 2000;
const FLUSH_MS = 300;

// Capture gate. Mirrors `settings.captureEnabled` in chrome.storage.local;
// when false we still log verdicts to the console for debugging but skip the
// pending queue so nothing hits storage. Defaults to true until we hear back
// from storage on startup.
//
// Autoflush gate. Mirrors `settings.autoFlush`. When true, every
// observed `finishHand` frame triggers a network flush of pending
// hands once the storage write that persisted the frame settles
// (see `triggerAutoFlush` below). The 5-min interval was removed
// 2026-05-23 in favour of finish-driven flushing — users wanted
// rounds to leave the local buffer the moment they end, not minutes
// later.
//
// Autodelete gate. Mirrors `settings.autoDelete`. When true,
// successfully-flushed hands have their source frames stripped from
// `messages[]` so the local capture buffer doesn't grow without
// bound. Off by default — destructive (the round disappears from
// History; only the audit row in `hands[]` survives).
let captureEnabled = true;
let autoFlush = true;
let autoDelete = false;
// Mirror of settings.adminToken so the SW autoflush can include it
// without re-reading storage on every finishHand. Persisted in
// chrome.storage.local in plaintext (unavoidable; same threat model
// as the rest of settings) — never logged to console here.
let adminToken = "";
chrome.storage.local.get([SETTINGS_KEY], (data) => {
  const s = data && data[SETTINGS_KEY];
  if (s && typeof s === "object") {
    if (typeof s.captureEnabled === "boolean") captureEnabled = s.captureEnabled;
    if (typeof s.autoFlush === "boolean") autoFlush = s.autoFlush;
    if (typeof s.autoDelete === "boolean") autoDelete = s.autoDelete;
    if (typeof s.adminToken === "string") adminToken = s.adminToken;
  }
});
chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== "local") return;
  if (!changes[SETTINGS_KEY]) return;
  const next = changes[SETTINGS_KEY].newValue;
  if (next && typeof next === "object") {
    if (typeof next.captureEnabled === "boolean") captureEnabled = next.captureEnabled;
    if (typeof next.autoFlush === "boolean") autoFlush = next.autoFlush;
    if (typeof next.autoDelete === "boolean") autoDelete = next.autoDelete;
    if (typeof next.adminToken === "string") adminToken = next.adminToken;
  }
});

const classifier = (typeof self !== "undefined" && self.CasinoClassify) || null;

// Rolling totals so the popup status bar can show a live meaningful/noise
// breakdown without re-scanning the whole stored array.
const totals = {
  messages: { meaningful: 0, noise: 0 },
  network:  { meaningful: 0, noise: 0, unknown: 0 }
};

function labelAndLog(scope, entry) {
  if (!classifier) return null;
  const verdict = scope === "network"
    ? classifier.classifyNetwork(entry)
    : classifier.classifyMessage(entry);
  entry._verdict = verdict;
  try {
    const line = classifier.formatLogLine(entry, verdict);
    if (verdict.verdict === "meaningful") console.log("%c" + line, "color:#4ade80");
    else if (verdict.verdict === "noise")  console.debug("%c" + line, "color:#8a93a6");
    else                                    console.log("%c" + line, "color:#fbbf24");
  } catch (_) { /* logging is best-effort */ }
  if (totals[scope] && verdict && totals[scope][verdict.verdict] != null) {
    totals[scope][verdict.verdict]++;
  }
  return verdict;
}

// Service workers can be torn down between events; in-memory queues are
// coalesced and flushed to chrome.storage.local on a short timer so we
// don't thrash storage on bursty network or socket traffic.
const pending = { [NETWORK_KEY]: [], [MESSAGES_KEY]: [] };
const caps = { [NETWORK_KEY]: MAX_NETWORK, [MESSAGES_KEY]: MAX_MESSAGES };
let flushScheduled = false;
let counter = 0;
function nextId() { counter = (counter + 1) % 1e9; return `${Date.now().toString(36)}-${counter.toString(36)}`; }

// Counter: how many `finishHand` frames are waiting in the in-memory
// queue (or have just been persisted). When `flush()` finishes its
// storage write we hand off to `triggerAutoFlush()` once if this is
// non-zero, then reset. Lets a burst of finishes (e.g. user hops
// tables) coalesce into a single network flush attempt.
let pendingFinishCount = 0;

// Cheap sniff: does this `captured` entry's WS payload contain a
// `finishHand` action on a `table:<id>` topic? We pre-check the raw
// string with `indexOf` before spending JSON.parse on it — most
// frames don't mention finishHand at all, so the bail is O(1).
function entryHasFinishHand(entry) {
  if (!entry || entry.kind !== "ws") return false;
  const d = entry.data;
  let text = null;
  if (d && typeof d === "object" && typeof d.data === "string") text = d.data;
  else if (typeof d === "string") text = d;
  if (!text || text.indexOf("finishHand") === -1) return false;
  try {
    const arr = JSON.parse(text);
    if (!Array.isArray(arr) || arr.length < 5) return false;
    const topic = arr[2];
    if (typeof topic !== "string" || !topic.startsWith("table:")) return false;
    const payload = arr[4];
    if (!payload || !Array.isArray(payload.updates)) return false;
    return payload.updates.some((u) => u && u.action === "finishHand");
  } catch (_) {
    return false;
  }
}

function scheduleFlush() {
  if (flushScheduled) return;
  flushScheduled = true;
  setTimeout(flush, FLUSH_MS);
}

async function flush() {
  flushScheduled = false;
  const keys = Object.keys(pending).filter((k) => pending[k].length > 0);
  if (keys.length === 0) return;
  const batches = {};
  for (const k of keys) {
    batches[k] = pending[k];
    pending[k] = [];
  }
  const existing = await chrome.storage.local.get(keys);
  const writes = {};
  for (const k of keys) {
    const merged = (existing[k] || []).concat(batches[k]);
    const trimmed = merged.length > caps[k] ? merged.slice(-caps[k]) : merged;
    writes[k] = trimmed;
  }
  await chrome.storage.local.set(writes);
  // Now that the new finishHand frames are durable in storage, kick
  // a network autoflush so the round leaves the local buffer
  // immediately. Errors are swallowed inside `triggerAutoFlush`.
  if (pendingFinishCount > 0) {
    pendingFinishCount = 0;
    triggerAutoFlush("finishHand");
  }
}

// Coalesced network-flush trigger. Multiple finishHand events in
// quick succession get collapsed into one flush + at most one
// queued follow-up (so we never miss a hand that arrived mid-call,
// but we also don't spawn N concurrent flushNow attempts). flush.js
// takes its own storage-keyed lock, so concurrent overlap is safe
// either way — this is purely an efficiency layer.
let autoflushBusy = false;
let autoflushQueued = false;
async function triggerAutoFlush(reason) {
  if (!autoFlush || !Flush) return;
  if (autoflushBusy) {
    autoflushQueued = true;
    return;
  }
  autoflushBusy = true;
  try {
    // Refresh the index first so a freshly-finished hand transitions
    // from in-progress → finished (pending) before flushNow filters
    // the queue.
    await Flush.refreshIndex();
    const summary = await Flush.flushNow({
      scope: "auto",
      owner: reason,
      autoDelete,
      adminToken
    });
    if (summary && (summary.attempted > 0 || summary.locked)) {
      console.log(
        "[CASINO] autoflush:",
        `reason=${reason}`,
        `attempted=${summary.attempted}`,
        `ok=${summary.succeeded}`,
        `fail=${summary.failed}`,
        `del=${summary.deletedHands || 0}`,
        summary.locked ? "(locked, skipped)" : ""
      );
    }
  } catch (e) {
    console.warn("[CASINO] autoflush error:", e);
  } finally {
    autoflushBusy = false;
    if (autoflushQueued) {
      autoflushQueued = false;
      triggerAutoFlush("finishHand-followup");
    }
  }
}

// ---------------------------------------------------------- webRequest

chrome.webRequest.onCompleted.addListener((d) => {
  if (!captureEnabled) return;
  const entry = {
    id: `${d.requestId}-${d.timeStamp}`,
    url: d.url,
    method: d.method,
    type: d.type,
    status: d.statusCode,
    fromCache: d.fromCache,
    ip: d.ip || null,
    ts: d.timeStamp,
    ok: d.statusCode >= 200 && d.statusCode < 400
  };
  labelAndLog("network", entry);
  pending[NETWORK_KEY].push(entry);
  scheduleFlush();
}, CASINO_FILTER);

chrome.webRequest.onErrorOccurred.addListener((d) => {
  if (!captureEnabled) return;
  const entry = {
    id: `${d.requestId}-${d.timeStamp}`,
    url: d.url,
    method: d.method,
    type: d.type,
    status: 0,
    error: d.error,
    ts: d.timeStamp,
    ok: false
  };
  labelAndLog("network", entry);
  pending[NETWORK_KEY].push(entry);
  scheduleFlush();
}, CASINO_FILTER);

// ------------------------------------------------------- runtime msgs

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || typeof msg !== "object") return;

  if (msg.kind === "pageData") {
    if (!captureEnabled) {
      sendResponse({ ok: true, dropped: true, reason: "capture-disabled" });
      return false;
    }
    handlePageData(msg, sender).then(
      (r) => sendResponse({ ok: true, ...r }),
      (e) => sendResponse({ ok: false, error: String(e) })
    );
    return true;
  }

  if (msg.kind === "captured") {
    if (!captureEnabled) {
      sendResponse({ ok: true, dropped: true, reason: "capture-disabled" });
      return false;
    }
    const entry = {
      id: nextId(),
      tabId: sender?.tab?.id ?? null,
      frameId: sender?.frameId ?? null,
      pageUrl: msg.pageUrl || sender?.tab?.url || null,
      pageTitle: msg.pageTitle || null,
      receivedTs: Date.now(),
      ...(msg.entry || {})
    };
    const verdict = labelAndLog("messages", entry);
    // Only `meaningful` entries earn a slot in the 2000-message cap:
    //   - `unknown`: classifier couldn't tell what it is — no usable signal.
    //   - `noise`:   classifier *can* tell, and it's session chatter
    //                (tick / extendedConnections / setIntent / ping / etc.)
    //                or a low-signal HTTP endpoint (iterableProxy, friends,
    //                hidden_avatars, lobby RSC dumps). See DATA_FORMAT.md §4.
    // Dropped entries are still counted in `totals` so the popup can show
    // the filter rate, but they don't consume storage.
    if (verdict && (verdict.verdict === "unknown" || verdict.verdict === "noise")) {
      sendResponse({ ok: true, verdict, dropped: true });
      return false;
    }
    pending[MESSAGES_KEY].push(entry);
    // Flag finishHand on the way in so the next storage flush can
    // hand off to the network autoflush. We only check meaningful
    // entries (filtered above), so the cost per WS frame is at most
    // one `indexOf("finishHand")` against the raw payload string.
    if (entryHasFinishHand(entry)) pendingFinishCount++;
    scheduleFlush();
    sendResponse({ ok: true, verdict: entry._verdict });
    return false;
  }

  if (msg.kind === "getTotals") {
    sendResponse({ ok: true, totals });
    return false;
  }
});

async function handlePageData(msg, sender) {
  const pageUrl = sender?.tab?.url || msg.url || null;
  const tabId = sender?.tab?.id ?? null;
  const entry = {
    tabId,
    url: pageUrl,
    title: msg.title || null,
    rows: Array.isArray(msg.rows) ? msg.rows : [],
    ts: Date.now()
  };
  const { [PAGEDATA_KEY]: existing = [] } = await chrome.storage.local.get(PAGEDATA_KEY);
  // Replace any prior snapshot for the same tab+url so the popup shows the
  // current page state rather than every intermediate scrape.
  const filtered = existing.filter((e) => !(e.tabId === tabId && e.url === pageUrl));
  filtered.push(entry);
  const trimmed = filtered.length > MAX_PAGEDATA ? filtered.slice(-MAX_PAGEDATA) : filtered;
  await chrome.storage.local.set({ [PAGEDATA_KEY]: trimmed });
  return { count: entry.rows.length };
}

chrome.runtime.onInstalled.addListener(async () => {
  const cur = await chrome.storage.local.get([NETWORK_KEY, PAGEDATA_KEY, MESSAGES_KEY]);
  const writes = {};
  if (!cur[NETWORK_KEY]) writes[NETWORK_KEY] = [];
  if (!cur[PAGEDATA_KEY]) writes[PAGEDATA_KEY] = [];
  if (!cur[MESSAGES_KEY]) writes[MESSAGES_KEY] = [];
  if (Object.keys(writes).length) await chrome.storage.local.set(writes);
});

// -------------------------------------------------- hand-index reconcile
//
// A periodic chrome.alarms tick keeps the per-hand sync index in sync
// with the live messages capture, so the per-round badges in History
// stay accurate even when the History tab isn't open. The alarm does
// NOT fire autoflush — that's now event-driven on `finishHand` (see
// `triggerAutoFlush` above). The 5-min cadence here is just an
// index-only sweep so badges stay truthful if a hand transitioned
// silently (e.g. the SW was torn down between captures).
const RECONCILE_ALARM = "casino-reconcile";
chrome.alarms.create(RECONCILE_ALARM, { periodInMinutes: 5, delayInMinutes: 1 });

// -------------------------------------------------- service-worker heartbeat
//
// MV3 service workers are killed by Chrome after ~30s of idle. Webrequest
// + content-script messages wake us on real casino.org activity, but if
// a casino tab is open and quietly sitting on a long-lived WebSocket with
// no per-frame chrome.* round-trips, the SW can still tear down. A
// minimum-period chrome.alarms tick (Chrome enforces 0.5min minimum in
// production builds) is the documented MV3 keep-alive pattern: the
// alarm wake event itself is the keep-alive, the handler does nothing.
//
// This is independent of `captureEnabled` — capture is gated on the
// flag inside the webRequest/onMessage handlers, but we always want
// the worker reachable so toggling capture back on is instant.
const KEEPALIVE_ALARM = "casino-keepalive";
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === KEEPALIVE_ALARM) return;  // wake-only, no work
  if (alarm.name !== RECONCILE_ALARM) return;
  if (!Flush) return;
  try {
    await Flush.refreshIndex();
  } catch (e) {
    console.warn("[CASINO] reconcile alarm error:", e);
  }
});
