/* Party networking — one socket, one room view.
 *
 * The server owns every scrap of game state. This side sends intents and
 * renders whatever comes back, which is why there is no game logic here and
 * no optimistic updates: a phone that guesses at the outcome of a tap is a
 * phone that shows the wrong score for 200ms on a bad connection.
 *
 * Two message rates share the socket and must not be treated alike:
 *
 *   state  a few times a round. Drives React. Cheap.
 *   snap   20 a second while a controller game runs. This must NEVER reach
 *          React — re-rendering a component tree 20 times a second to move
 *          sprites would drop frames on the phones that need them most. It
 *          goes straight to the canvas via a subscriber, bypassing state
 *          entirely. See party-arena.jsx.
 */
(function () {

const store = {
  get(k, d) { try { const v = localStorage.getItem('mm.' + k); return v === null ? d : JSON.parse(v); } catch { return d; } },
  set(k, v) { try { localStorage.setItem('mm.' + k, JSON.stringify(v)); } catch { /* private mode */ } },
};

function uuid() {
  if (crypto.randomUUID) return crypto.randomUUID();
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = Math.random() * 16 | 0;
    return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
  });
}

const myPid = () => store.get('pid', null) || (() => { const id = uuid(); store.set('pid', id); return id; })();

/* The API is a different origin in production (Vercel front, Render API), so
 * the socket cannot be derived from location — it follows the configured API
 * base, falling back to this origin for local single-host runs. */
