/* ===================================================================
   ent-api.jsx — server-backed entitlements for Embers (window.Ent)

   This REPLACES the honour-system entitlements.jsx in the page load
   order. The browser is no longer the authority for admin / paid /
   supporter status — the Cloudflare Worker API is. This module is a
   thin async client over that API plus a small synchronous snapshot
   cache so existing render code (which calls Ent.getTier() etc.
   synchronously) keeps working.

   Trust model: nothing here is a security boundary. The server verifies
   passkeys, enforces single-use email-bound grants, and sniffs/sanitizes
   uploads. A tampered client can lie to ITSELF about the cached snapshot,
   but it cannot mint a valid session, redeem a used code, or push an
   un-sanitized image past the server. Authority lives server-side.

   API base defaults to the dev Worker on :8787; override before this
   script loads with  window.EMBERS_API_BASE = 'https://api.example.com'.
   =================================================================== */
const Ent = (() => {
  const API_BASE = (typeof window !== 'undefined' && window.EMBERS_API_BASE) || 'http://localhost:8787';

  /* Grants are single-use and live for 48h server-side. We keep CODE_TTL_MIN
     (in minutes) so any old arithmetic still resolves to the real window, and
     add a human label for copy. */
  const CODE_TTL_MIN = 48 * 60;            // 2880 minutes = 48 hours
  const CODE_TTL_LABEL = '48 hours';

  /* ----- synchronous snapshot cache (fail-safe: anonymous/free) -----
     Populated by refresh() from GET /session/me. Render code reads these
     synchronously; call Ent.refresh() (returns a promise) to repopulate. */
  let snap = { authenticated: false, tier: 'free', isAdmin: false, canUpload: false, supporter: null };

  const normEmail = (e) => String(e || '').trim().toLowerCase();

  /* JSON fetch helper. Always credentialed (session cookie), fail-closed. */
  async function api(path, { method = 'GET', body, headers } = {}) {
    const opts = { method, credentials: 'include', headers: { ...(headers || {}) } };
    if (body !== undefined) {
      opts.headers['Content-Type'] = 'application/json';
      opts.body = JSON.stringify(body);
    }
    const res = await fetch(API_BASE + path, opts);
    let data = null;
    try { data = await res.json(); } catch {}
    return { ok: res.ok, status: res.status, data: data || {} };
  }

  /* ----- snapshot ----- */
  const applySnapshot = (s) => {
    snap = {
      authenticated: !!s.authenticated,
      tier: s.tier === 'paid' ? 'paid' : 'free',
      isAdmin: !!s.isAdmin,
      canUpload: !!s.canUpload,
      supporter: s.supporter || null,
    };
    return snap;
  };

  /* Re-fetch entitlement state from the server. Fail-safe to anonymous. */
  const refresh = async () => {
    try {
      const { ok, data } = await api('/session/me');
      if (ok) return applySnapshot(data);
    } catch {}
    return applySnapshot({ authenticated: false, tier: 'free', isAdmin: false, canUpload: false, supporter: null });
  };

  /* Synchronous reads of the cached snapshot (drop-in for the old API). */
  const getTier = () => snap.tier;
  const getAdmin = () => snap.isAdmin;
  const getCanUpload = () => snap.canUpload;
  const getSupporter = () => snap.supporter;
  const isAuthenticated = () => snap.authenticated;

  const webauthnReady = () =>
    typeof window !== 'undefined' &&
    window.SimpleWebAuthnBrowser &&
    typeof window.SimpleWebAuthnBrowser.startAuthentication === 'function';

  /* ----- admin / passkey login -----
     Drives a discoverable (resident-key) WebAuthn ceremony against the
     Worker. The OS draws any cross-device QR itself; we do nothing special
     for the phone hybrid transport. Returns { ok, reason }. */
  const adminLoginPasskey = async () => {
    if (!webauthnReady()) return { ok: false, reason: 'unsupported' };
    try {
      const begin = await api('/webauthn/auth/begin', { method: 'POST', body: {} });
      if (!begin.ok) return { ok: false, reason: begin.data.error || 'begin_failed' };
      const { options, ceremonyId } = begin.data;
      const response = await window.SimpleWebAuthnBrowser.startAuthentication({ optionsJSON: options });
      const finish = await api('/webauthn/auth/finish', { method: 'POST', body: { ceremonyId, response } });
      if (!finish.ok) return { ok: false, reason: finish.data.error || 'verify_failed' };
      await refresh();
      return { ok: true, reason: 'ok' };
    } catch (e) {
      // User dismissed the OS prompt, no authenticator, etc.
      return { ok: false, reason: (e && e.name === 'NotAllowedError') ? 'cancelled' : 'error' };
    }
  };

  /* ----- register a passkey -----
     Two modes, both server-gated:
       • Bootstrap (no admin yet): pass { bootstrapSecret } to mint the owner
         as admin. The server self-closes this path after the first admin.
       • Add-device (already signed in): omit the secret; the server binds the
         new passkey to the CURRENT session's account.
     Returns { ok, reason }. */
  const registerPasskey = async ({ email, displayName, bootstrapSecret } = {}) => {
    if (!webauthnReady() || typeof window.SimpleWebAuthnBrowser.startRegistration !== 'function') {
      return { ok: false, reason: 'unsupported' };
    }
    const headers = bootstrapSecret ? { 'X-Bootstrap': bootstrapSecret } : undefined;
    try {
      const begin = await api('/webauthn/register/begin', {
        method: 'POST',
        headers,
        body: { email: normEmail(email), displayName: String(displayName || '').slice(0, 80) },
      });
      if (!begin.ok) return { ok: false, reason: begin.data.error || 'begin_failed' };
      const response = await window.SimpleWebAuthnBrowser.startRegistration({ optionsJSON: begin.data.options });
      const finish = await api('/webauthn/register/finish', {
        method: 'POST',
        headers,
        body: { email: normEmail(email), response },
      });
      if (!finish.ok) return { ok: false, reason: finish.data.error || 'verify_failed' };
      await refresh();
      return { ok: true, reason: 'ok' };
    } catch (e) {
      return { ok: false, reason: (e && e.name === 'NotAllowedError') ? 'cancelled' : 'error' };
    }
  };

  /* ----- supporter grants (admin) -----
     mintGrant → server creates a single-use, email-bound, 48h code.
     Returns { ok, code, email, expiresAt } | { ok:false, reason }. */
  const mintGrant = async (email, displayName) => {
    const { ok, data } = await api('/grants/mint', {
      method: 'POST',
      body: { email: normEmail(email), displayName: String(displayName || '').slice(0, 80) },
    });
    if (!ok) return { ok: false, reason: data.error || 'mint_failed' };
    return { ok: true, code: data.code, email: data.email, expiresAt: data.expiresAt };
  };

  const listGrants = async () => {
    const { ok, data } = await api('/grants');
    return ok && Array.isArray(data.grants) ? data.grants : [];
  };

  const revokeGrant = async (code) => {
    const { ok, data } = await api('/grants/revoke', { method: 'POST', body: { code } });
    return ok ? { ok: true } : { ok: false, reason: data.error || 'revoke_failed' };
  };

  /* ----- redeem (supporter) -----
     Establishes a server-verified supporter session on success and refreshes
     the snapshot. Returns { ok, reason } mirroring the old shape; reason is
     the server error code ('invalid' | 'expired' | 'email_mismatch' |
     'already_redeemed' | 'invalid_email') on failure. */
  const redeem = async (rawCode, email, username) => {
    const code = String(rawCode || '').trim();
    const { ok, data } = await api('/grants/redeem', {
      method: 'POST',
      body: { code, email: normEmail(email), displayName: String(username || '').slice(0, 80) },
    });
    if (!ok) return { ok: false, reason: data.error || 'invalid' };
    await refresh();
    return { ok: true, reason: 'ok' };
  };

  /* ----- gated image upload -----
     Sends the raw file to the server, which sniffs + strips metadata + caps
     + quota-limits, then stores it in R2. Returns an absolute URL the render
     path can use directly (it already handles URL-string images). Throws on
     failure with a message suitable for a toast. */
  const uploadImage = async (file, kind) => {
    if (!file) throw new Error('No file selected.');
    const form = new FormData();
    form.append('file', file);
    form.append('kind', String(kind || 'image').slice(0, 32));
    const res = await fetch(API_BASE + '/assets/upload', {
      method: 'POST',
      credentials: 'include',
      body: form, // do NOT set Content-Type; the browser adds the multipart boundary
    });
    let data = null;
    try { data = await res.json(); } catch {}
    if (!res.ok) {
      const code = (data && data.error) || ('http_' + res.status);
      const msg =
        code === 'too_large' || code === 'quota_bytes' ? 'That image is too large.' :
        code === 'unsupported' ? 'Unsupported image type (use JPEG, PNG, WebP, or GIF).' :
        code === 'quota_rate' ? 'Too many uploads just now — try again in a moment.' :
        code === 'quota_count' ? 'Upload limit reached for this account.' :
        res.status === 401 || res.status === 403 ? 'You need an upload entitlement to add images.' :
        'Upload failed. Please try again.';
      const err = new Error(msg);
      err.code = code;
      throw err;
    }
    // Server returns a relative '/assets/<id>'; make it absolute to the API.
    return API_BASE + (data && data.url ? data.url : '');
  };

  /* ----- logout ----- */
  const logout = async () => {
    try { await api('/webauthn/auth/logout', { method: 'POST', body: {} }); } catch {}
    applySnapshot({ authenticated: false, tier: 'free', isAdmin: false, canUpload: false, supporter: null });
  };

  return {
    // synchronous snapshot reads (drop-in for the old honour-system Ent)
    getTier, getAdmin, getCanUpload, getSupporter, isAuthenticated,
    // server-backed actions
    refresh, adminLoginPasskey, registerPasskey,
    mintGrant, listGrants, revokeGrant, redeem,
    uploadImage, logout,
    // constants / helpers
    CODE_TTL_MIN, CODE_TTL_LABEL, normEmail, API_BASE,
  };
})();

