// Hand flush + export pipeline.
//
// Each hand carries TWO orthogonal state axes:
//
//   roundState (lifecycle, derived from the captured frames)
//     finished     — saw both `startHand` and `finishHand`. Flushable.
//     in-progress  — saw `startHand` but no `finishHand` yet.
//     incomplete   — never saw `startHand` (recording started mid-hand);
//                    the leading frames are missing forever.
//
//   syncState  (server-side persistence; only meaningful when
//                roundState === "finished" — empty/null otherwise)
//     pending       — finished hand we haven't successfully flushed.
//     flushing      — in-flight; another caller should not pick it up.
//     flushed       — server acknowledged a 2xx for this contentHash.
//     needs-reflush — exhausted retry attempts in a single flush call;
//                     the user can manually retry.
//
// Only `finished` hands can be flushed; the other two roundStates are
// never sent over the wire and never enter the export bundle.
//
// On every refresh we walk the live `messages[]` capture, run it
// through `Tableize`, and reconcile the derived hand list against
// the persisted index:
//
//   - new finished hand        -> add { round: finished, sync: pending }
//   - new in-progress hand     -> add { round: in-progress, sync: null }
//   - new incomplete hand      -> add { round: incomplete, sync: null }
//   - existing flushed hand    -> if its bytes drifted (the server
//                                 stream re-sent something), flip
//                                 syncState to `needs-reflush`.
//   - existing in-progress     -> if it just finished, promote
//                                 roundState to finished, sync to pending.
//
// Pure(ish) module — talks to chrome.storage.local + fetch, but has no
// DOM dependencies, so it runs identically inside the service worker
// AND inside `history.html`. Hangs `globalThis.CasinoFlush`.

