// Top-level controller for the Game History tab.
//
// Two sections, navigated via the top-bar pills (History / Settings) and a
// hash route (`#history` / `#settings`). History reads stored `messages[]`,
// groups them by `tableId` (permanent buckets, no time-based splitting),
// and detects individual hands inside each table via tableize.js.
// Settings reads/writes `settings` in chrome.storage.local — currently
// just `captureEnabled`. Auto-flush was removed 2026-05-21; the manual
// "Flush now" button covers that flow.

const MESSAGES_KEY = "messages";
const SETTINGS_KEY = "settings";

// Sessions (time-bounded buckets of frames) were removed in favor of
// permanent per-table buckets. Every captured frame for a given
// `tableId` lives under a single dropdown forever, regardless of how
// long ago it was captured.

const DEFAULT_SETTINGS = {
  captureEnabled: true
};

const Tableize = (typeof window !== "undefined" && window.CasinoTableize) || null;
const ReplayMod = (typeof window !== "undefined" && window.CasinoReplay) || null;
const Cards = (typeof window !== "undefined" && window.CasinoCards) || null;
const Users = (typeof window !== "undefined" && window.CasinoUsers) || null;
const Serialize = (typeof window !== "undefined" && window.CasinoSerialize) || null;
const Flush = (typeof window !== "undefined" && window.CasinoFlush) || null;
const Readable = (typeof window !== "undefined" && window.CasinoReadable) || null;

const els = {
  navTabs: document.querySelectorAll(".nav-tab"),
  sections: document.querySelectorAll(".section"),
  topbarStatus: document.getElementById("topbarStatus"),

  // History
  list: document.getElementById("sessionList"),
  empty: document.getElementById("emptyState"),
  // Action bar (sticky toolbar above the table list).
  actionBar: document.getElementById("actionBar"),
  actionBarCount: document.getElementById("actionBarCount"),
  selectAllBtn: document.getElementById("selectAllBtn"),
  deleteSelectedBtn: document.getElementById("deleteSelectedBtn"),
  exportSelectedBtn: document.getElementById("exportSelectedBtn"),
  exportReadableBtn: document.getElementById("exportReadableBtn"),
  cancelSelectionBtn: document.getElementById("cancelSelectionBtn"),

  // Settings
  captureToggle: document.getElementById("captureToggle"),
  flushEndpoint: document.getElementById("flushEndpoint"),
  flushStatus: document.getElementById("flushStatus"),
  flushError: document.getElementById("flushError"),
  flushBtn: document.getElementById("flushBtn"),
  storageMessages: document.getElementById("storageMessages"),
  storageSize: document.getElementById("storageSize"),

  // Inline replay (cloned from <template id="replayTemplate">)
  replayTemplate: document.getElementById("replayTemplate")
};

const state = {
  messages: [],
  // Permanent per-table buckets (no time-based splitting). Each entry
  // is a `Table` from tableize.js with a `hands[]` precomputed.
  tables: [],
  // Set of expanded table-row ids (the table dropdowns the user has
  // clicked open).
  expanded: new Set(),
  renderTimer: null,
  settings: { ...DEFAULT_SETTINGS },
  activeSection: "history",
  userIndex: new Map(),   // built lazily from CasinoUsers.buildIndex
  // Active inline replays. Key = `${tableId}::${handIndex}`. Value =
  // { engine, round, panel, peekCache, tableId, handIndex } so we can
  // detach / re-render without re-running buildSteps.
  replays: new Map(),
  // Flush index — Map<handKey, record> mirroring chrome.storage.local.hands.
  // Populated by refreshFlushIndex() and consumed by both the round-row
  // status badge and the Sync settings card.
  flushIndex: new Map(),
  flushBusy: false,
  // Multi-select for the History delete UI. Keys are
  // `${tableId}::${handIndex}` strings (same shape as `state.replays`).
  // When .size > 0, the sticky toolbar at the top of the History page
  // renders a "Delete N rounds" button.
  selected: new Set()
};

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

