// 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 a plain
  // result object: { ok, status, error? }. Network errors / DNS
  // failures are folded into `{ ok:false, status:0, error: "..." }`.
  async function postBatch(envelopes, userIndex) {
    const req = Serialize.buildFlushRequest(envelopes, userIndex);
    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",
          "X-Casino-Format": Serialize.FLUSH_FORMAT,
          "X-Casino-Schema": String(Serialize.SCHEMA_VERSION)
        },
        body
      });
      if (res.ok) return { ok: true, status: res.status };
      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}` };
    }
  }

  // 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)
  // Returns a summary { attempted, succeeded, failed, locked } 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 summary = { attempted: 0, succeeded: 0, failed: 0, locked: false };

    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;
      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 result = await postBatch(envs, userIndex);
        const now = Date.now();
        for (const r of slice) {
          const env = envByKey.get(r.handKey);
          if (env) r.contentHash = env.contentHash;
          r.lastAttemptTs = now;
          if (result.ok) {
            r.syncState = "flushed";
            r.flushedTs = now;
            r.lastError = null;
            r.flushAttempts = 0;
            summary.succeeded++;
          } else {
            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++;
          }
        }
        // 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);
      return summary;
    } finally {
      await releaseFlushLock();
    }
  }

  // ------------------------------------------------------- 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.
  async function exportAll() {
    const [persisted, messages] = await Promise.all([
      loadHandsIndex(), loadMessages()
    ]);
    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 container = Serialize.buildExportContainer(envelopes, userIndex);
    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));
