// Canonical serialization for the captured-hands pipeline.
//
// Two consumers, ONE shape:
//
//   - Network "flush"       sends a FlushRequest (gzipped JSON) over HTTPS
//                           to FLUSH_ENDPOINT. The body is a list of
//                           HandEnvelope objects.
//   - Local "export"        wraps the same HandEnvelope list in an
//                           ExportContainer, gzips it, base64s the bytes,
//                           and offers it as a `.casinodump` download.
//
// Keeping both flows on a single envelope shape means a future server
// can ingest both `POST /flush` payloads and uploaded `.casinodump`
// files through the same code path.
//
// Pure module — no DOM access, safe to import from a service worker
// AND a page context. Hangs `globalThis.CasinoSerialize`.

(function (root) {
  // Schema + format identifiers. Bump SCHEMA_VERSION any time the on-the-
  // wire shape of HandEnvelope / ExportContainer / FlushRequest changes
  // in an incompatible way.
  const SCHEMA_VERSION = 1;
  const EXPORT_FORMAT = "casino-export";
  const FLUSH_FORMAT = "casino-flush";

  // The fixed (display-only, non-configurable) destination for auto-
  // flushed hand envelopes. The user has explicitly asked that this be
  // visible but not editable from the UI. Production website is live
  // at sinostatistica.net (TLS terminated by Cloudflare → origin
  // nginx); the canonical hostname is `www.sinostatistica.net`, but
  // `manifest.json#host_permissions` also lists the apex so a manual
  // edit to either form keeps the SW's fetch authorised.
  const FLUSH_ENDPOINT = "https://www.sinostatistica.net/api/flush";

  // Phoenix server actions that bracket a complete hand. We slice the
  // session frames between (and including) the matching pair and ship
  // those — nothing else from the session is needed to replay the hand.
  const HAND_BRACKET_OPEN = "startHand";
  const HAND_BRACKET_CLOSE = "finishHand";

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

  // Lightweight UTF-8 encoder/decoder. TextEncoder/TextDecoder are
  // available in both service workers and page contexts.
  const _enc = new TextEncoder();
  const _dec = new TextDecoder();

  function jsonBytes(value) {
    return _enc.encode(JSON.stringify(value));
  }

  // Gzip a Uint8Array using the platform CompressionStream. Returns a
  // Promise<Uint8Array>. Throws if CompressionStream isn't available
  // (very old Chromium); we prefer to surface that loudly rather than
  // silently ship uncompressed bytes.
  async function gzipBytes(bytes) {
    if (typeof CompressionStream === "undefined") {
      throw new Error("CompressionStream not supported in this runtime");
    }
    const cs = new CompressionStream("gzip");
    const stream = new Blob([bytes]).stream().pipeThrough(cs);
    const buf = await new Response(stream).arrayBuffer();
    return new Uint8Array(buf);
  }

  async function gunzipBytes(bytes) {
    if (typeof DecompressionStream === "undefined") {
      throw new Error("DecompressionStream not supported in this runtime");
    }
    const ds = new DecompressionStream("gzip");
    const stream = new Blob([bytes]).stream().pipeThrough(ds);
    const buf = await new Response(stream).arrayBuffer();
    return new Uint8Array(buf);
  }

  // SHA-256 hex digest of a string, via SubtleCrypto. Used to fingerprint
  // a hand's serialized bytes so the flush layer can detect drift between
  // attempts and the server can deduplicate idempotently.
  async function sha256Hex(str) {
    const buf = await crypto.subtle.digest("SHA-256", _enc.encode(str));
    const arr = new Uint8Array(buf);
    let hex = "";
    for (let i = 0; i < arr.length; i++) {
      hex += arr[i].toString(16).padStart(2, "0");
    }
    return hex;
  }

  // Base64 encode a Uint8Array. Chunked to avoid the call-stack blow-up
  // on large arrays when using `String.fromCharCode(...arr)`.
  function bytesToBase64(bytes) {
    const CHUNK = 0x8000;
    let s = "";
    for (let i = 0; i < bytes.length; i += CHUNK) {
      s += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
    }
    return btoa(s);
  }

  function base64ToBytes(b64) {
    const s = atob(b64);
    const out = new Uint8Array(s.length);
    for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
    return out;
  }

  // ------------------------------------------------- hand envelope

  // Stable primary key for a hand record. Composed from the table id
  // plus a per-table hand identifier — we prefer the server-assigned
  // handId (always present in fresh captures) and fall back to the
  // hand's `firstTs` when it isn't, so the key remains deterministic
  // even for legacy captures with no server id.
  function handKey(tableId, handId, firstTs) {
    const safeTable = String(tableId || "unknown");
    if (handId != null && String(handId).length > 0 && !String(handId).startsWith("hand-")) {
      return `${safeTable}::${handId}`;
    }
    // Stable fallback: `firstTs` is the timestamp of the hand's opening
    // frame, which never changes once observed.
    return `${safeTable}::ts-${firstTs || 0}`;
  }

  // Slice the contiguous run from the matching `startHand` through (and
  // including) the matching `finishHand` out of a parent `frames` array.
  // We match by hand id when one is available on the frame; otherwise
  // we fall back to ordinal — in practice the poker server always stamps
  // `handId` so this fallback is just a safety net.
  //
  // NEW model: each hand from `tableize.js` already carries its own
  // `frames` slice; we just return that. This function is kept for the
  // legacy `(session, hand, handIndex)` callsites and as a fallback.
  function sliceHandFrames(session, hand, handIndex) {
    if (hand && Array.isArray(hand.frames) && hand.frames.length > 0) {
      return hand.frames;
    }
    if (!session || !Array.isArray(session.frames)) return [];

    const targetId = hand && hand.handId ? String(hand.handId) : null;
    let inHand = false;
    let seenStarts = 0;
    const out = [];

    for (const f of session.frames) {
      if (!f || f.event !== "output") {
        // Non-output frames inside the bracketed window get carried
        // along too — they may be input echoes, phx_join, etc., and a
        // server doing roundtrip replay benefits from having them.
        if (inHand) out.push(f);
        continue;
      }
      const updates = f.payload && Array.isArray(f.payload.updates)
        ? f.payload.updates
        : null;

      let frameMatchesHand = !targetId; // ordinal fallback: any frame counts
      if (updates && targetId) {
        for (const u of updates) {
          const id = (u && (u.handId ?? u.hand_id)) ?? null;
          if (id != null && String(id) === targetId) {
            frameMatchesHand = true;
            break;
          }
        }
        if (!frameMatchesHand) {
          const fHandId = f.payload && (f.payload.handId ?? f.payload.hand_id);
          if (fHandId != null && String(fHandId) === targetId) {
            frameMatchesHand = true;
          }
        }
      }

      // Open the slice on the first matching `startHand`. If there's no
      // server hand id (rare), open on the Nth `startHand` matching
      // `handIndex`.
      if (!inHand && updates) {
        for (const u of updates) {
          if (u && u.action === HAND_BRACKET_OPEN) {
            if (targetId) {
              if (frameMatchesHand) inHand = true;
            } else {
              if (seenStarts === handIndex) inHand = true;
              seenStarts++;
            }
            break;
          }
        }
      }

      if (inHand) out.push(f);

      // Close the slice on the first `finishHand` for our target id.
      if (inHand && updates) {
        for (const u of updates) {
          if (u && u.action === HAND_BRACKET_CLOSE) {
            const id = (u.handId ?? u.hand_id) ?? null;
            const closes = !targetId || (id != null && String(id) === targetId)
                          || frameMatchesHand;
            if (closes) {
              return out;
            }
          }
        }
      }
    }

    return out;
  }

  // Build the per-hand envelope, including the `contentHash` over the
  // canonical body (the bits that uniquely identify the bytes). The
  // hash is computed AFTER frame extraction, so a re-flushed envelope
  // always produces the same hash for the same bytes.
  //
  // Signature: `buildHandEnvelope(table, hand)`. The `table` carries
  // identity (tableId, names, pageUrl); the `hand` carries lifecycle
  // metadata and the frame slice. Legacy callers passing
  // `(session, hand, handIndex)` are still accepted for back-compat.
  async function buildHandEnvelope(tableLike, hand, _handIndex) {
    const frames = sliceHandFrames(tableLike, hand, _handIndex);
    const tableId = (tableLike && tableLike.tableId) || (hand && hand.tableId) || null;
    const handIdRaw = hand && hand.handId ? String(hand.handId) : null;
    const handId = handIdRaw && !handIdRaw.startsWith("hand-") ? handIdRaw : null;
    const firstTs = hand && hand.firstTs != null ? hand.firstTs : (frames[0]?.ts || 0);
    const lastTs = hand && hand.lastTs != null ? hand.lastTs : (frames[frames.length - 1]?.ts || 0);

    // Prefer the multi-name list from a `table` (tableize.js); fall back
    // to a single legacy `title` if we were given a session-shaped object.
    let names = null;
    if (tableLike && Array.isArray(tableLike.names) && tableLike.names.length) {
      names = tableLike.names.slice();
    } else if (tableLike && tableLike.title) {
      names = [String(tableLike.title)];
    }

    const body = {
      v: SCHEMA_VERSION,
      handKey: handKey(tableId, handIdRaw, firstTs),
      handId,
      tableId,
      tableNames: names,
      // Option B (2026-05-27): the bucket extracted these from the
      // WS `state` snapshot. We pass them through on every envelope
      // because the per-hand frame slice doesn't contain the state
      // event itself (it arrives before `startHand`), so the server
      // can't re-extract them on its own. Any field may be null
      // when no state snapshot was captured for this table.
      gameVariant: (tableLike && tableLike.gameVariant) || null,
      stakesTier:  (tableLike && tableLike.stakesTier)  || null,
      smallBlind:  (tableLike && tableLike.smallBlind)  || null,
      bigBlind:    (tableLike && tableLike.bigBlind)    || null,
      pageUrl: tableLike && tableLike.pageUrl || null,
      pageTitle: tableLike && tableLike.pageTitle || null,
      handIndex: hand && hand.handIndex != null ? hand.handIndex : 0,
      firstTs,
      lastTs,
      actionCount: hand && hand.actionCount != null ? hand.actionCount : null,
      frameCount: frames.length,
      // Lifecycle is the structural classification from tableize.js:
      // "finished" / "in-progress" / "incomplete". Carried into the
      // envelope so the server can defence-in-depth and refuse to
      // store anything that isn't `finished` (per chat 2026-05-21).
      lifecycle: (hand && hand.lifecycle) || "incomplete",
      frames
    };
    const canonical = JSON.stringify({
      handKey: body.handKey,
      handId: body.handId,
      frames: body.frames
    });
    body.contentHash = await sha256Hex(canonical);
    return body;
  }

  // ------------------------------------------- containers + codecs

  // Convert a `CasinoUsers.buildIndex(messages)` Map into a plain
  // `{ [userId]: username }` object suitable for embedding in a
  // container. The index is built once per flush/export and shared
  // across all envelopes, so the server can resolve each hand's
  // perspective seat to a casino-side display name without re-running
  // user-resolution itself.
  //
  // Pure helper, no DOM. Returns {} if `userIndex` is absent or
  // empty so the container key is always present.
  function userIndexToObject(userIndex) {
    const out = {};
    if (!userIndex) return out;
    if (typeof userIndex.forEach === "function") {
      userIndex.forEach((entry, id) => {
        if (entry && entry.username) out[String(id)] = entry.username;
      });
    } else if (typeof userIndex === "object") {
      for (const k in userIndex) {
        const v = userIndex[k];
        if (v && v.username) out[String(k)] = v.username;
      }
    }
    return out;
  }

  // `adminToken` (when truthy) is an opaque shared-secret string that
  // both the autoflush body and the .casinodump export carry. Server
  // compares it (timing-safe) against HARDCODED_ADMIN_FLUSH_TOKEN to
  // decide whether to accept generic-perspective rounds. Lives only
  // in the gzipped body so it doesn't surface in any access log.
  // Callers that don't have an admin token simply pass null/undefined.
  function buildExportContainer(envelopes, userIndex, adminToken) {
    const out = {
      v: SCHEMA_VERSION,
      format: EXPORT_FORMAT,
      exportedTs: Date.now(),
      handCount: envelopes.length,
      // userIndex: { [userId]: username }. Populated by callers from
      // `CasinoUsers.buildIndex(messages)`; consumed server-side to
      // resolve `seatId -> userId -> casino-side player display name`.
      userIndex: userIndexToObject(userIndex),
      hands: envelopes
    };
    if (typeof adminToken === "string" && adminToken.length > 0) {
      out.adminToken = adminToken;
    }
    return out;
  }

  function buildFlushRequest(envelopes, userIndex, adminToken) {
    const out = {
      v: SCHEMA_VERSION,
      format: FLUSH_FORMAT,
      flushedTs: Date.now(),
      handCount: envelopes.length,
      userIndex: userIndexToObject(userIndex),
      hands: envelopes
    };
    if (typeof adminToken === "string" && adminToken.length > 0) {
      out.adminToken = adminToken;
    }
    return out;
  }

  // Encode a container to a gzipped Uint8Array suitable for a network
  // request body or a download payload.
  async function encodeContainer(container) {
    return gzipBytes(jsonBytes(container));
  }

  // Encode for the `.casinodump` download — gzipped bytes wrapped in
  // base64 so the file is plain ASCII (works with email attachments,
  // copy-paste, etc.). The gzip step still happens; only the outer
  // wrapper is text.
  async function encodeContainerBase64(container) {
    const gz = await encodeContainer(container);
    return bytesToBase64(gz);
  }

  // Decode a base64-wrapped export back into the original JS object.
  // Round-trip helper — useful for tests AND for a future "import"
  // button that re-hydrates a `.casinodump` file.
  async function decodeContainerBase64(b64) {
    const gz = base64ToBytes(b64);
    const json = await gunzipBytes(gz);
    return JSON.parse(_dec.decode(json));
  }

  // ------------------------------------------------ public surface

  root.CasinoSerialize = {
    SCHEMA_VERSION,
    EXPORT_FORMAT,
    FLUSH_FORMAT,
    FLUSH_ENDPOINT,
    HAND_BRACKET_OPEN,
    HAND_BRACKET_CLOSE,

    handKey,
    sliceHandFrames,
    buildHandEnvelope,
    buildExportContainer,
    buildFlushRequest,
    encodeContainer,
    encodeContainerBase64,
    decodeContainerBase64,

    // Low-level codecs, exposed for tests + flush.js.
    gzipBytes,
    gunzipBytes,
    sha256Hex,
    bytesToBase64,
    base64ToBytes
  };
})(typeof self !== "undefined" ? self : (typeof window !== "undefined" ? window : globalThis));