window.Ent = Ent;

/* ===================================================================
   Presence (window.Presence) — ephemeral lobby presence client.

   Backs three things on top of the Worker's /presence/* routes:
     • the live "N DMs running games" counter on the startup page,
     • the no-code waiting room players land in, and
     • DM→player invitations across separate clients.

   This is NOT a security boundary. Authority for each action lives in
   the Worker, gated by bearer tokens it issues at open()/join() time.
   The client just holds those tokens in memory for the session and
   polls. Everything fails soft: a network hiccup never throws into the
   render tree — callers get nulls / empty lists and retry on the next
   poll tick.
   =================================================================== */
const Presence = (() => {
  const API_BASE = (typeof window !== 'undefined' && window.EMBERS_API_BASE) || 'http://localhost:8787';

  async function call(path, body) {
    try {
      const opts = { method: body === undefined ? 'GET' : 'POST', credentials: 'include', headers: {} };
      if (body !== undefined) {
        opts.headers['Content-Type'] = 'application/json';
        opts.body = JSON.stringify(body);
      }
      const res = await fetch(API_BASE + path, opts);
      let data = null;
      try { data = await res.json(); } catch {}
      return { ok: res.ok, status: res.status, data: data || {} };
    } catch {
      return { ok: false, status: 0, data: {} };
    }
  }

  /* ----- DM session state (one room per client) ----- */
  let dm = null; // { roomCode, dmToken }

  /* ----- waiting-player session state ----- */
  let me = null; // { playerId, playerToken, name }

  /* Public lobby counts. Never throws; returns zeros on failure. */
  const stats = async () => {
    const { ok, data } = await call('/presence/stats');
    return ok ? { dmCount: data.dmCount | 0, waitingCount: data.waitingCount | 0 } : { dmCount: 0, waitingCount: 0 };
  };

  /* DM opens a room under their save code. Returns { ok, reason }. */
  const dmOpen = async (roomCode, dmName) => {
    const { ok, status, data } = await call('/presence/dm/open', {
      roomCode: String(roomCode || ''),
      dmName: String(dmName || '').slice(0, 40),
    });
    if (!ok) return { ok: false, reason: data.error || (status === 409 ? 'room_taken' : 'open_failed') };
    dm = { roomCode: data.roomCode, dmToken: data.dmToken };
    return { ok: true, roomCode: data.roomCode };
  };

  const dmHeartbeat = async () => {
    if (!dm) return { ok: false, reason: 'no_room' };
    const { ok, data } = await call('/presence/dm/heartbeat', dm);
    return ok ? { ok: true } : { ok: false, reason: data.error || 'expired' };
  };

  const dmClose = async () => {
    if (!dm) return { ok: true };
    await call('/presence/dm/close', dm);
    dm = null;
    return { ok: true };
  };

  const dmPlayers = async () => {
    if (!dm) return [];
    const { ok, data } = await call('/presence/dm/players', dm);
    return ok && Array.isArray(data.players) ? data.players : [];
  };

  const dmInvite = async (playerId) => {
    if (!dm) return { ok: false, reason: 'no_room' };
    const { ok, data } = await call('/presence/dm/invite', { ...dm, playerId: String(playerId || '') });
    return ok ? { ok: true } : { ok: false, reason: data.error || 'invite_failed' };
  };

  const isDm = () => !!dm;
  const roomCode = () => (dm ? dm.roomCode : null);

  /* Player joins the waiting pool. Returns { ok, reason }. */
  const waitJoin = async (name) => {
    const { ok, data } = await call('/presence/wait/join', { name: String(name || '').slice(0, 40) });
    if (!ok) return { ok: false, reason: data.error || 'join_failed' };
    me = { playerId: data.playerId, playerToken: data.playerToken, name: data.name || 'Guest' };
    return { ok: true };
  };

  /* Heartbeat + check for an invite. Returns { ok, invite } or { ok:false }.
     invite is { roomCode, dmName } | null. On 'expired'/'unauthorized' the
     caller should re-join. */
  const waitPoll = async () => {
    if (!me) return { ok: false, reason: 'not_waiting' };
    const { ok, data } = await call('/presence/wait/poll', { playerId: me.playerId, playerToken: me.playerToken });
    if (!ok) return { ok: false, reason: data.error || 'expired' };
    return { ok: true, invite: data.invite || null };
  };

  const waitLeave = async () => {
    if (!me) return { ok: true };
    await call('/presence/wait/leave', { playerId: me.playerId, playerToken: me.playerToken });
    me = null;
    return { ok: true };
  };

  const isWaiting = () => !!me;

  return {
    stats,
    dmOpen, dmHeartbeat, dmClose, dmPlayers, dmInvite, isDm, roomCode,
    waitJoin, waitPoll, waitLeave, isWaiting,
    API_BASE,
  };
})();

window.Presence = Presence;