function escapeHtml(s) {
  if (s == null) return "";
  return String(s)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function formatDate(ts) {
  if (!ts) return "";
  const d = new Date(ts);
  const today = new Date();
  const sameDay =
    d.getFullYear() === today.getFullYear() &&
    d.getMonth() === today.getMonth() &&
    d.getDate() === today.getDate();
  if (sameDay) {
    return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
  }
  return d.toLocaleDateString([], { month: "short", day: "numeric" });
}

function formatClock(ts) {
  if (!ts) return "";
  const d = new Date(ts);
  return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
}

function formatDuration(firstTs, lastTs) {
  if (!firstTs || !lastTs || lastTs <= firstTs) return "—";
  const ms = lastTs - firstTs;
  const totalSec = Math.round(ms / 1000);
  const hours = Math.floor(totalSec / 3600);
  const minutes = Math.floor((totalSec % 3600) / 60);
  const seconds = totalSec % 60;
  if (hours > 0) return `${hours}h ${minutes}m`;
  if (minutes > 0) return `${minutes}m ${seconds}s`;
  return `${seconds}s`;
}

// Span between two wall-clock timestamps, expressed as a human-readable
// "first → last" range. Used in the table header so the user can tell
// at a glance whether a dropdown holds a single play session or hands
// scattered across many days.
function formatDateRange(firstTs, lastTs) {
  if (!firstTs || !lastTs) return "";
  const sameDay = formatDate(firstTs) === formatDate(lastTs);
  if (sameDay) return formatDate(firstTs);
  return `${formatDate(firstTs)} → ${formatDate(lastTs)}`;
}

function formatBytes(n) {
  if (!Number.isFinite(n) || n <= 0) return "0 B";
  if (n < 1024) return `${n} B`;
  if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
  return `${(n / (1024 * 1024)).toFixed(2)} MB`;
}

// Stand-in for a logo: last 2 chars of the table id, monospace.
function tileLabel(tableId) {
  const s = String(tableId || "");
  if (s.length === 0) return "?";
  return s.slice(-2);
}

// ----------------------------------------------------------------- rendering

// Lifecycle pill — `finished` / `in progress` / `incomplete`. Always
// rendered. tableize.js precomputes `hand.lifecycle`; the persisted
// flush index has the same value (just with a small lag). Prefer the
// hand's own value because it's strictly fresher than the persisted
// snapshot.
const ROUND_LABELS = {
  "finished":    "finished",
  "in-progress": "in progress",
  "incomplete":  "incomplete"
};
function renderRoundBadge(hand, rec) {
  const round = (hand && hand.lifecycle)
    || (rec && rec.roundState)
    || "in-progress";
  const label = ROUND_LABELS[round] || round;
  return `<span class="hand-status round-${round}">${escapeHtml(label)}</span>`;
}

// Sync pill — only meaningful for `finished` rounds. Returns "" for
// in-progress / incomplete so the row only carries one badge in those
// cases.
const SYNC_LABELS = {
  "pending":       "pending",
  "flushing":      "syncing…",
  "flushed":       "synced",
  "needs-reflush": "retry"
};
function renderSyncBadge(rec) {
  if (!rec) return "";
  if (rec.roundState !== "finished") return "";
  const sync = rec.syncState;
  if (!sync) return "";
  const label = SYNC_LABELS[sync] || sync;
  const tooltip = sync === "needs-reflush" && rec.lastError
    ? ` title="${escapeHtml(rec.lastError)}"`
    : "";
  return `<span class="flush-badge flush-${sync}"${tooltip}>${escapeHtml(label)}</span>`;
}

function renderHand(hand, tableId) {
  const idx = hand.handIndex;
  const round = idx + 1;
  const time = formatClock(hand.firstTs);
  const date = formatDate(hand.firstTs);
  const meta = `${hand.actionCount} action${hand.actionCount === 1 ? "" : "s"}`;
  const rec = Serialize
    ? state.flushIndex.get(Serialize.handKey(tableId, hand.handId, hand.firstTs))
    : null;
  const roundBadge = renderRoundBadge(hand, rec);
  const syncBadge = renderSyncBadge(rec);
  const selKey = handSelectKey(tableId, idx);
  const checked = state.selected.has(selKey) ? " checked" : "";
  // No surrounding box: the row is a plain indented line with a caret
  // (rendered via CSS ::before) and the felt panel below. The
  // checkbox sits OUTSIDE the .hand-row click target so toggling it
  // doesn't open / close the inline replay.
  return `
    <li class="hand-item" data-table-id="${escapeHtml(tableId)}" data-hand-index="${idx}" aria-expanded="false">
      <div class="hand-item-row">
        <label class="tree-select" title="Select round for bulk action">
          <input type="checkbox"
                 data-sel-level="hand"
                 data-sel-key="${escapeHtml(selKey)}"
                 data-table-id="${escapeHtml(tableId)}"
                 data-hand-index="${idx}"${checked} />
        </label>
        <div class="hand-row" role="button" tabindex="0"
             aria-label="Toggle replay for round ${round}">
          <span class="hand-index">Round ${round}</span>
          <span class="hand-time">${escapeHtml(date)} ${escapeHtml(time)}</span>
          <span class="hand-meta">${escapeHtml(meta)}</span>
          ${roundBadge}
          ${syncBadge}
        </div>
      </div>
      <div class="hand-replay" hidden></div>
    </li>`;
}

function renderTableBody(table) {
  const hands = Array.isArray(table.hands) ? table.hands : [];
  if (hands.length === 0) {
    return `
      <div class="no-hands">
        No hands captured for this table yet.
      </div>`;
  }
  // Newest hand at the top. tableize.js orders hands chronologically
  // within a table; reverse here so the most recent round shows first.
  const rows = hands.slice().reverse()
    .map((h) => renderHand(h, table.tableId))
    .join("");
  return `<ol class="hand-list">${rows}</ol>`;
}

// Compose the table title from the multi-name list. The most recent
// name leads, with prior names appended after a dash. Per user spec
// 2026-05-18: "Crystal Pond - Aquarium 2" when a table was renamed.
function composeTableTitle(table) {
  const names = Array.isArray(table.names) ? table.names.filter(Boolean) : [];
  if (names.length === 0) return "Table";
  // tableize.js stores names in first-occurrence order; we want the
  // most-recent first in the header, so reverse.
  return names.slice().reverse().join(" - ");
}

function renderTable(table) {
  const tableId = table.tableId;
  const isOpen = state.expanded.has(tableId);
  const hands = Array.isArray(table.hands) ? table.hands : [];
  const handCount = hands.length;
  const handLabel = handCount === 1 ? "1 hand" : `${handCount} hands`;
  const frameLabel = `${table.frameCount} frame${table.frameCount === 1 ? "" : "s"}`;
  const lastDate = formatDate(table.lastTs);
  const span = formatDateRange(table.firstTs, table.lastTs);

  const body = renderTableBody(table);
  const niceName = composeTableTitle(table);

  // Tri-state flag for the table-level checkbox is computed from
  // `state.selected` against the table's hands; data-* lets the
  // post-render sync routine flip the indeterminate property.
  return `
    <li class="session" data-table-id="${escapeHtml(tableId)}" aria-expanded="${isOpen ? "true" : "false"}">
      <div class="session-head">
        <label class="tree-select session-select" title="Select / deselect every round at this table">
          <input type="checkbox"
                 data-sel-level="table"
                 data-table-id="${escapeHtml(tableId)}" />
        </label>
        <div class="session-row" role="button" tabindex="0">
          <div class="session-info">
            <h2 class="session-title">
              <span class="session-name">${escapeHtml(niceName)}</span>
              <span class="session-tableid">#${escapeHtml(tableId)}</span>
            </h2>
            <div class="session-sub">
              <span>${escapeHtml(handLabel)}</span>
              <span class="dot-sep">·</span>
              <span>${escapeHtml(frameLabel)}</span>
            </div>
          </div>
          <div class="session-meta">
            <span class="session-time">${escapeHtml(lastDate)}</span>
            <span class="dot-sep">·</span>
            <span>${escapeHtml(span)}</span>
          </div>
        </div>
      </div>
      <div class="session-body">${body}</div>
    </li>`;
}

// Re-render strategy: if any replay panels are currently open, do a
// surgical update instead of replacing innerHTML so the panel's DOM
// node (and its inline animation/scroll state) survive. Otherwise the
// cheap full repaint is fine.
function renderHistory() {
  const tables = state.tables;
  updateTopbarStatus();

  if (tables.length === 0) {
    state.replays.clear();
    state.selected.clear();
    els.list.innerHTML = "";
    els.empty.classList.remove("hidden");
    refreshActionBar();
    return;
  }
  els.empty.classList.add("hidden");

  const validIds = new Set(tables.map((t) => t.tableId));
  for (const id of state.expanded) {
    if (!validIds.has(id)) state.expanded.delete(id);
  }
  // Drop selections whose underlying hand no longer exists (e.g. the
  // hand was just deleted, or messages were truncated).
  const validHandKeys = new Set(allHandKeys());
  for (const k of [...state.selected]) {
    if (!validHandKeys.has(k)) state.selected.delete(k);
  }

  if (state.replays.size === 0) {
    els.list.innerHTML = tables.map(renderTable).join("");
    syncSelectionDom();
    refreshActionBar();
    return;
  }

  // ---- Surgical path: at least one panel is open.
  // 1. Detach panels by tableId so they survive the re-render.
  const detached = new Map();   // tableId -> Array<{ key, handIndex, panel }>
  for (const [key, slot] of state.replays) {
    if (!validIds.has(slot.tableId)) {
      // Table somehow disappeared (rare; user cleared storage). Close
      // the replay rather than leak its node.
      state.replays.delete(key);
      continue;
    }
    let arr = detached.get(slot.tableId);
    if (!arr) { arr = []; detached.set(slot.tableId, arr); }
    const host = slot.panel && slot.panel.parentNode;
    if (host) {
      host.removeChild(slot.panel);
      arr.push({ key, handIndex: slot.handIndex, panel: slot.panel });
    }
  }

  els.list.innerHTML = tables.map(renderTable).join("");

  // 2. Re-attach detached panels into their new hand-item hosts and
  //    live-update each engine with any newly-arrived steps.
  for (const [tableId, arr] of detached) {
    const table = tables.find((t) => t.tableId === tableId);
    if (!table) continue;
    for (const entry of arr) {
      const handItem = els.list.querySelector(
        `.hand-item[data-table-id="${cssEscape(tableId)}"][data-hand-index="${entry.handIndex}"]`
      );
      if (!handItem) {
        state.replays.delete(entry.key);
        continue;
      }
      const host = handItem.querySelector(".hand-replay");
      if (!host) {
        state.replays.delete(entry.key);
        continue;
      }
      host.innerHTML = "";
      host.appendChild(entry.panel);
      host.hidden = false;
      handItem.setAttribute("aria-expanded", "true");

      const slot = state.replays.get(entry.key);
      if (slot) liveExtendReplay(slot, table);
    }
  }
  syncSelectionDom();
  refreshActionBar();
}

// Minimal CSS.escape() fallback for the few selector pieces we build.
function cssEscape(s) {
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") return CSS.escape(s);
  return String(s).replace(/([\0-\u001f"\\\[\]:;,.<>?+*~!#$%^&()=|/'])/g, "\\$1");
}

// Rebuild the round from the (possibly-extended) session, then refresh
// the engine if anything actually changed. Since steps are now MERGED
// packets — the head ("dealHoleCards") and tail ("awardPot") packets
// can grow / appear as more raw updates stream in — a packet-count
// comparison is no longer enough. We compare the number of underlying
// raw updates (sum of `step.updates.length`) instead. If anything
// changed we replace the engine wholesale; the engine's `goto()` is a
// full rebuild from step 0 anyway, so this is cheap.
function totalRawUpdates(round) {
  let n = 0;
  if (!round || !Array.isArray(round.steps)) return 0;
  for (const s of round.steps) {
    n += Array.isArray(s.updates) ? s.updates.length : 1;
  }
  return n;
}
function liveExtendReplay(slot, table) {
  if (!ReplayMod) return;
  const hand = table && Array.isArray(table.hands)
    ? table.hands[slot.handIndex]
    : null;
  // Pass the hand directly — buildSteps handles single-hand containers
  // by walking their `frames` slice. This is more robust than the
  // legacy "(table, handIndex)" path because the boundaries are
  // already locked in by tableize.js.
  const fresh = hand
    ? ReplayMod.buildSteps(hand, 0)
    : ReplayMod.buildSteps(table, slot.handIndex);
  if (!fresh) return;
  const have = totalRawUpdates(slot.round);
  const want = totalRawUpdates(fresh);
  if (want > have) {
    const wasAtEnd = slot.engine.cursor >= slot.round.steps.length - 1;
    const targetCursor = wasAtEnd
      ? fresh.steps.length - 1
      : Math.min(slot.engine.cursor, fresh.steps.length - 1);
    slot.round = fresh;
    slot.engine = new ReplayMod.Replay(fresh);
    slot.engine.goto(targetCursor);
    if (slot.peekCache && slot.engine.revealed) {
      for (const [sid, cards] of slot.engine.revealed) {
        slot.peekCache.set(Number(sid), cards);
      }
    }
  }
  renderPanel(replayKey(slot.tableId, slot.handIndex));
}

function scheduleRender() {
  if (state.renderTimer) return;
  state.renderTimer = setTimeout(() => {
    state.renderTimer = null;
    renderHistory();
  }, 200);
}

// Rebuild the per-table buckets from the flat captured-messages array.
// One bucket per `tableId`, no time-based splitting; every hand ever
// observed at that table lives in a single permanent dropdown.
function rebuildTables() {
  if (!Tableize) {
    state.tables = [];
    return;
  }
  state.tables = Tableize.tableize(state.messages || []);
}

// ----------------------------------------------------------- topbar status

function updateTopbarStatus() {
  if (state.activeSection === "history") {
    const n = state.tables.length;
    els.topbarStatus.textContent = `${n} table${n === 1 ? "" : "s"}`;
    els.topbarStatus.classList.remove("muted");
  } else {
    // Settings: show capture state at a glance.
    if (state.settings.captureEnabled) {
      els.topbarStatus.textContent = "Capture on";
      els.topbarStatus.classList.remove("muted");
    } else {
      els.topbarStatus.textContent = "Capture off";
      els.topbarStatus.classList.add("muted");
    }
  }
}

// ----------------------------------------------------------- section nav

function showSection(name) {
  const target = name === "settings" ? "settings" : "history";
  state.activeSection = target;
  for (const tab of els.navTabs) {
    if (tab.dataset.section === target) tab.setAttribute("aria-current", "page");
    else tab.removeAttribute("aria-current");
  }
  for (const sec of els.sections) {
    if (sec.id === `section-${target}`) sec.classList.add("section-active");
    else sec.classList.remove("section-active");
  }
  if (target === "settings") refreshStorageUsage();
  updateTopbarStatus();
}

function readSectionFromHash() {
  const h = (location.hash || "").replace(/^#/, "").toLowerCase();
  return h === "settings" ? "settings" : "history";
}

// ----------------------------------------------------------- session expand

function toggleExpand(li) {
  if (!li) return;
  const id = li.dataset.tableId;
  if (!id) return;
  const isOpen = li.getAttribute("aria-expanded") === "true";
  if (isOpen) {
    li.setAttribute("aria-expanded", "false");
    state.expanded.delete(id);
  } else {
    li.setAttribute("aria-expanded", "true");
    state.expanded.add(id);
  }
}

// --------------------------------------------------------------- settings

async function loadSettings() {
  const data = await chrome.storage.local.get([SETTINGS_KEY]);
  const stored = data[SETTINGS_KEY] && typeof data[SETTINGS_KEY] === "object"
    ? data[SETTINGS_KEY]
    : {};
  // Strip any legacy keys that may still be in stored settings — older
  // builds persisted `idleMs` and `autoFlush`. Current shape is just
  // `{ captureEnabled }`; everything else is ignored.
  const { idleMs: _drop, autoFlush: _drop2, ...sanitized } = stored;
  state.settings = { ...DEFAULT_SETTINGS, ...sanitized };
  applySettingsToUI();
}

async function saveSettings(patch) {
  state.settings = { ...state.settings, ...patch };
  await chrome.storage.local.set({ [SETTINGS_KEY]: state.settings });
}

function applySettingsToUI() {
  if (els.captureToggle) {
    els.captureToggle.checked = !!state.settings.captureEnabled;
  }
  if (els.flushEndpoint && Serialize) {
    els.flushEndpoint.textContent = Serialize.FLUSH_ENDPOINT;
  }
  updateTopbarStatus();
}

// ---------------------------------------------------------------- flush UI

// Reconcile the persisted hand index, mirror it into `state.flushIndex`,
// and refresh both the Settings status pill and the per-round badges
// in the History list. Safe to call repeatedly; the heavy work
// (sha-256 over each hand) is bounded by O(hands).
async function refreshFlushIndex() {
  if (!Flush) return;
  let index;
  try {
    index = await Flush.refreshIndex();
  } catch (e) {
    console.warn("[CASINO] refreshFlushIndex failed:", e);
    return;
  }
  state.flushIndex = new Map(index.map((r) => [r.handKey, r]));
  renderFlushStatus();
  // Round-row badges are part of the regular history render — kick a
  // re-render so they pick up the new state.
  scheduleRender();
}

function renderFlushStatus() {
  if (!els.flushStatus || !Flush) return;
  const stats = Flush.computeStats(Array.from(state.flushIndex.values()));

  // Two lines on the same row: sync axis first (the user's primary
  // concern), lifecycle counts second so they can see how many hands
  // are simply not flushable.
  const sync = [`${stats.flushed} synced`, `${stats.pending} pending`];
  if (stats.needsReflush > 0) sync.push(`${stats.needsReflush} retry`);
  if (stats.flushing > 0) sync.push(`${stats.flushing} in flight`);

  const lifecycle = [];
  if (stats.inProgress > 0) lifecycle.push(`${stats.inProgress} in progress`);
  if (stats.incomplete > 0) lifecycle.push(`${stats.incomplete} incomplete`);

  const parts = sync.join(" · ");
  const tail = lifecycle.length ? `  (${lifecycle.join(", ")} not flushable)` : "";
  els.flushStatus.textContent = parts + tail;

  if (els.flushBtn) {
    const haveActionable = stats.pending + stats.needsReflush > 0;
    els.flushBtn.disabled = state.flushBusy || !haveActionable;
  }
}

function setFlushError(msg) {
  if (!els.flushError) return;
  if (!msg) {
    els.flushError.textContent = "";
    els.flushError.hidden = true;
  } else {
    els.flushError.textContent = msg;
    els.flushError.hidden = false;
  }
}

async function manualFlush() {
  if (!Flush || state.flushBusy) return;
  state.flushBusy = true;
  setFlushError("");
  if (els.flushBtn) els.flushBtn.disabled = true;
  refreshActionBar();
  try {
    const summary = await Flush.flushNow({ scope: "all", owner: "history-page" });
    if (summary.locked) {
      setFlushError("Another flush is already in progress; try again in a few seconds.");
    } else if (summary.failed > 0) {
      setFlushError(`Flushed ${summary.succeeded}/${summary.attempted}; ${summary.failed} failed (will be retried).`);
    }
  } catch (e) {
    setFlushError(`Flush error: ${e && e.message || e}`);
  } finally {
    state.flushBusy = false;
    refreshActionBar();
    await refreshFlushIndex();
  }
}

// =================================================================
// Selection-scoped exports (.casinodump and readable JSON).
// -----------------------------------------------------------------
// Both exports walk `state.selected`, derive each hand's bytes from
// the live messages capture, and produce a single download. We use
// `chrome.downloads.download` with a `data:` URL — works from a page
// context AND a service worker, no Blob shenanigans needed.
// =================================================================

// Resolve `state.selected` to { selectedHands: [{ table, hand }],
// finishedOnly: boolean }. We restrict exports to `finished` hands;
// in-progress / incomplete are skipped silently per the existing
// flush semantics.
function selectionToHands() {
  const out = [];
  let skipped = 0;
  for (const key of state.selected) {
    const ref = handFromSelectionKey(key);
    if (!ref) { skipped++; continue; }
    if (ref.hand && ref.hand.lifecycle === "finished") {
      out.push({ table: ref.table, hand: ref.hand });
    } else {
      skipped++;
    }
  }
  return { selectedHands: out, skipped };
}

function timestampedFilename(prefix, ext) {
  return `${prefix}-${new Date().toISOString().replace(/[:.]/g, "-")}.${ext}`;
}

function downloadDataUrl(filename, dataUrl) {
  return new Promise((resolve, reject) => {
    try {
      chrome.downloads.download({ url: dataUrl, filename, saveAs: true }, (downloadId) => {
        if (chrome.runtime.lastError) {
          reject(new Error(chrome.runtime.lastError.message));
          return;
        }
        resolve({ downloadId, filename });
      });
    } catch (e) {
      reject(e);
    }
  });
}

async function exportSelectedDump() {
  if (!Serialize || !Flush || state.flushBusy) return;
  if (state.selected.size === 0) return;
  state.flushBusy = true;
  setFlushError("");
  refreshActionBar();
  try {
    const { selectedHands, skipped } = selectionToHands();
    if (selectedHands.length === 0) {
      setFlushError("Nothing to export — selection contains only in-progress / incomplete hands.");
      return;
    }
    const envelopes = [];
    for (const { table, hand } of selectedHands) {
      const env = await Serialize.buildHandEnvelope(table, hand);
      envelopes.push(env);
    }
    const userIndex = (Users && Users.buildIndex)
      ? Users.buildIndex(state.messages || [])
      : null;
    const container = Serialize.buildExportContainer(envelopes, userIndex);
    const b64 = await Serialize.encodeContainerBase64(container);
    const filename = timestampedFilename("casino-export", "casinodump");
    await downloadDataUrl(filename, `data:application/octet-stream;base64,${b64}`);
    if (skipped > 0) {
      setFlushError(`Exported ${envelopes.length} round${envelopes.length === 1 ? "" : "s"}; skipped ${skipped} unfinished.`);
    }
  } catch (e) {
    setFlushError(`Export error: ${e && e.message || e}`);
  } finally {
    state.flushBusy = false;
    refreshActionBar();
  }
}

async function exportSelectedReadable() {
  if (!Readable || !ReplayMod || state.flushBusy) return;
  if (state.selected.size === 0) return;
  state.flushBusy = true;
  setFlushError("");
  refreshActionBar();
  try {
    const { selectedHands, skipped } = selectionToHands();
    if (selectedHands.length === 0) {
      setFlushError("Nothing to export — selection contains only in-progress / incomplete hands.");
      return;
    }
    // Per chat 2026-05-21: extension does NOT assume hero names. We
    // emit `player.name = null` for every round (the website mirror
    // resolves names from the userIndex). Readers / tooling can
    // post-process if they want.
    const rounds = [];
    for (const { table, hand } of selectedHands) {
      const container = {
        frames: hand.frames || [],
        tableId: table.tableId,
        tableName: composeTableTitle(table),
        pageTitle: table.pageTitle || null
      };
      const round = ReplayMod.buildSteps(container, hand.handIndex || 0);
      rounds.push({
        player: { name: null, casinoUserId: null },
        table: { tableId: table.tableId, names: table.names || null },
        round: Readable.buildRoundReadable(round, null, {
          firstTs: hand.firstTs, lastTs: hand.lastTs
        })
      });
    }
    const payload = Readable.buildExport(rounds);
    const json = JSON.stringify(payload, null, 2);
    // data: URLs cap around 2 MB on Chrome — for big readables we'd
    // need a Blob URL. Stick with data: for now; the typical
    // extension-side selection is a few rounds.
    const b64 = btoa(unescape(encodeURIComponent(json)));
    const filename = timestampedFilename("casino-readable", "json");
    await downloadDataUrl(filename, `data:application/json;base64,${b64}`);
    if (skipped > 0) {
      setFlushError(`Exported ${rounds.length} round${rounds.length === 1 ? "" : "s"}; skipped ${skipped} unfinished.`);
    }
  } catch (e) {
    setFlushError(`Readable export error: ${e && e.message || e}`);
  } finally {
    state.flushBusy = false;
    refreshActionBar();
  }
}

async function refreshStorageUsage() {
  const messageCount = state.messages.length;
  if (els.storageMessages) {
    els.storageMessages.textContent = `${messageCount.toLocaleString()}`;
  }
  let bytes = 0;
  try {
    if (chrome.storage.local.getBytesInUse) {
      bytes = await new Promise((resolve, reject) => {
        chrome.storage.local.getBytesInUse([MESSAGES_KEY], (n) => {
          const err = chrome.runtime.lastError;
          if (err) reject(err);
          else resolve(n || 0);
        });
      });
    }
  } catch (_) {
    bytes = 0;
  }
  if (!bytes) {
    try { bytes = new Blob([JSON.stringify(state.messages)]).size; } catch { bytes = 0; }
  }
  if (els.storageSize) {
    els.storageSize.textContent = formatBytes(bytes);
  }
}

// =================================================================
// Delete: per-round, per-table.
// -----------------------------------------------------------------
// Hands are derived (not stored) — each `Hand` is a slice of the
// table's `frames[]`, and each frame carries a `srcId` pointing back
// to the raw `messages[]` entry. Deleting a hand therefore means
// *filtering the messages array* to drop every entry whose id appears
// in the hand's frame slice. Background's storage.onChanged fires,
// `tableize` re-runs, and the row disappears.
//
// Per-table delete is simpler: drop every message whose parsed
// Phoenix frame topic is `table:<id>`.
//
// `network[]` and `pageData[]` are not deleted — they aren't keyed by
// table, and the user's spec is round/table-scoped.
// =================================================================

// Set of message ids for the given hand. `tableize` already includes
// `srcId` on each frame; we just collect them.
function collectHandSrcIds(hand) {
  const out = new Set();
  if (!hand || !Array.isArray(hand.frames)) return out;
  for (const f of hand.frames) {
    if (f && f.srcId) out.add(f.srcId);
  }
  return out;
}

// Look up a hand by `${tableId}::${handIndex}` selection-key.
function handFromSelectionKey(key) {
  const sep = key.indexOf("::");
  if (sep < 0) return null;
  const tableId = key.slice(0, sep);
  const handIndex = Number(key.slice(sep + 2));
  if (!Number.isFinite(handIndex)) return null;
  const table = state.tables.find((t) => t.tableId === tableId);
  if (!table || !Array.isArray(table.hands)) return null;
  const hand = table.hands[handIndex];
  if (!hand) return null;
  return { tableId, handIndex, table, hand };
}

async function deleteSelectedHands() {
  if (state.selected.size === 0) return;
  const drop = new Set();
  for (const key of state.selected) {
    const ref = handFromSelectionKey(key);
    if (!ref) continue;
    for (const id of collectHandSrcIds(ref.hand)) drop.add(id);
  }
  state.selected.clear();
  await dropMessageIds(drop);
}

async function deleteTable(tableId) {
  const table = state.tables.find((t) => t.tableId === tableId);
  if (!table) return;
  const drop = new Set();
  if (Array.isArray(table.hands)) {
    for (const h of table.hands) {
      for (const id of collectHandSrcIds(h)) drop.add(id);
    }
  }
  // Also drop any table:<id> frames that didn't land inside a detected
  // hand (handshake / between-rounds chatter). We only have the parsed
  // payload + topic on `table.frames`, but every frame carries a srcId,
  // so a fold over `table.frames` is the right hammer.
  if (Array.isArray(table.frames)) {
    for (const f of table.frames) {
      if (f && f.srcId) drop.add(f.srcId);
    }
  }
  // Drop the now-orphan selection entries for this table, plus any
  // open replay panels.
  for (const key of [...state.selected]) {
    if (key.startsWith(tableId + "::")) state.selected.delete(key);
  }
  for (const key of [...state.replays.keys()]) {
    if (key.startsWith(tableId + "::")) state.replays.delete(key);
  }
  state.expanded.delete(tableId);
  await dropMessageIds(drop);
}

// Filter `messages[]` to remove every entry whose id is in `drop`,
// then write the trimmed array back. We also rebuild local state
// eagerly so the UI updates without waiting for the storage round-trip.
async function dropMessageIds(drop) {
  if (!drop || drop.size === 0) return;
  const trimmed = (state.messages || []).filter((m) => !m.id || !drop.has(m.id));
  state.messages = trimmed;
  rebuildTables();
  renderHistory();
  refreshActionBar();
  refreshStorageUsage();
  await chrome.storage.local.set({ [MESSAGES_KEY]: trimmed });
}

// Selection key for one round: `<tableId>::<handIndex>`. The leading
// segment lets us cheaply group selections by table for bulk ops.
function handSelectKey(tableId, handIndex) {
  return `${tableId}::${handIndex}`;
}

// Every selection-eligible round under one table, regardless of how
// the table is currently expanded. Used both to compute the table's
// tri-state and to cascade a table-level click.
function handKeysForTable(table) {
  const out = [];
  if (!table || !Array.isArray(table.hands)) return out;
  for (const h of table.hands) {
    out.push(handSelectKey(table.tableId, h.handIndex));
  }
  return out;
}

// Every selection-eligible round across the whole tree.
function allHandKeys() {
  const out = [];
  for (const t of state.tables) {
    for (const k of handKeysForTable(t)) out.push(k);
  }
  return out;
}

// Sync the action bar's count and per-button enabled state with the
// current selection. The bar itself is shown whenever any tables
// exist (so users can hit Select All from a clean state); it hides
// only on an empty list.
function refreshActionBar() {
  if (!els.actionBar) return;
  const haveData = state.tables.length > 0;
  if (!haveData) {
    els.actionBar.hidden = true;
    return;
  }
  els.actionBar.hidden = false;
  const n = state.selected.size;
  if (els.actionBarCount) {
    els.actionBarCount.textContent = n === 0
      ? "No rounds selected"
      : `${n} round${n === 1 ? "" : "s"} selected`;
  }
  const allKeys = allHandKeys();
  const allChecked = allKeys.length > 0 && allKeys.every((k) => state.selected.has(k));
  if (els.selectAllBtn) {
    // Disable Select all only when literally everything is already
    // selected — there's nothing more to select.
    els.selectAllBtn.disabled = allChecked;
  }
  if (els.deleteSelectedBtn)  els.deleteSelectedBtn.disabled  = n === 0 || state.flushBusy;
  if (els.exportSelectedBtn)  els.exportSelectedBtn.disabled  = n === 0 || state.flushBusy;
  if (els.exportReadableBtn)  els.exportReadableBtn.disabled  = n === 0 || state.flushBusy;
  if (els.cancelSelectionBtn) els.cancelSelectionBtn.disabled = n === 0;
}

// Walk the rendered DOM after each render and reflect `state.selected`
// onto every checkbox's `checked` (and table-level `indeterminate`)
// property. Cheap to re-run; the History list is at most a few
// hundred rows.
function syncSelectionDom() {
  if (!els.list) return;
  // Round checkboxes — boolean checked.
  const handBoxes = els.list.querySelectorAll('input[data-sel-level="hand"]');
  for (const cb of handBoxes) {
    const key = cb.dataset.selKey;
    cb.checked = !!(key && state.selected.has(key));
  }
  // Table checkboxes — tri-state. checked iff every round selected,
  // indeterminate iff some-but-not-all, unchecked iff none.
  const tableBoxes = els.list.querySelectorAll('input[data-sel-level="table"]');
  for (const cb of tableBoxes) {
    const tableId = cb.dataset.tableId;
    const table = state.tables.find((t) => t.tableId === tableId);
    const keys = handKeysForTable(table);
    if (keys.length === 0) {
      cb.checked = false;
      cb.indeterminate = false;
      continue;
    }
    let n = 0;
    for (const k of keys) if (state.selected.has(k)) n++;
    cb.checked = n === keys.length;
    cb.indeterminate = n > 0 && n < keys.length;
  }
}

// =================================================================
// Inline Replay panel
// =================================================================
//
// Each `.hand-item` row in the DOM has a `.hand-replay` host element. When
// the user toggles a round open we clone `<template id="replayTemplate">`
// into that host, build a CasinoReplay.Replay engine, and record everything
// in `state.replays.get(key)` so we can re-render on step changes.
//
// Multiple rounds can be expanded simultaneously; each panel owns its own
// engine and DOM references.

function replayKey(tableId, handIndex) {
  return `${tableId}::${handIndex}`;
}

function fallbackName(seat) {
  if (seat.userId != null) {
    if (Users) return Users.nameFor(state.userIndex, seat.userId);
    return `User ${seat.userId}`;
  }
  return `Seat ${seat.seatId}`;
}

function avatarFor(seat) {
  if (seat.userId == null || !Users) return null;
  return Users.avatarFor(state.userIndex, seat.userId);
}

// "Hero seat" = the seat whose dealHoleCards step carried real (non-X)
// cards. That's the perspective the round was recorded from. The data
// answer is stable for the whole round so we compute it once per replay.
function detectHeroSeat(round) {
  if (!round || !Array.isArray(round.steps)) return null;
  for (const step of round.steps) {
    if (!step || step.action !== "dealHoleCards") continue;
    const u = step.update;
    if (!u || !Array.isArray(u.players)) continue;
    for (const p of u.players) {
      if (p && p.seatId != null && Array.isArray(p.cards) && p.cards.length === 2
          && p.cards.every((c) => c && c !== "X")) {
        return Number(p.seatId);
      }
    }
  }
  return null;
}

// "Currently acting" seat = the seat whose action belongs to the *current*
// step (not the last action ever recorded, which Replay.snapshot.lastAction
// would give us). If the current step isn't a player action (e.g. it's
// dealCommunityCards / showdown / awardPot), nobody is highlighted yellow.
const ACTING_STEPS = new Set(["check", "call", "bet", "raise", "fold", "allIn"]);
function actingSeatForStep(step) {
  if (!step || !ACTING_STEPS.has(step.action)) return null;
  const sid = step.update && step.update.seatId;
  return sid != null ? Number(sid) : null;
}

// Seat positions (percent of felt) tuned for up to 9 seats. The first slot
// in each layout is "bottom center" — i.e. where the local player would sit
// on casino.org — and the rest fan outward.
function seatPositions(count) {
  // Layouts keep `--x` between ~16 and 84 so the 160px seat boxes don't
  // clip on either side of the felt at common widths. The first slot in
  // each layout is bottom-centre (hero seat on casino.org).
  const layouts = {
    1: [[50, 82]],
    2: [[50, 82], [50, 18]],
    3: [[50, 82], [18, 48], [82, 48]],
    4: [[50, 82], [18, 56], [50, 18], [82, 56]],
    5: [[50, 84], [18, 64], [25, 24], [75, 24], [82, 64]],
    6: [[50, 84], [18, 64], [22, 24], [50, 16], [78, 24], [82, 64]],
    7: [[50, 86], [18, 68], [18, 38], [35, 18], [65, 18], [82, 38], [82, 68]],
    8: [[50, 86], [20, 70], [18, 42], [32, 20], [68, 20], [82, 42], [80, 70], [50, 16]],
    9: [[50, 86], [18, 72], [18, 48], [25, 22], [40, 14], [60, 14], [75, 22], [82, 48], [82, 72]]
  };
  return layouts[Math.min(9, Math.max(1, count))] || layouts[9];
}

// Pretty pill labels for action + chips. Stable width inside the pill so
// the name plate above does not jitter step-to-step — the chip count goes
// here, never on the plate.
function actionPillLabel(action, chips) {
  const name = ({
    check: "CHECK",
    call: "CALL",
    bet: "BET",
    raise: "RAISE",
    fold: "FOLD",
    allIn: "ALL IN",
    show: "SHOW",
    muck: "MUCK"
  })[action] || String(action).toUpperCase();
  if (chips && Number.isFinite(chips) && chips > 0
      && (action === "call" || action === "bet" || action === "raise" || action === "allIn")) {
    return `${name} ${chips}`;
  }
  return name;
}

// ---- Chip stack renderer ---------------------------------------------------
//
// Decomposes an integer chip amount into our 8 fixed denominations
// (greedy: 25000 -> 5000 -> 1000 -> 500 -> 100 -> 25 -> 5 -> 1) and emits
// HTML for the "zig-zag tower of stacks" visual:
//
//   * One COLUMN per denomination present in the breakdown. Columns are
//     ordered left-to-right with the largest denomination first.
//   * Within a column, identical-denomination chips stack vertically
//     (each chip sits a few pixels above the one below it). More chips
//     => a taller column. Capped at 8 visible chips per column to keep
//     the visual contained; any overflow is denoted by a small "+N"
//     badge over the column.
//   * Column BOTTOMS alternate between a low baseline and a slightly
//     raised baseline (the zig-zag): even-indexed columns (incl. col 0,
//     the leftmost / largest denomination) sit on the LOW baseline,
//     odd-indexed columns ride RAISED. This matches the user's spec —
//     "first chip is at down left, second is up and righter" — and
//     stacks same-denomination chips vertically inside each column.
//
// The returned markup is a single `.chip-stack` flex row whose CSS does
// the actual placement and overlap. JS only emits column descriptors.
const CHIP_DENOMS = [25000, 5000, 1000, 500, 100, 25, 5, 1];
const CHIP_STACK_CAP = 8;     // max chips drawn per column

function chipBreakdown(amount) {
  let n = Math.max(0, Math.floor(Number(amount) || 0));
  const out = [];
  for (const d of CHIP_DENOMS) {
    if (n >= d) {
      const count = Math.floor(n / d);
      n -= count * d;
      out.push({ denom: d, count });
    }
  }
  return out;   // [{denom, count}, ...] largest -> smallest
}

function chipStackHtml(amount) {
  const breakdown = chipBreakdown(amount);
  if (breakdown.length === 0) return "";
  const columns = breakdown.map((b, i) => {
    const visible = Math.min(b.count, CHIP_STACK_CAP);
    const overflow = b.count - visible;
    const chips = [];
    // k=0 is the bottom-most chip (CSS `bottom: 0`); successive chips
    // are emitted in DOM order on top of it so the natural paint order
    // gives us a chip stack with the topmost image drawn last.
    for (let k = 0; k < visible; k++) {
      const src = `assets/chip${b.denom}.png`;
      chips.push(`<img class="chip" src="${escapeHtml(src)}" alt="" style="--k:${k};" draggable="false"/>`);
    }
    const overflowBadge = overflow > 0
      ? `<span class="chip-stack-overflow">+${overflow}</span>`
      : "";
    // Even-indexed columns ride the lower baseline; odd-indexed ride
    // the raised baseline (zig-zag). Column index encoded as a CSS
    // custom property so the stylesheet can pick the offset.
    const parity = (i % 2 === 0) ? "low" : "high";
    return `<span class="chip-col chip-col-${parity}" data-denom="${b.denom}" data-count="${b.count}">
        ${chips.join("")}
        ${overflowBadge}
      </span>`;
  });
  return `<span class="chip-stack" aria-hidden="true">${columns.join("")}</span>`;
}

function renderSeats(snap, peekCache, ctx) {
  ctx = ctx || {};
  const heroSeat = ctx.heroSeat != null ? Number(ctx.heroSeat) : null;
  const actingSeat = ctx.actingSeat != null ? Number(ctx.actingSeat) : null;
  const currentStep = ctx.currentStep || null;
  const currentChips = currentStep && currentStep.update && Number.isFinite(currentStep.update.chips)
    ? currentStep.update.chips
    : null;

  // "away" players are dropped from the felt entirely — they're treated as
  // if they got up from the table for this round. "sitOut" players are
  // still rendered so the viewer can see who's at the table; they get a
  // dark-grey treatment via the `.sit-out` class below.
  const occupied = Object.values(snap.seats)
    .filter((s) => s.state !== "away"
                && (s.userId != null
                    || s.state === "playing"
                    || s.state === "sitOut"
                    || (Array.isArray(s.cards) && s.cards.length)))
    .sort((a, b) => a.seatId - b.seatId);

  if (occupied.length === 0) return "";

  const positions = seatPositions(occupied.length);

  return occupied.map((seat, i) => {
    const [x, y] = positions[i] || [50, 50];
    // Pick one of four orientations from the seat's position on the felt.
    // The CSS for each orientation arranges the three pieces (icon /
    // name plate / cards) into a different grid template, all without
    // ever rotating the cards themselves.
    //
    //   down  (bottom of felt):  icon -> name plate -> cards
    //   up    (top of felt):     cards -> icon -> name plate
    //   left  (left edge):       icon-then-plate column on the left;
    //                            cards to the RIGHT of that column,
    //                            bottom-aligned with the icon.
    //   right (right edge):      icon-then-plate column on the right;
    //                            cards to the LEFT of that column,
    //                            bottom-aligned with the icon.
    // Pick the dominant axis. If the seat is far from the centre on the X
    // axis (corner seats, side seats), it's "left" or "right" regardless
    // of its Y. Only seats roughly aligned with the centre column become
    // "up" / "down".
    let orient;
    const dxCenter = Math.abs(x - 50);
    const dyCenter = Math.abs(y - 50);
    if (dxCenter > dyCenter) {
      orient = (x < 50) ? "left" : "right";
    } else {
      orient = (y > 50) ? "down" : "up";
    }

    const peekable = peekCache.has(seat.seatId);
    let cardsHtml = "";
    if (Array.isArray(seat.cards)) {
      cardsHtml = `
        <div class="seat-cards">
          ${cardHtml(seat.cards[0], { peekable, seatId: seat.seatId, cardIdx: 0 })}
          ${cardHtml(seat.cards[1], { peekable, seatId: seat.seatId, cardIdx: 1 })}
        </div>`;
    }

    // Pill: prefer the seat's own lastAction; if THIS seat is the actor on
    // the current step also surface that step's chip count.
    // ALWAYS emit a `.seat-action` element (visibility-hidden when empty)
    // so the plate height never changes between steps.
    let actionPill;
    if (seat.lastAction) {
      const cls = `action-${seat.lastAction}`;
      const chips = (actingSeat === seat.seatId) ? currentChips : null;
      actionPill = `<span class="seat-action ${cls}">${escapeHtml(actionPillLabel(seat.lastAction, chips))}</span>`;
    } else if (seat.state === "sitOut") {
      actionPill = `<span class="seat-action action-fold">SIT OUT</span>`;
    } else {
      actionPill = `<span class="seat-action placeholder" aria-hidden="true">·</span>`;
    }

    // Bet / winnings / refund block (in front of the seat). At any
    // normal step this is a pill with the numeric amount + chip stack
    // for the seat's outstanding bet. At the award step the bet is
    // already 0; we instead show:
    //   - winnings  -> green pill, "+N" amount, with the same chip
    //                  stack visualisation (the chips are flowing INTO
    //                  the player's pocket).
    //   - refund    -> cyan pill, "+N" amount, same chip stack.
    // A seat that's both a winner *and* received a refund stacks both
    // pills (refund first, winnings second) so they're visible side
    // by side. The class on the outer `.seat-bet` controls the colour.
    let betHtml = "";
    if (seat.bet > 0) {
      betHtml = `<div class="seat-bet" title="Bet">
           <span class="seat-bet-amount">${escapeHtml(String(seat.bet))}</span>
           ${chipStackHtml(seat.bet)}
         </div>`;
    } else if ((seat.winnings || 0) > 0 || (seat.refund || 0) > 0) {
      const parts = [];
      if ((seat.refund || 0) > 0) {
        parts.push(`<div class="seat-bet seat-bet-refund" title="Refund">
           <span class="seat-bet-amount">+${escapeHtml(String(seat.refund))}</span>
           ${chipStackHtml(seat.refund)}
         </div>`);
      }
      if ((seat.winnings || 0) > 0) {
        parts.push(`<div class="seat-bet seat-bet-winnings" title="Winnings">
           <span class="seat-bet-amount">+${escapeHtml(String(seat.winnings))}</span>
           ${chipStackHtml(seat.winnings)}
         </div>`);
      }
      betHtml = parts.join("");
    }

    const dealer = (seat.seatId === snap.dealerSeat)
      ? `<span class="seat-dealer" aria-label="Dealer">D</span>`
      : "";

    // State classes — applied to the outer `.felt-seat` and consumed by
    // history.css. The whole badge (icon + plate) takes on the state
    // colour. Precedence is enforced in CSS via source order: later
    // rules win, so we list them outer-class-string in least-to-most
    // important order.
    const winnerCls = seat.winnings > 0 ? " seat-winner" : "";
    const refundCls = (seat.refund || 0) > 0 && seat.winnings <= 0 ? " seat-refund" : "";
    const foldedCls = seat.folded ? " folded" : "";
    const heroCls = (heroSeat != null && seat.seatId === heroSeat) ? " hero" : "";
    const actingCls = (actingSeat != null && seat.seatId === actingSeat) ? " acting" : "";
    const sitOutCls = seat.state === "sitOut" ? " sit-out" : "";
    const allInCls = seat.allIn ? " all-in" : "";

    const avatar = avatarFor(seat);
    const avatarHtml = avatar
      ? `<img class="seat-avatar-img" src="${escapeHtml(avatar)}" alt="" referrerpolicy="no-referrer" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'seat-avatar-fallback',textContent:'?'}))"/>`
      : `<span class="seat-avatar-fallback" aria-hidden="true">${escapeHtml(String(seat.seatId))}</span>`;

    // Always emit the four pieces (icon, plate, cards, bet) in the same
    // DOM order. The unified seat grid in history.css places each piece
    // into its named area (`icon | bet` on top, `plate | cards` below)
    // regardless of where the seat sits on the felt.
    const cardsBlock = cardsHtml || `<div class="seat-cards empty" aria-hidden="true"></div>`;
    return `
      <div class="felt-seat orient-${orient}${heroCls}${actingCls}${foldedCls}${sitOutCls}${allInCls}${refundCls}${winnerCls}"
           style="--x:${x}%; --y:${y}%;"
           data-seat-id="${seat.seatId}">
        <div class="seat-icon">${avatarHtml}</div>
        <div class="seat-name-plate">
          <span class="seat-name" title="seat ${seat.seatId}">${escapeHtml(fallbackName(seat))}</span>
          <span class="seat-stack">${seat.stack != null ? escapeHtml(String(seat.stack)) : "—"}</span>
          ${actionPill}
        </div>
        ${cardsBlock}
        ${betHtml}
        ${dealer}
      </div>`;
  }).join("");
}

// Card HTML — uses the CasinoCards SVG renderer at a single canonical size.
// `opts.peekable` marks hidden cards that are eventually revealed, so the
// peek-on-click handler can flip them. `opts.boardIdx` lets the panel
// renderer locate community-card slots after re-render.
function cardHtml(face, opts) {
  opts = opts || {};
  if (Cards) {
    const dataAttrs = [];
    if (opts.peekable) dataAttrs.push({ name: "data-peekable", value: "1" });
    if (opts.seatId != null) dataAttrs.push({ name: "data-seat-id", value: opts.seatId });
    if (opts.cardIdx != null) dataAttrs.push({ name: "data-card-idx", value: opts.cardIdx });
    if (opts.boardIdx != null) dataAttrs.push({ name: "data-board-idx", value: opts.boardIdx });
    return Cards.render(face || "X", {
      hidden: !face || face === "X",
      peekable: !!opts.peekable,
      dataAttrs
    });
  }
  const txt = face && face !== "X" ? face : "X";
  return `<span class="card-wrap"><span class="card-fallback">${escapeHtml(txt)}</span></span>`;
}

// Pre-scan the round for every community card that will *ever* be dealt,
// in board order. Used so unflipped board slots can be peeked just like
// hidden hole cards. Returns an array of length 0..5.
function indexBoardRevelations(round) {
  const out = [];
  if (!round || !Array.isArray(round.steps)) return out;
  for (const step of round.steps) {
    if (step.action !== "dealCommunityCards") continue;
    const cards = step.update && Array.isArray(step.update.cards) ? step.update.cards : null;
    if (!cards) continue;
    for (const c of cards) {
      if (out.length >= 5) break;
      out.push(c);
    }
    if (out.length >= 5) break;
  }
  return out;
}

function renderPanel(key) {
  const slot = state.replays.get(key);
  if (!slot || !slot.panel) return;
  const { engine, round, panel } = slot;
  const snap = engine.snapshot();
  const step = engine.currentStep();

  // Hero is round-stable once dealHoleCards has been seen, but the round
  // may have grown to include it since we last rendered, so re-detect if
  // we still don't have one.
  if (slot.heroSeat == null) slot.heroSeat = detectHeroSeat(round);
  const actingSeat = actingSeatForStep(step);

  // Refresh board revelations: when a round is being recorded live, new
  // dealCommunityCards steps may have been appended since the panel opened.
  slot.boardRevelations = indexBoardRevelations(round);

  // Same idea for hole-card revelations: when `show` updates arrive after
  // the panel was opened, the engine's `revealed` map gets the new cards
  // (via appendSteps), but our slot-level peekCache snapshot is stale.
  // Resync it so freshly-revealed seats become click-to-peek.
  if (slot.peekCache && engine.revealed) {
    for (const [sid, cards] of engine.revealed) {
      slot.peekCache.set(Number(sid), cards);
    }
  }

  // Pot — pill on the left, chip-stack visualisation on the right. The
  // chip stack reflects `snap.pot` decomposed into our 8 fixed
  // denominations (see chipStackHtml). Empty pot => no chips.
  const potEl = panel.querySelector(".replay-pot");
  const potValue = snap.pot || 0;
  potEl.innerHTML = `
    <span class="pot-pill"><span class="pot-label">Pot</span>${escapeHtml(String(potValue))}</span>
    ${chipStackHtml(potValue)}`;

  // Board: always 5 slots. Already-dealt -> face up. Not yet dealt -> hidden
  // card back; if we *know* what will be dealt later (boardRevelations) the
  // back is marked peekable so the user can click it to flip-preview.
  const boardEl = panel.querySelector(".replay-board");
  const boardHtml = [];
  for (let i = 0; i < 5; i++) {
    if (i < snap.board.length) {
      boardHtml.push(cardHtml(snap.board[i], { boardIdx: i }));
    } else {
      const future = slot.boardRevelations[i];
      const peekable = !!(future && future !== "X");
      boardHtml.push(cardHtml(null, { peekable, boardIdx: i }));
    }
  }
  boardEl.innerHTML = boardHtml.join("");

  // Seats
  const seatsEl = panel.querySelector(".replay-seats");
  seatsEl.innerHTML = renderSeats(snap, slot.peekCache, {
    heroSeat: slot.heroSeat,
    actingSeat,
    currentStep: step
  });

  // Step label
  panel.querySelector(".replay-step-label").textContent = step
    ? ReplayMod.stepLabel(step)
    : "Before deal";

  // Buttons
  panel.querySelector(".replay-prev").disabled = engine.cursor < 0;
  panel.querySelector(".replay-next").disabled = engine.cursor >= engine.length() - 1;

  // Scrubber bounds may have grown if new steps streamed in live.
  const scrub = panel.querySelector(".replay-scrubber");
  if (Number(scrub.max) !== engine.length() - 1) {
    scrub.max = String(Math.max(0, engine.length() - 1));
    renderTicks(panel, round);
  }
  const cursor = Math.max(0, engine.cursor);
  if (Number(scrub.value) !== cursor) scrub.value = String(cursor);
}

function renderTicks(panel, round) {
  const ticksEl = panel.querySelector(".replay-ticks");
  const n = round.steps.length;
  if (n <= 0) { ticksEl.innerHTML = ""; return; }

  // Label `dealCommunityCards` steps in order: Flop / Turn / River.
  let dealsSeen = 0;
  const labelByIdx = new Array(n).fill("");
  for (let i = 0; i < n; i++) {
    const s = round.steps[i];
    if (!s.milestone) continue;
    if (s.action === "dealCommunityCards") {
      dealsSeen++;
      if (dealsSeen === 1) labelByIdx[i] = "Flop";
      else if (dealsSeen === 2) labelByIdx[i] = "Turn";
      else if (dealsSeen === 3) labelByIdx[i] = "River";
      else labelByIdx[i] = "Board";
    } else {
      labelByIdx[i] = shortMilestoneLabel(s);
    }
  }

  const html = round.steps.map((s, i) => {
    const x = n === 1 ? 0 : (i / (n - 1)) * 100;
    const cls = s.milestone ? "replay-tick milestone" : "replay-tick";
    const label = s.milestone && labelByIdx[i]
      ? `<span class="replay-tick-label" style="left:${x}%;">${escapeHtml(labelByIdx[i])}</span>`
      : "";
    return `<span class="${cls}" style="left:${x}%;"></span>${label}`;
  }).join("");
  ticksEl.innerHTML = html;
}

function shortMilestoneLabel(step) {
  switch (step.action) {
    case "startHand":     return "Start";
    case "dealHoleCards": return "Deal";
    case "showdown":      return "Showdown";
    case "awardPot":      return "Award";
    case "finishHand":    return "End";
    default:              return "";
  }
}

function snapToMilestone(round, value) {
  const milestoneIdxs = [];
  for (let i = 0; i < round.steps.length; i++) {
    if (round.steps[i].milestone) milestoneIdxs.push(i);
  }
  if (milestoneIdxs.length === 0) return value;
  let best = milestoneIdxs[0];
  let bestDist = Math.abs(value - best);
  for (const idx of milestoneIdxs) {
    const d = Math.abs(value - idx);
    if (d < bestDist) { best = idx; bestDist = d; }
  }
  if (bestDist <= 1) return best;
  return value;
}

// Build (or rebuild) the inline replay panel for a hand row.
function openInlineReplay(handItem) {
  if (!ReplayMod) return;
  const tableId = handItem.dataset.tableId;
  const handIndex = Number(handItem.dataset.handIndex);
  if (!tableId || !Number.isFinite(handIndex)) return;

  const key = replayKey(tableId, handIndex);
  const host = handItem.querySelector(".hand-replay");
  if (!host) return;

  const table = state.tables.find((t) => t.tableId === tableId);
  if (!table) return;
  const hand = Array.isArray(table.hands) ? table.hands[handIndex] : null;
  // Pass the hand directly when we have it so buildSteps walks its
  // dedicated frame slice. Falls back to (table, handIndex) for safety.
  const round = hand
    ? ReplayMod.buildSteps(hand, 0)
    : ReplayMod.buildSteps(table, handIndex);
  if (!round || round.steps.length === 0) {
    host.hidden = false;
    host.innerHTML = `<div class="replay-empty">No authoritative gameplay updates were captured for this round.</div>`;
    handItem.setAttribute("aria-expanded", "true");
    return;
  }

  const engine = new ReplayMod.Replay(round);
  const peekCache = new Map();
  for (const [sid, cards] of engine.revealed) {
    peekCache.set(Number(sid), cards);
  }

  // Clone the template into the host.
  host.innerHTML = "";
  const fragment = els.replayTemplate.content.cloneNode(true);
  const panel = fragment.querySelector(".replay-inline");
  host.appendChild(fragment);
  host.hidden = false;

  // Headline
  const tableLabel = round.tableName && round.tableName !== `Table ${round.tableId}`
    ? `${round.tableName} · #${round.tableId}`
    : `Table #${round.tableId}`;
  panel.querySelector(".replay-title").textContent = `Round ${handIndex + 1}`;
  panel.querySelector(".replay-subtitle").textContent =
    `${tableLabel} · ${round.steps.length} step${round.steps.length === 1 ? "" : "s"}`;

  // Scrubber bounds.
  const scrub = panel.querySelector(".replay-scrubber");
  scrub.min = "0";
  scrub.max = String(round.steps.length - 1);
  scrub.step = "1";
  scrub.value = "0";

  // Cache + wire up.
  state.replays.set(key, { engine, round, panel, peekCache, tableId, handIndex });
  renderTicks(panel, round);

  // Bind buttons / scrubber.
  panel.querySelector(".replay-prev").addEventListener("click", () => {
    engine.prev();
    renderPanel(key);
  });
  panel.querySelector(".replay-next").addEventListener("click", () => {
    engine.next();
    renderPanel(key);
  });
  // Scrubber free-steps across every individual step. We deliberately do
  // NOT snap to milestones — the user wanted granular control.
  scrub.addEventListener("input", () => {
    engine.goto(Number(scrub.value));
    renderPanel(key);
  });

  // Card-peek delegation (scoped to this panel so multiple replays don't
  // fight over the same click event). Hidden cards always respond:
  //   - peekable    -> flip to reveal then flip back
  //   - non-peekable -> shake to indicate "we never learned this card"
  // We re-fetch the slot from state.replays on each click so we always
  // see the freshest peekCache / boardRevelations (live updates may have
  // grown them since the panel opened).
  panel.addEventListener("click", (e) => {
    const cardWrap = e.target.closest(".card-wrap.hidden");
    if (!cardWrap) return;
    const liveSlot = state.replays.get(key);
    if (!liveSlot) return;
    peekCard(cardWrap, liveSlot);
  });

  // Keyboard nav while focused on the panel.
  panel.addEventListener("keydown", (e) => {
    if (e.key === "ArrowRight") { e.preventDefault(); engine.next(); renderPanel(key); }
    else if (e.key === "ArrowLeft") { e.preventDefault(); engine.prev(); renderPanel(key); }
  });

  // Skip directly to the first frame at-or-after `dealHoleCards`. The
  // user explicitly asked that hole cards already be dealt when the
  // replay first opens; the betting actions start being interesting only
  // after the deal.
  const dealIdx = round.steps.findIndex((s) => s.action === "dealHoleCards");
  engine.goto(dealIdx >= 0 ? dealIdx : 0);
  renderPanel(key);

  handItem.setAttribute("aria-expanded", "true");
}

function closeInlineReplay(handItem) {
  const tableId = handItem.dataset.tableId;
  const handIndex = Number(handItem.dataset.handIndex);
  const key = replayKey(tableId, handIndex);
  state.replays.delete(key);
  const host = handItem.querySelector(".hand-replay");
  if (host) {
    host.innerHTML = "";
    host.hidden = true;
  }
  handItem.setAttribute("aria-expanded", "false");
}

function toggleInlineReplay(handItem) {
  const isOpen = handItem.getAttribute("aria-expanded") === "true";
  if (isOpen) closeInlineReplay(handItem);
  else openInlineReplay(handItem);
}

// Click on a hidden card.
//  - peekable (eventually-revealed) card  -> flip to face, hold 1s, flip back
//  - non-peekable / truly unknown card    -> horizontal shake for ~1s
//
// Identity lookup is driven by data-attrs on the wrap element:
//   data-board-idx -> slot.boardRevelations[i]
//   data-seat-id   -> slot.peekCache.get(seatId)[cardIdx]
//
// We track per-wrapper state inline via dataset.peeking so a second click
// while a flip/shake is in-flight is ignored (prevents desync).
const peekTimers = new WeakMap();

function lookupHiddenFace(wrapEl, slot) {
  if (wrapEl.dataset.boardIdx != null) {
    const i = Number(wrapEl.dataset.boardIdx);
    if (slot.boardRevelations && Number.isFinite(i)) {
      const f = slot.boardRevelations[i];
      return f && f !== "X" ? f : null;
    }
  } else if (wrapEl.dataset.seatId != null) {
    const seatId = Number(wrapEl.dataset.seatId);
    const cardIdx = Number(wrapEl.dataset.cardIdx);
    const cards = slot.peekCache.get(seatId);
    if (cards && Number.isFinite(cardIdx)) {
      const f = cards[cardIdx];
      return f && f !== "X" ? f : null;
    }
  }
  return null;
}

function peekCard(wrapEl, slot) {
  if (wrapEl.dataset.peeking === "1") return;

  const face = lookupHiddenFace(wrapEl, slot);
  if (!face) {
    // Card was never revealed in this round. Shake to acknowledge the
    // click and let the user know there is nothing to show.
    shakeUnknownCard(wrapEl);
    return;
  }
  if (!Cards) return;

  if (peekTimers.has(wrapEl)) {
    clearTimeout(peekTimers.get(wrapEl));
    peekTimers.delete(wrapEl);
  }
  wrapEl.dataset.peeking = "1";

  Cards.flip(wrapEl, face, { peekable: true, hidden: false }).then(() => {
    peekTimers.set(wrapEl, setTimeout(() => {
      // Wrap may have been replaced by a re-render between flips; bail if
      // it is no longer in the DOM (a fresh hidden card replaced it).
      if (!wrapEl.isConnected) {
        peekTimers.delete(wrapEl);
        return;
      }
      Cards.flip(wrapEl, "X", { peekable: true, hidden: true }).then(() => {
        delete wrapEl.dataset.peeking;
        peekTimers.delete(wrapEl);
      });
    }, 1000));
  });
}

// Horizontal shake. ~1 s, several oscillations, decaying amplitude. Uses
// requestAnimationFrame so we don't have to ship a keyframes rule that may
// fight with the in-flight flip transform on the same element.
function shakeUnknownCard(wrapEl) {
  if (wrapEl.dataset.peeking === "1") return;
  wrapEl.dataset.peeking = "1";
  const duration = 950;
  const start = performance.now();
  const amplitude = 8;     // px
  const frequency = 7;     // half-cycles over the duration
  wrapEl.style.transformOrigin = "50% 50%";
  wrapEl.style.willChange = "transform";

  function tick(now) {
    const elapsed = now - start;
    const t = Math.min(1, elapsed / duration);
    // Decaying sine: amplitude * (1 - t) * sin(2 * PI * frequency * t).
    const x = amplitude * (1 - t) * Math.sin(2 * Math.PI * frequency * t);
    wrapEl.style.transform = `translateX(${x.toFixed(2)}px)`;
    if (t < 1) {
      requestAnimationFrame(tick);
    } else {
      wrapEl.style.transform = "";
      wrapEl.style.willChange = "";
      delete wrapEl.dataset.peeking;
    }
  }
  requestAnimationFrame(tick);
}

// --------------------------------------------------------------- wiring

for (const tab of els.navTabs) {
  tab.addEventListener("click", () => {
    const target = tab.dataset.section || "history";
    location.hash = target;
    showSection(target);
  });
}

window.addEventListener("hashchange", () => {
  showSection(readSectionFromHash());
});

els.list.addEventListener("click", (e) => {
  // 1. Tri-state checkbox at any tree level. Let it toggle natively
  //    (the `change` listener below picks the new value up) and stop
  //    propagation so the surrounding row isn't activated.
  const cb = e.target.closest('input[data-sel-level]');
  if (cb) {
    e.stopPropagation();
    return;
  }

  // 2. Click on a hand row toggles its inline replay panel. We check
  //    this BEFORE the session-row handler so clicking a round inside
  //    an expanded session doesn't fall through to "collapse the table".
  const handRow = e.target.closest(".hand-row");
  if (handRow) {
    const handItem = handRow.closest(".hand-item");
    if (handItem) {
      toggleInlineReplay(handItem);
      return;
    }
  }
  const row = e.target.closest(".session-row");
  if (!row) return;
  const li = row.closest(".session");
  toggleExpand(li);
});

// Track checkbox state changes via `change` (rather than `click`) so
// keyboard activation also flows through the same pipe. Cascading is
// handled here for table-level boxes; round-level boxes simply toggle
// their own selection key.
els.list.addEventListener("change", (e) => {
  const cb = e.target.closest('input[data-sel-level]');
  if (!cb) return;
  const level = cb.dataset.selLevel;

  if (level === "hand") {
    const key = cb.dataset.selKey;
    if (!key) return;
    if (cb.checked) state.selected.add(key);
    else state.selected.delete(key);
  } else if (level === "table") {
    // Cascade-down: ticking the table box selects every round under
    // it; unticking deselects every round under it. Indeterminate
    // is a *visual-only* state — clicking it always lands in
    // checked=true (the browser default), which becomes "select all
    // here". That matches the user's "click parent → all children
    // ticked" expectation.
    const tableId = cb.dataset.tableId;
    const table = state.tables.find((t) => t.tableId === tableId);
    if (!table) return;
    const keys = handKeysForTable(table);
    if (cb.checked) for (const k of keys) state.selected.add(k);
    else           for (const k of keys) state.selected.delete(k);
  }

  refreshActionBar();
  syncSelectionDom();
});

els.list.addEventListener("keydown", (e) => {
  if (e.key !== "Enter" && e.key !== " ") return;
  const handRow = e.target.closest(".hand-row");
  if (handRow) {
    const handItem = handRow.closest(".hand-item");
    if (handItem) {
      e.preventDefault();
      toggleInlineReplay(handItem);
      return;
    }
  }
  const row = e.target.closest(".session-row");
  if (!row) return;
  e.preventDefault();
  const li = row.closest(".session");
  toggleExpand(li);
});

if (els.captureToggle) {
  els.captureToggle.addEventListener("change", () => {
    saveSettings({ captureEnabled: els.captureToggle.checked });
  });
}

if (els.flushBtn) {
  els.flushBtn.addEventListener("click", () => {
    manualFlush();
  });
}

if (els.selectAllBtn) {
  els.selectAllBtn.addEventListener("click", () => {
    for (const k of allHandKeys()) state.selected.add(k);
    refreshActionBar();
    syncSelectionDom();
  });
}

if (els.deleteSelectedBtn) {
  els.deleteSelectedBtn.addEventListener("click", () => {
    const n = state.selected.size;
    if (n === 0) return;
    if (!window.confirm(`Delete ${n} round${n === 1 ? "" : "s"}? This cannot be undone.`)) return;
    deleteSelectedHands().catch((err) => console.error("[CASINO] deleteSelectedHands:", err));
  });
}

if (els.exportSelectedBtn) {
  els.exportSelectedBtn.addEventListener("click", () => {
    exportSelectedDump().catch((err) => console.error("[CASINO] exportSelectedDump:", err));
  });
}

if (els.exportReadableBtn) {
  els.exportReadableBtn.addEventListener("click", () => {
    exportSelectedReadable().catch((err) => console.error("[CASINO] exportSelectedReadable:", err));
  });
}

if (els.cancelSelectionBtn) {
  els.cancelSelectionBtn.addEventListener("click", () => {
    state.selected.clear();
    refreshActionBar();
    syncSelectionDom();
  });
}

// ---------------------------------------------------------------- bootstrap

function rebuildUserIndex() {
  if (!Users) return;
  state.userIndex = Users.buildIndex(state.messages || []);
}

async function load() {
  await loadSettings();
  const data = await chrome.storage.local.get([MESSAGES_KEY]);
  state.messages = data[MESSAGES_KEY] || [];
  rebuildTables();
  rebuildUserIndex();
  showSection(readSectionFromHash());
  renderHistory();
  // Reconcile the persisted hand index against the freshly-loaded
  // messages. Errors here shouldn't block the rest of the UI.
  refreshFlushIndex().catch((e) => console.warn("[CASINO] flush refresh:", e));
}

chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== "local") return;

  if (changes[MESSAGES_KEY]) {
    state.messages = changes[MESSAGES_KEY].newValue || [];
    rebuildTables();
    rebuildUserIndex();
    // renderHistory() now does a surgical update that preserves open
    // replay panels — see the surgical branch there. No need to clear
    // state.replays.
    scheduleRender();
    if (state.activeSection === "settings") refreshStorageUsage();
    // Hand boundaries may have shifted (new finishHand, drift on a
    // previously-flushed hand, etc.) — keep the badges + status truthful.
    refreshFlushIndex().catch(() => {});
  }

  if (changes[SETTINGS_KEY]) {
    const next = changes[SETTINGS_KEY].newValue || {};
    const { idleMs: _drop, ...sanitized } = next;
    state.settings = { ...DEFAULT_SETTINGS, ...sanitized };
    applySettingsToUI();
  }

  if (Flush && changes[Flush.HANDS_KEY]) {
    // The SW (or another tab) updated the hand index — mirror it
    // locally so the UI reflects the latest flush state.
    const arr = changes[Flush.HANDS_KEY].newValue;
    if (Array.isArray(arr)) {
      state.flushIndex = new Map(arr.map((r) => [r.handKey, r]));
      renderFlushStatus();
      scheduleRender();
    }
  }
});

load().catch((err) => {
  els.empty.classList.remove("hidden");
  els.empty.querySelector(".empty-title").textContent = "Failed to load history";
  els.empty.querySelector(".empty-sub").textContent = String(err && err.message || err);
});
