/* ===================================================================
   tablesync.jsx — window.TableSync, the realtime shared-table client.

   One WebSocket per client to the room's TableSync Durable Object
   (server/src/durable/tableSync.ts), reached through the Worker route
   /sync/:roomCode. The DO is the single source of truth and enforces
   authority + per-recipient visibility; this client is a thin transport.

   Sync model (matches the DO):
     • The DM is snapshot-authoritative — it pushes the full table doc
       (app.jsx buildSnapshot) via {t:'snapshot'}; the DO re-projects it
       per recipient so players only ever see within their character.
       The DM ignores inbound snapshots (it authors them).
     • Players emit only self-scoped actions (token.move, chat.add, throw,
       bolt); the DO authorizes + folds + fans them out. Players apply the
       DM's projected snapshots and peer actions.

   This is NOT a security boundary. Authority lives in the DO/Worker. A
   tampered client can only lie to itself; it cannot forge a DM role (the
   trusted X-Embers-Role header is set server-side after auth) nor steal a
   character seat (per-character seat tokens, first-come, hashed in the DO).

   Fails soft: every callback is wrapped; a dropped socket auto-reconnects
   with backoff and the local table keeps working offline regardless.
   =================================================================== */
const TableSync = (() => {
  const API_BASE = (typeof window !== 'undefined' && window.EMBERS_API_BASE) || 'http://localhost:8787';
  // ws(s):// derived from the http(s) API base.
  const WS_BASE = API_BASE.replace(/^http/i, 'ws');

  const SEAT_PREFIX = 'embers:seat:'; // per-character seat token, keyed roomCode:charRef

  const PING_MS = 25000;       // keepalive so idle hibernation doesn't drop us
  const BACKOFF_MIN = 1000;    // reconnect backoff floor
  const BACKOFF_MAX = 15000;   // …and ceiling

  /* Single live connection per client (one table at a time, like Presence). */
  let conn = null; // { ws, role, roomCode, dmToken, charRef, handlers, closed, backoff, pingTimer, retryTimer }

  const seatKey = (roomCode, charRef) => SEAT_PREFIX + roomCode + ':' + charRef;
  const loadSeat = (roomCode, charRef) => {
    try { return localStorage.getItem(seatKey(roomCode, charRef)) || null; } catch { return null; }
  };
  const saveSeat = (roomCode, charRef, token) => {
    try { if (token) localStorage.setItem(seatKey(roomCode, charRef), token); } catch {}
  };

  const safe = (fn, ...args) => { try { if (fn) fn(...args); } catch (e) { /* never throw into render */ } };

  const isActive = () => !!(conn && conn.ws && conn.ws.readyState === 1 && !conn.closed);

  /* Send a raw frame if the socket is open; silently drop otherwise (the next
     snapshot / reconnect reconciles state, so a lost frame is never fatal). */
  const sendRaw = (obj) => {
    if (conn && conn.ws && conn.ws.readyState === 1) {
      try { conn.ws.send(JSON.stringify(obj)); return true; } catch {}
    }
    return false;
  };

  const emit = (action) => { if (action && action.type) sendRaw({ t: 'action', action }); };
  const pushSnapshot = (snapshot) => { if (snapshot) sendRaw({ t: 'snapshot', snapshot }); };

  const clearTimers = (c) => {
    if (!c) return;
    if (c.pingTimer) { clearInterval(c.pingTimer); c.pingTimer = null; }
    if (c.retryTimer) { clearTimeout(c.retryTimer); c.retryTimer = null; }
  };

  /* Build the upgrade URL. The WS upgrade is a GET, so it slips past the
     POST-only origin guard; the route re-checks Origin itself. */
  const urlFor = (c) => {
    const q = new URLSearchParams();
    q.set('role', c.role);
    if (c.role === 'dm' && c.dmToken) q.set('dmToken', c.dmToken);
    return `${WS_BASE}/sync/${encodeURIComponent(c.roomCode)}?${q.toString()}`;
  };

  const scheduleReconnect = (c) => {
    if (c.closed) return;
    clearTimers(c);
    const delay = c.backoff;
    c.backoff = Math.min(BACKOFF_MAX, Math.round(c.backoff * 1.7));
    c.retryTimer = setTimeout(() => { if (!c.closed) dial(c); }, delay);
    safe(c.handlers.onStatus, 'reconnecting');
  };

  const onMessage = (c, ev) => {
    let msg = null;
    try { msg = JSON.parse(String(ev.data)); } catch { return; }
    if (!msg || typeof msg.t !== 'string') return;
    switch (msg.t) {
      case 'welcome':
        if (msg.charRef && msg.seatToken) saveSeat(c.roomCode, msg.charRef, msg.seatToken);
        safe(c.handlers.onWelcome, msg);
        break;
      case 'snapshot':
        safe(c.handlers.onSnapshot, msg.snapshot);
        break;
      case 'action':
        safe(c.handlers.onAction, msg.action);
        break;
      case 'error':
        safe(c.handlers.onError, msg.code);
        break;
      case 'pong':
      default:
        break;
    }
  };

  const dial = (c) => {
    if (c.closed) return;
    let ws;
    try { ws = new WebSocket(urlFor(c)); } catch { return scheduleReconnect(c); }
    c.ws = ws;

    ws.addEventListener('open', () => {
      if (c.closed) { try { ws.close(); } catch {} return; }
      c.backoff = BACKOFF_MIN; // healthy connection resets the backoff
      // Identify: a player claims/resumes its seat; the DM just says hello.
      const hello = { t: 'hello' };
      if (c.role === 'player' && c.charRef) {
        hello.charRef = c.charRef;
        const seat = loadSeat(c.roomCode, c.charRef);
        if (seat) hello.seatToken = seat;
      }
      sendRaw(hello);
      c.pingTimer = setInterval(() => sendRaw({ t: 'ping' }), PING_MS);
      safe(c.handlers.onStatus, 'open');
    });

    ws.addEventListener('message', (ev) => onMessage(c, ev));

    ws.addEventListener('close', () => {
      clearTimers(c);
      if (!c.closed) scheduleReconnect(c);
      else safe(c.handlers.onStatus, 'closed');
    });

    ws.addEventListener('error', () => { try { ws.close(); } catch {} });
  };

  /* Open (or re-open) the single connection. Tears down any previous one.
       open({ role:'dm'|'player', roomCode, dmToken?, charRef?, handlers }) */
  const open = (opts) => {
    close();
    const o = opts || {};
    if (!o.roomCode || (o.role !== 'dm' && o.role !== 'player')) return;
    const c = {
      ws: null,
      role: o.role,
      roomCode: String(o.roomCode),
      dmToken: o.dmToken || null,
      charRef: o.charRef || null,
      handlers: o.handlers || {},
      closed: false,
      backoff: BACKOFF_MIN,
      pingTimer: null,
      retryTimer: null,
    };
    conn = c;
    dial(c);
  };

  const close = () => {
    if (!conn) return;
    conn.closed = true;
    clearTimers(conn);
    if (conn.ws) { try { conn.ws.close(); } catch {} }
    conn = null;
  };

  return { open, close, emit, pushSnapshot, isActive, API_BASE, WS_BASE };
})();

window.TableSync = TableSync;