function wsUrl() {
  const base = window.PH365_API_BASE || '';
  if (!base) return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`;
  return base.replace(/^http/, 'ws').replace(/\/+$/, '') + '/ws';
}

const api = async (path, opts = {}) => {
  const res = await fetch((window.PH365_API_BASE || '') + path, {
    headers: { 'Content-Type': 'application/json' },
    ...opts,
  });
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
  return body;
};
window.PartyApi = api;

/* Snapshot subscribers — the canvas, and nothing else. */
const snapSubs = new Set();
window.PartyOnSnapshot = (fn) => { snapSubs.add(fn); return () => snapSubs.delete(fn); };

window.useParty = function useParty() {
  const [room, setRoom] = React.useState(null);
  const [conn, setConn] = React.useState('idle');     // idle | connecting | live | lost
  const [err, setErr] = React.useState('');
  const [profile, setProfile] = React.useState(null);
  const [xp, setXp] = React.useState(null);
  const [me, setMe] = React.useState(() => ({
    pid: myPid(),
    name: store.get('name', ''),
    emoji: store.get('emoji', ''),
  }));

  const ws = React.useRef(null);
  const want = React.useRef(null);       // { code, role }
  const tries = React.useRef(0);
  const timer = React.useRef(null);
  const skew = React.useRef(0);
  const demoted = React.useRef(false);

  const nowServer = React.useCallback(() => Date.now() + skew.current, []);

  const open = React.useCallback(() => {
    if (!want.current) return;
    if (ws.current && (ws.current.readyState === 0 || ws.current.readyState === 1)) return;
    clearTimeout(timer.current);

    /* Detach before closing. A closed socket still fires onclose, and that
     * handler schedules a reconnect — so a socket replaced while open would
     * come back from the dead and close its own replacement, forever. */
    if (ws.current) {
      ws.current.onopen = ws.current.onmessage = ws.current.onerror = ws.current.onclose = null;
      try { ws.current.close(); } catch { /* already closing */ }
      ws.current = null;
    }

    setConn('connecting');
    const sock = new WebSocket(wsUrl());
    ws.current = sock;

    sock.onopen = () => {
      tries.current = 0;
      sock.send(JSON.stringify({
        t: 'hello',
        code: want.current.code,
        role: want.current.role,
        pid: me.pid,
        /* Store first, state second. saveMe() is a setState, so a screen that
           takes a name and joins in the same gesture — which is what a scanned
           code does — would otherwise hand over the previous name, or none at
           all for a first-time guest. The store is written synchronously and
           is what persists anyway. */
        name: store.get('name', '') || me.name,
        emoji: store.get('emoji', '') || me.emoji,
        pin: store.get('pin', ''),
      }));
    };

    sock.onmessage = (ev) => {
      let m;
      try { m = JSON.parse(ev.data); } catch { return; }

      if (m.t === 'error') {
        /* A stale PIN must not lock a guest out of a party they can legitimately
         * join — drop to player and carry on. Once only, never ping-pong. */
        if (/pin/i.test(m.msg || '') && want.current?.role === 'owner' && !demoted.current) {
          demoted.current = true;
          store.set('pin', '');
          want.current = { ...want.current, role: 'player' };
          open();
          return;
        }
        setErr(m.msg);
        if (m.fatal) { want.current = null; setConn('idle'); setRoom(null); }
        return;
      }
      if (m.t === 'joined') {
        setMe(prev => {
          const next = { ...prev, name: m.you.name, emoji: m.you.emoji };
          store.set('name', next.name); store.set('emoji', next.emoji);
          return next;
        });
        if (m.profile) setProfile(m.profile);
        return;
      }
      /* The hot path. 20Hz. Never touches React state. */
      if (m.t === 'snap') { for (const fn of snapSubs) fn(m); return; }
      if (m.t === 'xp') { setXp(m); if (m.after) setProfile(m.after); return; }
      if (m.t === 'state') {
        skew.current = m.now - Date.now();
        setConn('live');
        setErr('');
        setRoom(m);
      }
    };

    sock.onclose = () => {
      if (!want.current) { setConn('idle'); return; }
      setConn('lost');
      /* Back off, but never past 4s — a party cannot wait out a long backoff. */
      const delay = Math.min(4000, 400 * Math.pow(1.6, tries.current++));
      timer.current = setTimeout(open, delay);
    };
    sock.onerror = () => { /* onclose always follows; handled there */ };
  }, [me.pid, me.name, me.emoji]);

  const connect = React.useCallback((code, role) => {
    want.current = { code, role };
    setErr('');
    open();
  }, [open]);

  const disconnect = React.useCallback(() => {
    want.current = null;
    clearTimeout(timer.current);
    if (ws.current) {
      ws.current.onclose = null;
      try { ws.current.close(); } catch { /* already gone */ }
      ws.current = null;
    }
    setRoom(null); setConn('idle'); setXp(null);
  }, []);

  const send = React.useCallback((obj) => {
    if (ws.current && ws.current.readyState === 1) ws.current.send(JSON.stringify(obj));
  }, []);

  React.useEffect(() => () => {
    clearTimeout(timer.current);
    if (ws.current) { ws.current.onclose = null; try { ws.current.close(); } catch { /* gone */ } }
  }, []);

  const saveMe = React.useCallback((name, emoji) => {
    setMe(prev => {
      const next = { ...prev, name: name ?? prev.name, emoji: emoji ?? prev.emoji };
      store.set('name', next.name); store.set('emoji', next.emoji);
      return next;
    });
  }, []);

  const host = React.useCallback(async (mode) => {
    const pin = store.get('pin', '');
    const r = await api('/api/room', {
      method: 'POST',
      body: JSON.stringify({ pin, mode, ownerName: store.get('name', '') || 'Host' }),
    });
    if (!r.code) throw new Error(r.error || 'could not open a lobby');
    connect(r.code, 'owner');
    return r;
  }, [connect]);

  /* Scanning your own ears' QR should land you as the HOST of that
   * room, not as a guest at your own party. The server confirms
   * ownership from the pair PIN before we pick a role. */
  const joinScanned = React.useCallback(async (code) => {
    try {
      const pin = store.get('pin', '');
      if (pin) {
        const r = await api(`/api/room/${code}?pin=${encodeURIComponent(pin)}`);
        if (r && r.owner) {
          connect(code, 'owner');
          return 'owner';
        }
      }
    } catch { /* fall through to the guest prompt */ }
    return 'player';
  }, [connect]);

  return {
    room, conn, err, me, profile, xp, setXp,
    connect, disconnect, send, host, saveMe, nowServer, joinScanned,
    isOwner: !!room?.isOwner,
  };
};

/* A repaint between server messages, for countdowns only. The server sends
 * `endsAt`; without a local tick the number would sit still until the next
 * state message, which in a 30s vote is most of the round. 4Hz is plenty —
 * the arena drives its own frames on the canvas. */
window.usePartyTick = function usePartyTick(active) {
  const [, bump] = React.useState(0);
  React.useEffect(() => {
    if (!active) return undefined;
    const t = setInterval(() => bump(n => n + 1), 250);
    return () => clearInterval(t);
  }, [active]);
};

window.PartyTimerNeeded = function PartyTimerNeeded(v) {
  if (!v) return false;
  if (['pick', 'vote', 'intro', 'scores'].includes(v.phase)) return true;
  if (v.phase === 'play' && v.game) {
    if (v.game.controller) return true;
    return ['ask', 'write', 'vote', 'arm', 'go'].includes(v.game.phase);
  }
  return false;
};

})();