(function (root) {
  const Serialize = root.CasinoSerialize;
  const Tableize = root.CasinoTableize;

  if (!Serialize) {
    throw new Error("flush.js: CasinoSerialize not loaded");
  }
  if (!Tableize) {
    throw new Error("flush.js: CasinoTableize not loaded");
  }

  const HANDS_KEY = "hands";
  const MESSAGES_KEY = "messages";
  const SETTINGS_KEY = "settings";
  const FLUSH_LOCK_KEY = "flushLock";

  // Per-call retry budget (NOT a per-hand lifetime budget). After this
  // many failures inside a single `flushNow` call, the hand is parked
  // as `needs-reflush` and the loop moves on.
  const MAX_ATTEMPTS_PER_CALL = 3;

  // Bundle up to this many envelopes per HTTP request. Keeps each POST
  // body well under common 10 MB / 25 MB server caps even at heavy
  // capture density.
  const BATCH_SIZE = 20;

  // 90s is a generous upper bound on the time a single flush attempt
  // can hold the lock. Longer than that and we assume a previous tab
  // crashed mid-flush; another caller can steal the lock.
  const LOCK_STALE_MS = 90 * 1000;

  // ----------------------------------------------- storage helpers

  async function loadHandsIndex() {
    const data = await chrome.storage.local.get([HANDS_KEY]);
    const arr = data[HANDS_KEY];
    return Array.isArray(arr) ? arr : [];
  }

  async function saveHandsIndex(arr) {
    await chrome.storage.local.set({ [HANDS_KEY]: arr });
  }

  async function loadMessages() {
    const data = await chrome.storage.local.get([MESSAGES_KEY]);
    const arr = data[MESSAGES_KEY];
    return Array.isArray(arr) ? arr : [];
  }

  async function loadSettings() {
    const data = await chrome.storage.local.get([SETTINGS_KEY]);
    return (data && data[SETTINGS_KEY]) || {};
  }

  // Lock primitive — coarse but reliable. Reads-then-writes the
  // `flushLock` key with a stale-detection fallback. Two simultaneous
  // callers (SW alarm + page button) can collide here; the loser sees
  // an existing lock and bails. Stale locks (browser crashed mid-call)
  // get reclaimed after LOCK_STALE_MS.
  async function acquireFlushLock(ownerLabel) {
    const data = await chrome.storage.local.get([FLUSH_LOCK_KEY]);
    const cur = data[FLUSH_LOCK_KEY];
    if (cur && Number.isFinite(cur.ts) && Date.now() - cur.ts < LOCK_STALE_MS) {
      return false;
    }
    const lock = { ts: Date.now(), owner: String(ownerLabel || "anon") };
    await chrome.storage.local.set({ [FLUSH_LOCK_KEY]: lock });
    return true;
  }

  async function releaseFlushLock() {
    await chrome.storage.local.remove([FLUSH_LOCK_KEY]);
  }

  // ----------------------------------------- hand index reconciliation

  // Build a map of every hand the current `messages[]` capture implies,
  // keyed by Serialize.handKey(tableId, handId, firstTs). Each value
  // carries the parent `table` and the `hand` record (which already has
  // a precomputed `lifecycle` from tableize.js).
  function deriveHandsFromMessages(messages) {
    const tables = Tableize.tableize(messages);
    const out = new Map();
    for (const table of tables) {
      const hands = table.hands || [];
      for (const hand of hands) {
        const key = Serialize.handKey(table.tableId, hand.handId, hand.firstTs);
        out.set(key, { table, hand, handIndex: hand.handIndex });
      }
    }
    return out;
  }

  // Lifecycle classification: just trust what tableize.js already
  // computed on the hand. The structural rule (finished iff finishHand
  // present; incomplete iff superseded or never had startHand) lives
  // there, so the flush layer stays stupid about gameplay.
  function classifyRoundState(hand) {
    return (hand && hand.lifecycle) || "incomplete";
  }

  // Compute a fresh contentHash for a hand. Used both when inserting
  // new records and when checking whether an already-flushed record
  // has drifted.
  async function computeContentHash(table, hand) {
    const env = await Serialize.buildHandEnvelope(table, hand);
    return env.contentHash;
  }

  // One-time migration of pre-table records. Old records were keyed
  // `${sessionId}::${handIndex}` and stored a `sessionId` + a single
  // `tableName`. The new shape drops `sessionId` and uses the table-
  // scoped `Serialize.handKey`. We migrate by tableId+firstTs lookup;
  // any legacy record we can't re-key is left as-is so its flushed
  // status survives even if the reconciliation can't find the live
  // hand anymore.
  function migrateLegacyRecord(rec, derived) {
    if (!rec) return rec;
    if (rec.handKey && rec.tableId && !rec.sessionId) return rec; // already new shape
    // Try to find a derived hand whose tableId+firstTs matches.
    for (const [key, d] of derived) {
      if (d.table.tableId !== rec.tableId) continue;
      if (Math.abs((d.hand.firstTs || 0) - (rec.firstTs || 0)) > 5000) continue;
      const migrated = { ...rec };
      delete migrated.sessionId;
      migrated.handKey = key;
      migrated.handId = d.hand.handId && !String(d.hand.handId).startsWith("hand-")
        ? String(d.hand.handId)
        : (rec.handId || null);
      migrated.tableNames = (d.table.names && d.table.names.slice()) || (rec.tableName ? [rec.tableName] : null);
      delete migrated.tableName;
      return migrated;
    }
    // Unrecoverable — strip session bits but otherwise preserve. The
    // record will be carried as-is (flushed records survive even when
    // their bytes can't be re-derived).
    if (rec.sessionId || rec.tableName) {
      const migrated = { ...rec };
      delete migrated.sessionId;
      if (rec.tableName) {
        migrated.tableNames = [rec.tableName];
        delete migrated.tableName;
      }
      return migrated;
    }
    return rec;
  }

  // Reconcile the derived hand list with the persisted index.
  // Returns the new index. Persistence is the caller's responsibility.
  async function reconcileIndex(persisted, derived) {
    // Pass 1: rewrite legacy records to the new key shape so the
    // lookups below behave consistently.
    const migrated = persisted.map((r) => migrateLegacyRecord(r, derived));

    const out = [];
    const seen = new Set();

    for (const [key, { table, hand }] of derived) {
      seen.add(key);
      const prior = migrated.find((r) => r.handKey === key);
      const desiredRound = classifyRoundState(hand);

      if (!prior) {
        // First time seeing this hand. Only stamp a contentHash and a
        // syncState for `finished`; the other two roundStates aren't
        // flushable and have no sync metadata to track.
        const record = {
          handKey: key,
          tableId: table.tableId,
          tableNames: table.names ? table.names.slice() : null,
          handId: hand.handId && !String(hand.handId).startsWith("hand-")
            ? String(hand.handId)
            : null,
          firstTs: hand.firstTs || 0,
          lastTs: hand.lastTs || 0,
          roundState: desiredRound,
          syncState: desiredRound === "finished" ? "pending" : null,
          flushAttempts: 0,
          lastAttemptTs: 0,
          lastError: null,
          flushedTs: 0,
          contentHash: null
        };
        if (desiredRound === "finished") {
          record.contentHash = await computeContentHash(table, hand);
        }
        out.push(record);
        continue;
      }

      // Existing record. Update lifecycle-adjacent fields + the
      // table-name list (so renames flow through immediately).
      const next = { ...prior };
      next.tableId = table.tableId;
      next.tableNames = table.names ? table.names.slice() : next.tableNames;
      if (hand.firstTs != null) next.firstTs = hand.firstTs;
      if (hand.lastTs != null) next.lastTs = hand.lastTs;
      if (!next.handId && hand.handId && !String(hand.handId).startsWith("hand-")) {
        next.handId = String(hand.handId);
      }

      const priorRound = next.roundState;
      next.roundState = desiredRound;

      if (desiredRound !== "finished") {
        // Lifecycle regression (e.g. previously-finished hand re-derives
        // without its finishHand because the bytes were truncated, or
        // a new startHand has supplanted it). Drop sync metadata —
        // unfinished hands don't have anything to sync against.
        next.syncState = null;
        next.contentHash = null;
        next.flushAttempts = 0;
        next.lastError = null;
        out.push(next);
        continue;
      }

      // From here on: roundState === "finished". Manage syncState.
      if (priorRound !== "finished") {
        // Hand just transitioned into finished (e.g. its closing frame
        // arrived in a later capture). Initialise its sync row.
        next.syncState = "pending";
        next.contentHash = await computeContentHash(table, hand);
        next.flushAttempts = 0;
        next.lastError = null;
      } else if (next.syncState === "flushed") {
        // Already flushed — only need to react to byte drift.
        const fresh = await computeContentHash(table, hand);
        if (fresh !== next.contentHash) {
          next.syncState = "needs-reflush";
          next.contentHash = fresh;
          next.flushAttempts = 0;
          next.lastError = "Bytes changed since last flush";
        }
      } else if (next.syncState === "flushing") {
        next.syncState = "pending";
      } else if (next.syncState === "pending" || next.syncState === "needs-reflush") {
        if (!next.contentHash) {
          next.contentHash = await computeContentHash(table, hand);
        }
      } else {
        next.syncState = "pending";
        if (!next.contentHash) {
          next.contentHash = await computeContentHash(table, hand);
        }
      }

      out.push(next);
    }

    // Drop persisted records whose underlying hand is no longer
    // derivable from messages[] (e.g. the user cleared local storage).
    // We keep already-flushed records as an audit trail.
    for (const prior of migrated) {
      if (seen.has(prior.handKey)) continue;
      if (prior.syncState === "flushed") out.push(prior);
    }

    out.sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
    return out;
  }

  // Public: refresh the on-disk hand index. Cheap to call repeatedly;
  // the heavy work is only the per-hand SHA-256, which itself is cheap
  // (envelopes are tens of KB). Returns the new index. Skips the write
  // when the result is byte-identical to what's already there — avoids
  // a feedback loop where the SW and the page each react to the
  // other's storage change.
  async function refreshIndex() {
    const [persisted, messages] = await Promise.all([
      loadHandsIndex(), loadMessages()
    ]);
    const derived = deriveHandsFromMessages(messages);
    const next = await reconcileIndex(persisted, derived);
    const before = JSON.stringify(persisted);
    const after = JSON.stringify(next);
    if (before !== after) {
      await saveHandsIndex(next);
    }
    return next;
  }

  // Aggregate counters for the Settings status pill. Reports both
  // lifecycle and sync axes; the UI mostly cares about `pending`,
  // `flushed`, `needsReflush`, plus a hint about how many hands are
  // stuck in non-flushable lifecycle states.
  function computeStats(index) {
    const stats = {
      total: index.length,
      finished: 0,
      inProgress: 0,
      incomplete: 0,
      flushed: 0,
      pending: 0,
      flushing: 0,
      needsReflush: 0
    };
    for (const r of index) {
      switch (r.roundState) {
        case "finished":    stats.finished++; break;
        case "in-progress": stats.inProgress++; break;
        case "incomplete":  stats.incomplete++; break;
      }
      switch (r.syncState) {
        case "flushed":       stats.flushed++; break;
        case "pending":       stats.pending++; break;
        case "flushing":      stats.flushing++; break;
        case "needs-reflush": stats.needsReflush++; break;
      }
    }
    return stats;
  }

  // ------------------------------------------------- network flush

  // Build envelopes for the records we're about to send. Re-derives the
  // session + hand from messages[] each time so the bytes are always
  // current — important when an autoflush fires shortly after a
  // `finishHand` because the messages array may still be fresh.
  //
  // Also returns a user index built from the same messages so the
  // flush container can carry `userId -> username` mappings server-side
  // (needed to render the per-player tree on statisticasino).
  async function buildEnvelopesForRecords(records) {
    const messages = await loadMessages();
    const derived = deriveHandsFromMessages(messages);
    const envs = [];
    for (const r of records) {
      const d = derived.get(r.handKey);
      if (!d) continue;
      const env = await Serialize.buildHandEnvelope(d.table, d.hand);
      envs.push(env);
    }
    const userIndex = (typeof CasinoUsers !== "undefined" && CasinoUsers.buildIndex)
      ? CasinoUsers.buildIndex(messages)
      : null;
    return { envs, userIndex };
  }

  // POST a batch of envelopes to FLUSH_ENDPOINT. Returns:
  //   { ok, status, summary?, error? }
  // - ok=true   means the HTTP layer succeeded AND we managed to parse
  //             the response body. `summary` carries the server's
  //             ingest counters so the caller can tell apart
  //             "200, all hands accepted" from "200, all hands
  //             dropped server-side as rejectedGeneric/Incomplete/etc."
  // - ok=false  is reserved for HTTP-level failures (network, 4xx,
  //             5xx). Network / DNS errors fold into status=0.
  //
  // `adminToken` (when non-empty) is folded into the gzipped body —
  // never into a header — so it can't surface in nginx / Cloudflare
  // access logs. Schema/format identifiers stay inside the encrypted
  // body too; we deliberately don't advertise them as `X-Casino-*`
  // headers (no need to fingerprint the request to anyone watching
  // the wire post-TLS).
  async function postBatch(envelopes, userIndex, adminToken) {
    const req = Serialize.buildFlushRequest(envelopes, userIndex, adminToken);
    let body;
    try {
      body = await Serialize.encodeContainer(req);
    } catch (e) {
      return { ok: false, status: 0, error: `encode: ${e && e.message || e}` };
    }
    try {
      const res = await fetch(Serialize.FLUSH_ENDPOINT, {
        method: "POST",
        headers: {
          "Content-Type": "application/octet-stream",
          "Content-Encoding": "gzip"
        },
        body
      });
      if (res.ok) {
        // Server's response is { summary: { received, accepted,
        // duplicates, rejectedGeneric, rejectedIncomplete, errors[] }}
        // — see statisticasino/src/lib/server/ingest.js. We need the
        // summary to tell `200 OK, your hand is in the DB` apart from
        // `200 OK, but I rejected every envelope you sent`.
        let summary = null;
        try {
          const json = await res.json();
          if (json && json.summary && typeof json.summary === "object") {
            summary = json.summary;
          }
        } catch (_) {
          // Older servers may not return JSON. ok stays true; the
          // caller falls back to "trust the HTTP status" semantics.
        }
        return { ok: true, status: res.status, summary };
      }
      let snippet = "";
      try { snippet = (await res.text()).slice(0, 240); } catch (_) {}
      return { ok: false, status: res.status, error: `HTTP ${res.status} ${snippet}`.trim() };
    } catch (e) {
      return { ok: false, status: 0, error: `fetch: ${e && e.message || e}` };
    }
  }

  // Decide whether the server actually persisted a given envelope by
  // looking at the response summary. Returns one of:
  //   "accepted"   counted in `accepted` (incl. acceptedGeneric) — DB row exists
  //   "duplicate"  counted in `duplicates` — server already had it; treat as success
  //   "rejected"   server explicitly rejected (rejectedGeneric / rejectedIncomplete / per-record error)
  //   "unknown"    no summary available (older server / missing JSON);
  //                fall back to the HTTP status convention
  //
  // The server returns aggregate counters, NOT a per-handKey list, so
  // when received > 0 we can only say "all of this batch were accepted"
  // OR "some of this batch were rejected". Mixed batches downgrade
  // every record to "rejected" out of caution — better to re-flush a
  // duplicate (which the server idempotently absorbs) than to silently
  // declare success for a hand that didn't land.
  function classifyBatch(summary) {
    if (!summary || typeof summary !== "object") return "unknown";
    const received = Number(summary.received) || 0;
    const accepted = Number(summary.accepted) || 0;
    const duplicates = Number(summary.duplicates) || 0;
    const errArr = Array.isArray(summary.errors) ? summary.errors : [];
    if (received === 0) return "rejected"; // empty batch never reached the loop
    if (errArr.length > 0) return "rejected";
    // Strict equality: every received hand must be accepted-or-deduped.
    if (accepted + duplicates !== received) return "rejected";
    return accepted > 0 ? "accepted" : "duplicate";
  }

  // Public: drain pending + needs-reflush hands to the endpoint.
  // `opts.scope` controls which states are eligible:
  //   - "auto"           pending only (autoflush — never retries failures)
  //   - "all"  (default) pending + needs-reflush (manual "Flush now")
  //   - "needs-reflush"  needs-reflush only (manual retry)
  // `opts.autoDelete` (boolean): after a successful POST for a record,
  //   strip its source frames from `messages[]` so the local capture
  //   buffer doesn't grow without bound. The audit row in `hands[]`
  //   survives (with `syncState === "flushed"`) so the user keeps a
  //   record that the upload happened.
  // Returns a summary { attempted, succeeded, failed, locked,
  //   deletedHands } so the UI can show a toast or update the status
  //   pill.
  async function flushNow(opts) {
    const scope = (opts && opts.scope) || "all";
    const ownerLabel = (opts && opts.owner) || "unknown";
    const autoDelete = !!(opts && opts.autoDelete);
    // Optional shared-secret string. Empty/missing = anonymous flush
    // (server treats as non-admin → generic rounds rejected). When
    // present, server compares timing-safe against
    // HARDCODED_ADMIN_FLUSH_TOKEN and grants admin upload privileges
    // for this batch only.
    const adminToken = (opts && typeof opts.adminToken === "string")
      ? opts.adminToken : "";
    const summary = {
      attempted: 0, succeeded: 0, failed: 0, locked: false, deletedHands: 0
    };

    const got = await acquireFlushLock(ownerLabel);
    if (!got) { summary.locked = true; return summary; }

    try {
      let index = await refreshIndex();

      const eligibleSync = scope === "auto"
        ? new Set(["pending"])
        : scope === "needs-reflush"
          ? new Set(["needs-reflush"])
          : new Set(["pending", "needs-reflush"]);

      // Only `finished` rounds are flushable. `in-progress` and
      // `incomplete` are skipped unconditionally — even a manual
      // "Flush now" can't ship them, by design.
      const queue = index
        .filter((r) => r.roundState === "finished" && eligibleSync.has(r.syncState))
        .sort((a, b) => (a.firstTs || 0) - (b.firstTs || 0));
      if (queue.length === 0) return summary;

      // Mark everything we're about to send as `flushing` so a
      // concurrent caller (or a UI render) sees the in-flight state.
      // Pre-write so a crash mid-call doesn't leave records "stuck"
      // in their old state — the next reconcile demotes `flushing`
      // back to `pending`.
      for (const r of queue) r.syncState = "flushing";
      await saveHandsIndex(index);

      // Walk in batches, posting each batch and updating state as
      // soon as we get a result back. Keep going past per-batch
      // failures; the loser hands get parked.
      let batchFailures = 0;
      // Records whose POST succeeded — used by the autoDelete pass
      // below. We collect them here rather than re-walking `index`
      // afterwards so we don't conflate hands flushed in earlier
      // calls with hands flushed in this call.
      const flushedThisCall = [];
      for (let i = 0; i < queue.length; i += BATCH_SIZE) {
        const slice = queue.slice(i, i + BATCH_SIZE);
        summary.attempted += slice.length;

        const { envs, userIndex } = await buildEnvelopesForRecords(slice);
        // Re-bind each record to its envelope's contentHash so what we
        // PERSIST as the source-of-truth hash matches what we ACTUALLY
        // sent — protects against mid-flight drift.
        const envByKey = new Map(envs.map((e) => [e.handKey, e]));
        const now = Date.now();

        // Defensive: if we couldn't build ANY envelope for this slice,
        // the source frames have been evicted from messages[] and we
        // have nothing to send. Demote the slice back to pending and
        // skip the network call rather than POSTing an empty body the
        // server would (rightly) 400 and we'd then mis-classify.
        if (envs.length === 0) {
          for (const r of slice) {
            r.lastAttemptTs = now;
            r.lastError = "source frames missing; cannot build envelope";
            r.syncState = "pending";
            summary.failed++;
          }
          await saveHandsIndex(index);
          continue;
        }

        const result = await postBatch(envs, userIndex, adminToken);
        const verdict = result.ok ? classifyBatch(result.summary) : null;
        // Surface the server's verdict for the SW console — invaluable
        // when chasing "shown synced but not actually" reports. We log
        // anything that isn't a clean `accepted`.
        if (result.ok && verdict !== "accepted") {
          console.warn(
            "[CASINO] flush server response:",
            `verdict=${verdict || "unknown"}`,
            "summary=", result.summary || null
          );
        }
        for (const r of slice) {
          const env = envByKey.get(r.handKey);
          r.lastAttemptTs = now;

          if (!env) {
            // This record's envelope was missing from the POST (other
            // records in the slice got built; this one didn't). Hold
            // it as pending so the next reconcile either rebuilds or
            // garbage-collects it.
            r.lastError = "source frames missing; cannot build envelope";
            r.syncState = "pending";
            summary.failed++;
            continue;
          }

          // Bind the persisted contentHash to what we ACTUALLY sent.
          r.contentHash = env.contentHash;

          if (!result.ok) {
            r.flushAttempts = (r.flushAttempts || 0) + 1;
            r.lastError = result.error || `HTTP ${result.status}`;
            r.syncState = (r.flushAttempts >= MAX_ATTEMPTS_PER_CALL)
              ? "needs-reflush"
              : "pending";
            summary.failed++;
            batchFailures++;
            continue;
          }

          // HTTP succeeded. Decide based on what the server's
          // summary actually says about this batch.
          if (verdict === "accepted" || verdict === "duplicate" || verdict === "unknown") {
            r.syncState = "flushed";
            r.flushedTs = now;
            r.lastError = verdict === "duplicate"
              ? "Server reports duplicate (already stored)."
              : null;
            r.flushAttempts = 0;
            summary.succeeded++;
            flushedThisCall.push(r);
          } else {
            // Server responded 200 but explicitly rejected hands in
            // this batch (rejectedGeneric / rejectedIncomplete /
            // per-record error). Don't flag flushed; surface the
            // reason so the user can act on it.
            r.flushAttempts = (r.flushAttempts || 0) + 1;
            const s = result.summary || {};
            const reason = s.rejectedGeneric > 0
              ? `server rejected as generic (${s.rejectedGeneric}/${s.received}); sign in as admin or capture from a seat with visible hole cards`
              : s.rejectedIncomplete > 0
                ? `server rejected as incomplete (${s.rejectedIncomplete}/${s.received})`
                : (Array.isArray(s.errors) && s.errors[0])
                  ? `server error: ${String(s.errors[0]).slice(0, 200)}`
                  : `server accepted=${s.accepted || 0}/${s.received || 0}`;
            r.lastError = reason;
            r.syncState = (r.flushAttempts >= MAX_ATTEMPTS_PER_CALL)
              ? "needs-reflush"
              : "pending";
            summary.failed++;
          }
        }
        // Persist progress after each batch so a partial run is durable.
        await saveHandsIndex(index);

        // Quick circuit-break: if we hit two consecutive total-batch
        // failures, the endpoint is unreachable; bail rather than
        // burning attempts on every record.
        if (batchFailures >= MAX_ATTEMPTS_PER_CALL && summary.succeeded === 0) {
          break;
        }
      }

      // Anything we marked `flushing` but never touched (because of an
      // early bail) needs to come back to `pending` so the next call
      // can pick it up.
      for (const r of index) {
        if (r.syncState === "flushing") r.syncState = "pending";
      }
      await saveHandsIndex(index);

      // Autodelete pass — strip source frames for every hand whose
      // POST succeeded in *this* call. The audit row in `index` is
      // intentionally NOT removed: reconcileIndex's tail loop carries
      // `flushed` records forward even when their underlying messages
      // are gone, so the user retains a record that the upload
      // happened.
      if (autoDelete && flushedThisCall.length > 0) {
        try {
          const dropped = await dropFramesForRecords(flushedThisCall);
          if (dropped > 0) summary.deletedHands = flushedThisCall.length;
        } catch (e) {
          // Don't fail the whole flush if the cleanup pass blows up;
          // the user keeps the (already-flushed) hand in History and
          // can hit Delete on it manually.
          console.warn("[CASINO] autoDelete cleanup failed:", e);
        }
      }
      return summary;
    } finally {
      await releaseFlushLock();
    }
  }

  // Drop every `messages[]` entry whose `srcId` belongs to one of the
  // hands referenced by `records`. Returns the number of message
  // entries removed (0 if no overlap). Re-derives the live messages
  // and tableize() output on entry — we deliberately do NOT cache
  // those across the flush call because new frames may have arrived
  // mid-flush and we don't want to drop them.
  async function dropFramesForRecords(records) {
    if (!Array.isArray(records) || records.length === 0) return 0;
    const messages = await loadMessages();
    if (!Array.isArray(messages) || messages.length === 0) return 0;
    const tables = Tableize.tableize(messages);
    const handByKey = new Map();
    for (const t of tables) {
      for (const h of t.hands || []) {
        const key = Serialize.handKey(t.tableId, h.handId, h.firstTs);
        handByKey.set(key, h);
      }
    }
    const dropIds = new Set();
    for (const r of records) {
      const h = handByKey.get(r.handKey);
      if (!h || !Array.isArray(h.frames)) continue;
      for (const f of h.frames) {
        if (f && f.srcId) dropIds.add(f.srcId);
      }
    }
    if (dropIds.size === 0) return 0;
    const trimmed = messages.filter((m) => !m.id || !dropIds.has(m.id));
    if (trimmed.length === messages.length) return 0;
    await chrome.storage.local.set({ [MESSAGES_KEY]: trimmed });
    return messages.length - trimmed.length;
  }

  // ------------------------------------------------------- export

  // Public: gather every flushable-or-already-flushed hand into a
  // single `.casinodump` download. `incomplete` records are excluded.
  // Export does NOT mutate flush state — it's a backup, not a flush.
  //
  // The `adminToken` from settings is baked into the dump if present,
  // so an admin who exports a .casinodump and re-uploads it via
  // /contribute (without signing in) still gets generic-upload
  // privileges. Operators who don't want a shared dump to grant
  // privileges should clear the token in Settings before exporting.
  async function exportAll() {
    const [persisted, messages, settings] = await Promise.all([
      loadHandsIndex(), loadMessages(), loadSettings()
    ]);
    const derived = deriveHandsFromMessages(messages);

    // Export mirrors flush eligibility: only `finished` rounds. We do
    // include already-flushed rounds — export is a backup, not a flush
    // delta.
    const eligible = persisted.filter((r) => r.roundState === "finished");
    const envelopes = [];
    for (const r of eligible) {
      const d = derived.get(r.handKey);
      if (!d) continue; // record was for a hand we no longer have bytes for
      const env = await Serialize.buildHandEnvelope(d.table, d.hand);
      envelopes.push(env);
    }

    const userIndex = (typeof CasinoUsers !== "undefined" && CasinoUsers.buildIndex)
      ? CasinoUsers.buildIndex(messages)
      : null;
    const adminToken = typeof settings.adminToken === "string" ? settings.adminToken : "";
    const container = Serialize.buildExportContainer(envelopes, userIndex, adminToken);
    const b64 = await Serialize.encodeContainerBase64(container);
    const filename = `casino-export-${new Date().toISOString().replace(/[:.]/g, "-")}.casinodump`;

    // `chrome.downloads` is the right primitive in extension contexts
    // (works from both the SW and the page). Wrap base64 as a data URL.
    const url = `data:application/octet-stream;base64,${b64}`;
    return new Promise((resolve, reject) => {
      try {
        chrome.downloads.download({ url, filename, saveAs: true }, (downloadId) => {
          if (chrome.runtime.lastError) {
            reject(new Error(chrome.runtime.lastError.message));
            return;
          }
          resolve({ downloadId, handCount: envelopes.length, filename });
        });
      } catch (e) {
        reject(e);
      }
    });
  }

  // -------------------------------------------------------- public

  root.CasinoFlush = {
    refreshIndex,
    flushNow,
    exportAll,
    computeStats,
    loadHandsIndex,
    HANDS_KEY,
    FLUSH_LOCK_KEY,
    MAX_ATTEMPTS_PER_CALL,
    BATCH_SIZE
  };
})(typeof self !== "undefined" ? self : (typeof window !== "undefined" ? window : globalThis));
