// Classifier shared between the service worker and the popup.
//
// Every intercepted item (network request, fetch/xhr response, ws/sse frame,
// beacon, postMessage) is run through `classifyMessage` to produce a verdict:
//
//   verdict   : "meaningful" | "noise" | "unknown"
//   category  : short tag describing why (e.g. "game-api", "analytics")
//   reasons   : [ "host bugsnag.com", "gameKeys=3", ... ]
//   score     : signed integer (positive => game-meaningful, negative => noise)
//   confidence: "high" | "low"
//
// "meaningful" means the payload looks like in-game data (real-time game
// state, table/hand/seat/player events, lobby/auth that affects play).
// "noise" means it's analytics, error reporting, ads, fonts, or other CDN
// chrome that has nothing to do with the game. "unknown" means we couldn't
// confidently place it on either side.

(function (root) {
  // -------------------------------------------------------- noise patterns

  // Hosts that are almost always third-party telemetry / ads / fonts / CDN.
  // Matched as substrings against the request host.
  const NOISE_HOST_PATTERNS = [
    /bugsnag\.com$/i,
    /amplitude\.com$/i,
    /\.amplitude\./i,
    /sentry\.io$/i,
    /(^|\.)google-analytics\.com$/i,
    /(^|\.)googletagmanager\.com$/i,
    /(^|\.)googletagservices\.com$/i,
    /(^|\.)doubleclick\.net$/i,
    /(^|\.)googlesyndication\.com$/i,
    /(^|\.)adservice\.google\./i,
    /(^|\.)facebook\.com$/i,
    /(^|\.)facebook\.net$/i,
    /(^|\.)hotjar\.com$/i,
    /(^|\.)mixpanel\.com$/i,
    /(^|\.)segment\.(io|com)$/i,
    /(^|\.)datadoghq\.com$/i,
    /(^|\.)newrelic\.com$/i,
    /(^|\.)nr-data\.net$/i,
    /(^|\.)optimizely\.com$/i,
    /(^|\.)launchdarkly\.com$/i,
    /(^|\.)intercom\.io$/i,
    /(^|\.)intercomcdn\.com$/i,
    /(^|\.)cloudflareinsights\.com$/i,
    /(^|\.)gstatic\.com$/i,
    /(^|\.)googleapis\.com$/i,
    /(^|\.)googleadservices\.com$/i,
    /(^|\.)tiktok\.com$/i,
    /(^|\.)snapchat\.com$/i,
    /(^|\.)taboola\.com$/i,
    /(^|\.)outbrain\.com$/i
  ];

  // Path fragments that scream "this is telemetry" even on first-party hosts.
  const NOISE_PATH_PATTERNS = [
    /\/analytics(\b|\/)/i,
    /\/track(\b|\/)/i,
    /\/collect(\b|\/)/i,
    /\/beacon(\b|\/)/i,
    /\/metric/i,
    /\/telemetry/i,
    /\/csp[-_]?report/i,
    /\/error[-_]?report/i,
    /\/notify\b/i,
    /\/pixel\b/i,
    /\/heartbeat\b/i,
    /\/ping\b/i,
    /\/log(s)?\b/i,
    /\/event(s)?\b/i,
    /\/2\/httpapi/i,           // amplitude
    /\/v1\/sessions/i,         // session-replay vendors
    /\/_next\/static\//i,
    /\/_next\/data\//i,        // mostly route props, not gameplay
    /\/static\//i,
    /\/assets\//i,
    /\/cdn-cgi\//i
  ];

  // Request types that are static page chrome, not gameplay.
  const NOISE_REQUEST_TYPES = new Set([
    "stylesheet", "image", "font", "media", "script",
    "imageset", "object", "ping", "csp_report", "speculative"
  ]);

  const STATIC_EXT = /\.(?:js|mjs|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf|eot|mp3|mp4|webm|map|wasm)(\?|#|$)/i;

  // -------------------------------------------------------- game patterns

  // Path fragments that strongly suggest in-game traffic. Tuned for the
  // Replay Poker product at /replaypoker/ but generic enough for the rest of
  // casino.org's game endpoints.
  const GAME_PATH_PATTERNS = [
    /\/replaypoker\/?(api|ws|sse|socket|live|game|hand|table|lobby|tournament|seat|chat)/i,
    /\/api\/(games?|tables?|hands?|players?|seats?|lobby|tournament|chat|action|deal|sit|leave|stack|chip)/i,
    /\/games?\/[^/]+\/(action|state|deal|hand|seat|player|chips|bet|fold|call|raise|check)/i,
    /\/(socket\.io|ws|sse|realtime|live|stream)\b/i,
    /\/auth\/(login|logout|session|refresh|token)/i,
    /\/lobby\b/i,
    /\/tables?\b/i,
    /\/hands?\b/i,
    /\/poker\b/i,
    /\/tournament(s)?\b/i,
    /\/chat\b/i,
    /\/(fold|call|raise|check|bet|all-?in|deal|sit|leave|join|stand)\b/i
  ];

  // JSON keys that suggest a payload is describing real game state.
  // Tracked as a Set for fast lookup; we count how many appear in the body.
  const GAME_KEY_HINTS = new Set([
    // Cards / hand
    "cards", "holeCards", "hole_cards", "communityCards", "community_cards",
    "board", "flop", "turn", "river", "street", "round",
    // Identifiers
    "handId", "hand_id", "tableId", "table_id", "gameId", "game_id",
    "tournamentId", "tournament_id", "seatId", "seat_id",
    // Players / seats
    "seat", "seats", "player", "players", "dealer", "buttonSeat", "button_seat",
    "currentSeat", "current_seat", "smallBlind", "small_blind",
    "bigBlind", "big_blind", "blinds",
    // Money
    "pot", "pots", "sidePot", "side_pots", "chips", "stack", "bet", "wager",
    "rake", "ante", "buyIn", "buy_in",
    // Actions
    "action", "actions", "fold", "call", "raise", "check", "allIn", "all_in",
    "lastAction", "last_action", "turnTimer", "turn_timer",
    "showdown", "winners", "winner"
  ]);

  // Socket.IO event names commonly used by poker clients.
  const GAME_EVENT_PATTERNS = [
    /^(hand|table|seat|player|chat|chip|deal|action|tournament|lobby)/i,
    /(Started|Ended|Updated|Joined|Left|Changed|Dealt|Folded|Called|Raised|Checked|Won)$/
  ];

  // ---------------------------------------------- session-chatter rules
  //
  // Even when a frame ends up on a "game" host/path, large chunks of the
  // Replay Poker WebSocket stream are bookkeeping: timer ticks, presence
  // heartbeats, UI hint text, the player's own intent previews, options
  // toggles that get echoed back to them, etc. Those don't actually move
  // the game forward, and they dwarf the real action frames if we keep
  // everything. See DATA_FORMAT.md §4 for the full rubric.

  // `updates[*].action` values that, on their own, carry no new game info.
  const WS_NOISE_UPDATE_ACTIONS = new Set([
    "tick",
    "extendedConnections",
    "dealerMessage",
    "resetTable",
    "intent"
  ]);

  // `input.action` values the local client sends that are not gameplay.
  const WS_NOISE_INPUT_ACTIONS = new Set([
    "setIntent",
    "clearIntent",
    "setSeatOption",
    "ping"
  ]);

  // Low-signal first-party API paths. Listed *before* the broad
  // GAME_PATH_PATTERNS check so they win — otherwise `/replaypoker/api/...`
  // would always come back as game-api.
  const SESSION_CHATTER_PATH_PATTERNS = [
    // Iterable push-notification plumbing.
    /\/replaypoker\/api\/iterable(Proxy|Token)\b/i,
    // Friends list polled on a timer; observed empty in every sample.
    /\/replaypoker\/api\/v2\/friends(\b|\/|\?|$)/i,
    // UI preference (hidden avatars).
    /\/replaypoker\/api\/v2\/hidden_avatars(\b|\/|\?|$)/i,
    // Server-rendered React tree dumps (1-2 MB each). content.js scrapes
    // the visible table list out of the DOM, so we don't need this blob.
    /\/replaypoker\/lobby\/(rings|sngs|mtts|missions)(\b|\/|\?|$)/i
  ];

  // ----------------------------------------------------------- helpers

  function getHost(url) {
    try { return new URL(url).host.toLowerCase(); } catch { return ""; }
  }
  function getPath(url) {
    try { return new URL(url).pathname; } catch { return ""; }
  }
  function looksLikeJsonString(s) {
    if (typeof s !== "string") return false;
    const t = s.trim();
    return (t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"));
  }

  function safeParseJson(s) {
    try { return JSON.parse(s); } catch { return null; }
  }

  // Try harder to extract a JSON payload from a frame that might be wrapped
  // in a non-JSON envelope (engine.io's `42{...}` / `42[...]`, Phoenix's
  // `[null,"1","topic","event",{...}]`, Centrifuge `{push:{channel,data}}`,
  // or just a JSON value embedded in something else).
  // Returns either:
  //   { kind: "json", value }      — fully parsed JSON value
  //   { kind: "socketio", event, payload }  — socket.io style frame
  //   { kind: "text", text }       — couldn't extract anything structured
  //   null                          — nothing usable
  function extractStructured(s) {
    if (typeof s !== "string") return null;
    const t = s.trim();
    if (!t) return null;

    // 1) Plain JSON object / array — fast path.
    if (looksLikeJsonString(t)) {
      const v = safeParseJson(t);
      if (v != null) return { kind: "json", value: v };
    }

    // 2) socket.io / engine.io frame: digits, optional namespace, then JSON.
    //    Examples: "42[\"event\",{...}]", "42/poker,[\"event\",{...}]",
    //              "42{\"a\":1}" (rare custom variant), "3" (heartbeat).
    const sioArr = t.match(/^\d{1,3}(?:\/[^,]*,)?(\[[\s\S]*\])\s*$/);
    if (sioArr) {
      const arr = safeParseJson(sioArr[1]);
      if (Array.isArray(arr) && arr.length >= 1) {
        return { kind: "socketio", event: String(arr[0] || ""), payload: arr.length > 1 ? arr[1] : null };
      }
    }
    const sioObj = t.match(/^\d{1,3}(?:\/[^,]*,)?(\{[\s\S]*\})\s*$/);
    if (sioObj) {
      const obj = safeParseJson(sioObj[1]);
      if (obj != null) return { kind: "json", value: obj };
    }

    // 3) Embedded JSON anywhere in the string — find the first '{' or '[' and
    //    greedily match to the last '}' or ']'. Cheap heuristic, but catches
    //    things like 'data: {"foo":1}' (SSE-style) or 'msg=[...]'.
    const firstObj = t.indexOf("{");
    const firstArr = t.indexOf("[");
    let first = -1;
    if (firstObj < 0) first = firstArr;
    else if (firstArr < 0) first = firstObj;
    else first = Math.min(firstObj, firstArr);
    if (first >= 0) {
      const opener = t[first];
      const closer = opener === "{" ? "}" : "]";
      const last = t.lastIndexOf(closer);
      if (last > first) {
        const candidate = t.slice(first, last + 1);
        const v = safeParseJson(candidate);
        if (v != null) return { kind: "json", value: v };
      }
    }

    return { kind: "text", text: t };
  }

  // Walk a parsed JSON value and collect:
  //   - matched: Set of keys present anywhere in the tree that are in
  //              GAME_KEY_HINTS (drives the "meaningful" score)
  //   - all:     Set of every key seen (depth-limited) so the popup can
  //              render the actual field names the user wants to inspect
  //   - topLevel: keys at the outermost object/array (good signal of shape)
  //
  // Bounded depth + node count so a huge nested response can't lock us up.
  function inspectKeys(value, depth = 0, budget = { n: 2000 }, out = null) {
    if (!out) out = { matched: new Set(), all: new Set(), topLevel: [] };
    if (budget.n <= 0 || depth > 4 || value == null) return out;
    budget.n--;
    if (Array.isArray(value)) {
      // Look at the first few elements so we don't blow the budget on long
      // lists of seats/players/cards but still see their inner shape.
      const cap = Math.min(value.length, 8);
      if (depth === 0 && cap > 0 && value[0] && typeof value[0] === "object") {
        // For top-level arrays we treat the first element's keys as topLevel
        // so things like [{handId, seat, ...}, ...] still show field names.
        for (const k of Object.keys(value[0])) out.topLevel.push(k);
      }
      for (let i = 0; i < cap; i++) inspectKeys(value[i], depth + 1, budget, out);
    } else if (typeof value === "object") {
      const keys = Object.keys(value);
      if (depth === 0) for (const k of keys) out.topLevel.push(k);
      for (const k of keys) {
        out.all.add(k);
        if (GAME_KEY_HINTS.has(k)) out.matched.add(k);
        inspectKeys(value[k], depth + 1, budget, out);
      }
    }
    return out;
  }

  // Backwards-compatible shim used in a couple of spots below.
  function countGameKeys(value) {
    return inspectKeys(value).matched.size;
  }

  // engine.io / socket.io frames look like "42[\"event\",{...}]" or
  // "42/ns,[\"event\",{...}]". Returns { event, payload } or null.
  function parseSocketIoFrame(s) {
    if (typeof s !== "string") return null;
    const m = s.match(/^\d{1,3}(?:\/[^,]*,)?(\[.*\])\s*$/s);
    if (!m) return null;
    const arr = safeParseJson(m[1]);
    if (!Array.isArray(arr) || arr.length === 0) return null;
    return { event: String(arr[0] || ""), payload: arr.length > 1 ? arr[1] : null };
  }

  // Phoenix Channels v2 frames are five-element arrays:
  //   [ joinRef, ref, topic, event, payload ]
  // Returns { joinRef, ref, topic, event, payload } or null.
  function parsePhoenixWsFrame(s) {
    if (typeof s !== "string") return null;
    const t = s.trim();
    if (!t.startsWith("[") || !t.endsWith("]")) return null;
    const arr = safeParseJson(t);
    if (!Array.isArray(arr) || arr.length < 4) return null;
    const [joinRef, ref, topic, event, payload] = arr;
    if (typeof topic !== "string" || typeof event !== "string") return null;
    return { joinRef, ref, topic, event, payload: payload == null ? null : payload };
  }

  // True if a single entry inside `output.updates[]` carries no new gameplay
  // info on its own. Used by the frame-level inspector below.
  function wsUpdateIsChatter(update) {
    if (!update || typeof update !== "object") return false;
    const action = update.action;
    if (!action) return false;
    if (WS_NOISE_UPDATE_ACTIONS.has(action)) return true;
    // `seat` shows up in two flavours: a real state change (carries a
    // `sequence` number, no `routeToUser`) and an option-toggle echo (always
    // `routeToUser: true`). Only the echo is chatter.
    if (action === "seat" && update.routeToUser === true) return true;
    return false;
  }

  // Walk a parsed Phoenix frame and decide whether the whole frame is just
  // session chatter (no usable gameplay signal). Returns null when the frame
  // is meaningful, or { reason } when it should be downgraded.
  //
  // The rule from DATA_FORMAT.md §4:
  //   - `state` and `broadcast` are always meaningful.
  //   - `input` is meaningful unless its action is in WS_NOISE_INPUT_ACTIONS.
  //   - `updates` (client → server resume) is always chatter.
  //   - `output` is meaningful if at least one of its updates[] is
  //     non-chatter; otherwise drop.
  function classifyPhoenixFrame(frame) {
    if (!frame) return null;
    const { event, payload } = frame;

    if (event === "input") {
      const action = payload && typeof payload === "object" ? payload.action : null;
      if (action && WS_NOISE_INPUT_ACTIONS.has(action)) {
        return { reason: `ws-input-chatter:${action}` };
      }
      return null;
    }
    if (event === "updates") {
      // Client-side resume request, payload is `{ handId, sequence }`.
      return { reason: "ws-client-resume" };
    }
    if (event === "output") {
      const updates = payload && Array.isArray(payload.updates) ? payload.updates : null;
      if (!updates || updates.length === 0) {
        return { reason: "ws-empty-output" };
      }
      const anyReal = updates.some((u) => !wsUpdateIsChatter(u));
      if (!anyReal) return { reason: "ws-all-chatter-updates" };
      return null;
    }
    // state / broadcast / anything else: leave verdict to the body inspector.
    return null;
  }

  // ------------------------------------------------------ URL classifier

  // Returns { verdict, category, reason, score } or null if we have no opinion.
  function classifyByUrl(url, type) {
    const host = getHost(url);
    const path = getPath(url);
    if (!host && !path) return null;

    for (const re of NOISE_HOST_PATTERNS) {
      if (re.test(host)) return { verdict: "noise", category: "third-party", reason: `host:${host}`, score: -5 };
    }
    // Session-chatter paths must win over GAME_PATH_PATTERNS below — otherwise
    // /replaypoker/api/iterableProxy or /replaypoker/lobby/rings would always
    // come back as "game-api" purely because they sit under /replaypoker/.
    for (const re of SESSION_CHATTER_PATH_PATTERNS) {
      if (re.test(path)) return { verdict: "noise", category: "session-chatter-api", reason: `path:${path}`, score: -4 };
    }
    for (const re of NOISE_PATH_PATTERNS) {
      if (re.test(path)) return { verdict: "noise", category: "telemetry-or-static", reason: `path:${path}`, score: -4 };
    }
    if (type && NOISE_REQUEST_TYPES.has(String(type).toLowerCase()) && STATIC_EXT.test(path)) {
      return { verdict: "noise", category: "static-asset", reason: `type:${type}`, score: -3 };
    }
    if (STATIC_EXT.test(path)) {
      return { verdict: "noise", category: "static-asset", reason: `ext:${path}`, score: -3 };
    }
    for (const re of GAME_PATH_PATTERNS) {
      if (re.test(path)) return { verdict: "meaningful", category: "game-api", reason: `path:${path}`, score: 4 };
    }
    return null;
  }

  // ---------------------------------------------------- message classifier

  function classifyMessage(m) {
    const reasons = [];
    let score = 0;
    let category = "uncategorized";

    if (!m || typeof m !== "object") {
      return { verdict: "unknown", category, reasons: ["not-an-object"], score: 0, confidence: "low" };
    }

    // 1) URL-driven verdict (most signals come from here).
    const urlVerdict = m.url ? classifyByUrl(m.url, m.type) : null;
    if (urlVerdict) {
      score += urlVerdict.score;
      reasons.push(urlVerdict.reason);
      category = urlVerdict.category;
    }

    // 2) Kind-specific rules.
    if (m.kind === "beacon") {
      score -= 3;
      reasons.push("beacon");
      if (category === "uncategorized") category = "beacon";
    }
    if (m.kind === "ws" || m.kind === "sse") {
      const host = getHost(m.url || "");
      if (host && !NOISE_HOST_PATTERNS.some((re) => re.test(host))) {
        score += 2;
        reasons.push(`realtime:${m.kind}`);
        if (category === "uncategorized") category = "realtime";
      }
    }
    if (m.kind === "postmessage") {
      // Most postMessage traffic on Next.js apps is iframe chatter (ads,
      // Stripe, hCaptcha). Only meaningful if game keys are present.
      score -= 1;
      if (category === "uncategorized") category = "postmessage";
    }

    // 3) Body inspection — strongest game signal.
    const bodies = collectBodies(m);
    const inspect = { matched: new Set(), all: new Set(), topLevel: [] };
    let socketEvent = null;
    let bodyInspected = false;
    let bodyPreview = null;

    for (const body of bodies) {
      const ex = extractStructured(body);
      if (!ex) continue;
      if (ex.kind === "json") {
        inspectKeys(ex.value, 0, { n: 2000 }, inspect);
        bodyInspected = true;
      } else if (ex.kind === "socketio") {
        socketEvent = ex.event;
        if (GAME_EVENT_PATTERNS.some((re) => re.test(ex.event))) {
          score += 3;
          reasons.push(`socketio-event:${ex.event}`);
        } else {
          score += 1;
          reasons.push(`socketio:${ex.event || "?"}`);
        }
        if (ex.payload != null) {
          inspectKeys(ex.payload, 0, { n: 2000 }, inspect);
          bodyInspected = true;
        }
      } else if (ex.kind === "text") {
        // Keep the first non-empty text preview so the popup can show what
        // the unparseable frame actually contained.
        if (!bodyPreview && ex.text) bodyPreview = ex.text;
      }
    }

    const matchedGameKeys = Array.from(inspect.matched);
    // Deduplicate topLevel while preserving order, drop any that are already
    // in matchedGameKeys so the popup doesn't show them twice.
    const topLevelSeen = new Set();
    const topLevelKeys = [];
    for (const k of inspect.topLevel) {
      if (!topLevelSeen.has(k)) {
        topLevelSeen.add(k);
        topLevelKeys.push(k);
      }
    }
    const allKeys = Array.from(inspect.all);
    const gameKeys = matchedGameKeys.length;

    if (gameKeys > 0) {
      score += Math.min(gameKeys, 6);
      reasons.push(`gameKeys=[${matchedGameKeys.join(",")}]`);
      if (category === "uncategorized" || category === "realtime" || category === "postmessage") {
        category = "game-payload";
      }
    } else if (bodyInspected && topLevelKeys.length > 0) {
      // No game match but we did see a shape — record it so the popup can
      // show the field names without us having to re-parse.
      reasons.push(`fields=[${topLevelKeys.slice(0, 6).join(",")}${topLevelKeys.length > 6 ? ",..." : ""}]`);
    }

    // 3b) Phoenix-frame inspection. Even when a WS frame matched a bunch of
    // game keys (`action`, `seatId`, ...), a frame whose updates are all
    // chatter (tick / extendedConnections / dealerMessage / resetTable /
    // routeToUser-only seat echoes / intent), or an `input` whose action is
    // setIntent/clearIntent/setSeatOption/ping, has no usable gameplay
    // signal. Force-downgrade those to noise so they don't crowd out real
    // hands in the 2000-message cap.
    let phoenixOverride = null;
    if (m.kind === "ws") {
      for (const body of bodies) {
        if (typeof body !== "string") continue;
        const frame = parsePhoenixWsFrame(body);
        if (!frame) continue;
        phoenixOverride = classifyPhoenixFrame(frame);
        if (phoenixOverride) {
          reasons.push(`phx:${frame.event}:${phoenixOverride.reason}`);
          category = "session-chatter";
        }
        break;
      }
    }

    // 4) Final verdict from score, unless a category-level override fired.
    let verdict;
    if (phoenixOverride) verdict = "noise";
    else if (score >= 3) verdict = "meaningful";
    else if (score <= -2) verdict = "noise";
    else verdict = "unknown";

    const confidence = phoenixOverride ? "high" : (Math.abs(score) >= 4 ? "high" : "low");

    // If body inspection produced nothing structured but we did see text,
    // surface a short preview so the popup can render it in place of chips.
    let preview = null;
    if (bodyPreview) {
      const trimmed = bodyPreview.replace(/\s+/g, " ").trim();
      preview = trimmed.length > 160 ? trimmed.slice(0, 160) + "…" : trimmed;
    }

    return {
      verdict,
      category,
      reasons,
      score,
      confidence,
      gameKeys,
      matchedGameKeys,
      topLevelKeys,
      allKeys,
      socketEvent,
      preview
    };
  }

  // For chrome.webRequest entries (no bodies, just metadata).
  function classifyNetwork(n) {
    const u = classifyByUrl(n.url || "", n.type);
    if (u) {
      return {
        verdict: u.verdict,
        category: u.category,
        reasons: [u.reason],
        score: u.score,
        confidence: Math.abs(u.score) >= 4 ? "high" : "low"
      };
    }
    return {
      verdict: "unknown",
      category: "uncategorized",
      reasons: ["no-rule"],
      score: 0,
      confidence: "low"
    };
  }

  function collectBodies(m) {
    const out = [];
    const push = (b) => {
      if (b && typeof b === "object" && typeof b.data === "string") out.push(b.data);
      else if (typeof b === "string") out.push(b);
    };
    push(m.requestBody);
    push(m.responseBody);
    push(m.data);
    return out;
  }

  // One-line summary suitable for console.log.
  function formatLogLine(m, v) {
    const url = m.url || "";
    const where = url.length > 90 ? url.slice(0, 90) + "..." : url;
    const tag = v.verdict.toUpperCase();
    const cat = v.category || "?";
    const score = v.score >= 0 ? `+${v.score}` : `${v.score}`;
    const kind = m.kind || m.type || "?";
    const op = m.method || m.phase || "";
    const evt = v.socketEvent ? ` evt=${v.socketEvent}` : "";
    const matched = v.matchedGameKeys && v.matchedGameKeys.length
      ? ` match{${v.matchedGameKeys.join(",")}}`
      : "";
    const fields = (!matched && v.topLevelKeys && v.topLevelKeys.length)
      ? ` fields{${v.topLevelKeys.slice(0, 6).join(",")}${v.topLevelKeys.length > 6 ? ",..." : ""}}`
      : "";
    return `[CASINO][${tag}/${cat} ${score}] ${kind} ${op} ${where}${evt}${matched}${fields} :: ${v.reasons.join(", ") || "no-signal"}`;
  }

  const api = {
    classifyMessage,
    classifyNetwork,
    formatLogLine,
    inspectKeys,
    // Exposed for tests / debugging:
    _internals: {
      NOISE_HOST_PATTERNS,
      NOISE_PATH_PATTERNS,
      GAME_PATH_PATTERNS,
      GAME_KEY_HINTS,
      countGameKeys,
      parseSocketIoFrame
    }
  };

  root.CasinoClassify = api;
})(typeof self !== "undefined" ? self : (typeof window !== "undefined" ? window : globalThis));
