// Host app — orchestrates: room creation, Spotify auth, playlist load,
// device pick, round start/reveal/next via socket + Spotify API.

(() => {
const H = window.HostScreens;
const { Stage, GlobalRisoStyles } = window;
const Sp = window.Spotify;

// Per-host persistence keys
const SAVED_ROOM_KEY = 'hf_host_room';
const SAVED_PLAYLIST_KEY = 'hf_host_recent_playlists';
const SAVED_TRACKS_KEY = 'hf_host_tracks';
const SAVED_USED_KEY = 'hf_host_used';
const SAVED_SONG_KEY = 'hf_host_current_song';
const SAVED_PLAYLIST_NAME_KEY = 'hf_host_playlist_name';
const SAVED_TIMER_KEY = 'hf_host_round_timer';
const SAVED_SCORING_KEY = 'hf_host_scoring_rule';
const SAVED_MY_PLAYLISTS_KEY = 'hf_host_my_playlists'; // bookmarks: pasted playlists the host wants again
const SCORING_RULES = ['closest', 'podium', 'banded'];

// Year-quality tiers across a track list. 'curated' (operator decks) and
// 'gold' (iTunes+MB cross-checked) count as verified; 'silver' is a
// best-effort single-source year; everything else is the raw album date.
function yearTiers(list) {
  const total = (list || []).length;
  const gold = (list || []).filter(t => t.yearStatus === 'gold' || t.yearStatus === 'curated').length;
  const silver = (list || []).filter(t => t.yearStatus === 'silver').length;
  const album = total - gold - silver;
  const pct = total ? Math.round(((gold + silver) / total) * 100) : 0;
  return { gold, silver, album, total, pct };
}

function loadJson(key, fallback) {
  try { return JSON.parse(localStorage.getItem(key) || '') ?? fallback; }
  catch (_) { return fallback; }
}
function saveJson(key, val) {
  try { localStorage.setItem(key, JSON.stringify(val)); } catch (_) {}
}

function App() {
  // Desktop host view (laptop / projector / TV) — wide viewport + fine pointer.
  // Mobile / touch hosts keep the existing phone UI untouched.
  const isDesktopHost = window.useIsDesktopHost ? window.useIsDesktopHost() : false;
  const [config, setConfig] = React.useState(null);     // /api/config
  const [configError, setConfigError] = React.useState(null);
  const [view, setView] = React.useState('start');      // start | spotify | playlist | device | room | playing | reveal | final
  const [token, setToken] = React.useState(Sp.loadStoredToken());
  // null = unknown, true = on FULL_ACCESS_EMAILS allowlist, false = not on it.
  // Drives whether the "SPOTIFY · FULL" mode tile shows on the splash and
  // whether Premium-only affordances appear elsewhere in the host flow.
  const [fullAccess, setFullAccess] = React.useState(null);
  const [authError, setAuthError] = React.useState(null);
  const [authBusy, setAuthBusy] = React.useState(false);
  const [code, setCode] = React.useState(null);
  const [room, setRoom] = React.useState(null);
  const [tracks, setTracks] = React.useState(() => loadJson(SAVED_TRACKS_KEY, []));
  const [usedTrackIds, setUsedTrackIds] = React.useState(() => new Set(loadJson(SAVED_USED_KEY, [])));
  const [currentSong, setCurrentSong] = React.useState(() => loadJson(SAVED_SONG_KEY, null));
  const [playlistError, setPlaylistError] = React.useState(null);
  const [playlistBusy, setPlaylistBusy] = React.useState(false);
  const [recentPlaylists, setRecentPlaylists] = React.useState(loadJson(SAVED_PLAYLIST_KEY, []));
  const [curatedPresets, setCuratedPresets] = React.useState([]);
  const [playlistName, setPlaylistName] = React.useState(() => localStorage.getItem(SAVED_PLAYLIST_NAME_KEY) || '');
  const [roundTimerSec, setRoundTimerSec] = React.useState(() => {
    const v = parseInt(localStorage.getItem(SAVED_TIMER_KEY) || '', 10);
    return Number.isFinite(v) && v > 0 ? v : null;
  });
  const setRoundTimerPersist = (v) => {
    setRoundTimerSec(v);
    if (v) localStorage.setItem(SAVED_TIMER_KEY, String(v));
    else localStorage.removeItem(SAVED_TIMER_KEY);
  };
  const [scoringRule, setScoringRule] = React.useState(() => {
    const v = localStorage.getItem(SAVED_SCORING_KEY);
    return SCORING_RULES.includes(v) ? v : 'closest';
  });
  const setScoringRulePersist = (rule) => {
    if (!SCORING_RULES.includes(rule)) return;
    setScoringRule(rule);
    localStorage.setItem(SAVED_SCORING_KEY, rule);
    // Tell the server immediately so the room reflects it for everyone — the
    // setting also re-sends on reconnect (see socket connect handler).
    socketRef.current?.emit('host:set_scoring', { rule }, () => {});
  };
  const setPlaylistNamePersist = (name) => {
    setPlaylistName(name || '');
    if (name) localStorage.setItem(SAVED_PLAYLIST_NAME_KEY, name);
    else localStorage.removeItem(SAVED_PLAYLIST_NAME_KEY);
  };
  const [myPlaylists, setMyPlaylists] = React.useState([]);
  const [userId, setUserId] = React.useState(null);
  const [loadingMine, setLoadingMine] = React.useState(false);
  // MY PLAYLISTS — local bookmarks for pasted playlists ({ids, name,
  // trackCount, verifiedPct, lastUsed}); device-local by design (v1).
  const [savedPlaylists, setSavedPlaylists] = React.useState(() => loadJson(SAVED_MY_PLAYLISTS_KEY, []));
  // Year-verification job state (bronze → gold). lastLoadedIdsKey ties the
  // running deck back to its bookmark so verifiedPct updates after a job.
  const [verifyState, setVerifyState] = React.useState('idle'); // idle | running | done | failed
  const [verifyProgress, setVerifyProgress] = React.useState({ done: 0, total: 0 });
  const [lastLoadedIdsKey, setLastLoadedIdsKey] = React.useState(null);
  const verifyPollRef = React.useRef(null);
  // Monotonic deck identity — bumped on every deck load. A verify run captures
  // the epoch at start; its poll only applies results while the epoch still
  // matches, so a job for a swapped-away deck can never corrupt the new one.
  const deckEpochRef = React.useRef(0);
  React.useEffect(() => () => clearTimeout(verifyPollRef.current), []);
  // Abandon any in-flight verify poll and start a fresh deck epoch.
  const resetVerifyForNewDeck = () => {
    clearTimeout(verifyPollRef.current);
    verifyPollRef.current = null;
    deckEpochRef.current += 1;
    setVerifyState('idle');
    return deckEpochRef.current;
  };
  const [devices, setDevices] = React.useState([]);
  // 'preview' = free 30s MP3 from Spotify embed (no auth, no Premium, anyone can host).
  // 'spotify' = full songs via Spotify Connect (Premium + dev-app allowlist).
  const [mode, setMode] = React.useState(localStorage.getItem('hf_mode') === 'spotify' ? 'spotify' : 'preview');
  const audioRef = React.useRef(null);
  // Host-as-player state
  const [hostPlays, setHostPlays] = React.useState(localStorage.getItem('hf_host_plays') === 'true');
  const [hostName, setHostName] = React.useState(localStorage.getItem('hf_host_name') || '');
  const [hostGuessYear, setHostGuessYear] = React.useState(1995);
  const [hostLocked, setHostLocked] = React.useState(false);
  const playerSocketRef = React.useRef(null);
  const HOST_PLAYER_ID = React.useMemo(() => {
    let id = localStorage.getItem('hf_host_player_id');
    if (!id) {
      id = 'p_host_' + Math.random().toString(36).slice(2, 8);
      localStorage.setItem('hf_host_player_id', id);
    }
    return id;
  }, []);
  const [pickedDevice, setPickedDevice] = React.useState(localStorage.getItem('hf_device_id') || null);
  const [refreshingDevices, setRefreshingDevices] = React.useState(false);
  const [reveal, setReveal] = React.useState(null);
  const [settingsOpen, setSettingsOpen] = React.useState(false);
  const [showStats, setShowStats] = React.useState(false);
  const [debugOpen, setDebugOpen] = React.useState(false);
  const [leaveConfirmOpen, setLeaveConfirmOpen] = React.useState(false);
  const socketRef = React.useRef(null);
  const tokenRef = React.useRef(token);
  React.useEffect(() => { tokenRef.current = token; }, [token]);

  // Persist game state for mid-game reconnect.
  React.useEffect(() => {
    if (tracks.length) saveJson(SAVED_TRACKS_KEY, tracks);
    else localStorage.removeItem(SAVED_TRACKS_KEY);
  }, [tracks]);
  React.useEffect(() => {
    saveJson(SAVED_USED_KEY, Array.from(usedTrackIds));
  }, [usedTrackIds]);
  React.useEffect(() => {
    if (currentSong) saveJson(SAVED_SONG_KEY, currentSong);
    else localStorage.removeItem(SAVED_SONG_KEY);
  }, [currentSong]);

  // ── Bootstrap: load config + handle auth callback if present ──
  React.useEffect(() => {
    let cancelled = false;
    fetch('/api/config').then(r => r.json()).then(async (cfg) => {
      if (cancelled) return;
      setConfig(cfg);
      if (!cfg.spotifyClientId) {
        setConfigError("Server is missing SPOTIFY_CLIENT_ID. Add it to .env and restart, then retry.");
      }

      const url = new URL(window.location.href);
      const authCode = url.searchParams.get('code');
      const authErr = url.searchParams.get('error');
      if (authErr) {
        setAuthError(authErr);
        url.searchParams.delete('error');
        url.searchParams.delete('state');
        window.history.replaceState({}, '', url.toString());
      }
      if (authCode && cfg.spotifyClientId) {
        try {
          setAuthBusy(true);
          // Verify OAuth state matches what we stored before exchanging the
          // code. Mismatch = CSRF attempt or stale tab; abort without
          // exchanging — the auth code is still safe to discard.
          const expectedState = sessionStorage.getItem('hf_oauth_state');
          const gotState = url.searchParams.get('state');
          sessionStorage.removeItem('hf_oauth_state');
          if (!expectedState || expectedState !== gotState) {
            setAuthError('bad_state');
            url.searchParams.delete('code');
            url.searchParams.delete('state');
            window.history.replaceState({}, '', url.toString());
            setAuthBusy(false);
            return;
          }
          const t = await Sp.exchangeCode(cfg.spotifyClientId, cfg.redirectUri, authCode);
          Sp.storeToken(t);
          setToken(t);
          url.searchParams.delete('code');
          url.searchParams.delete('state');
          window.history.replaceState({}, '', url.toString());
          // If solo (or any other page) initiated the auth, bounce back there.
          const returnTo = localStorage.getItem('hf_post_auth_return');
          if (returnTo) {
            localStorage.removeItem('hf_post_auth_return');
            window.location.href = returnTo;
            return;
          }
          setView('playlist');
        } catch (e) {
          setAuthError(e.message);
        } finally {
          setAuthBusy(false);
        }
      }
    }).catch(e => setConfigError(`Could not load config: ${e.message}`));
    return () => { cancelled = true; };
  }, []);

  // ── Load curated playlist presets (operator-verified years) ──
  React.useEffect(() => {
    fetch('/api/playlists').then(r => r.json()).then((j) => {
      setCuratedPresets(j.presets || []);
    }).catch(() => {});
  }, []);

  // Open a second socket as a "player" when host wants to play too. Joins the same
  // room with a separate playerId so the server scores them like any guest.
  React.useEffect(() => {
    if (!hostPlays || !code || !(hostName && hostName.trim().length >= 2)) {
      if (playerSocketRef.current) {
        playerSocketRef.current.disconnect();
        playerSocketRef.current = null;
      }
      return;
    }
    const sock = io({ transports: ['websocket', 'polling'] });
    playerSocketRef.current = sock;
    sock.on('connect', () => {
      sock.emit('player:join', { code, name: hostName.trim(), playerId: HOST_PLAYER_ID }, () => {});
    });
    return () => {
      sock.disconnect();
      if (playerSocketRef.current === sock) playerSocketRef.current = null;
    };
  }, [hostPlays, code, hostName]);

  // Reset host's per-round guess state on new round.
  React.useEffect(() => {
    if (room?.phase === 'playing') {
      setHostLocked(false);
      setHostGuessYear(1995);
    }
  }, [room?.phase, room?.round]);

  const setHostPlaysPersist = (v) => {
    setHostPlays(v);
    localStorage.setItem('hf_host_plays', String(v));
  };
  const setModePersist = (v) => {
    setMode(v);
    localStorage.setItem('hf_mode', v);
  };
  const setHostNamePersist = (v) => {
    setHostName(v);
    localStorage.setItem('hf_host_name', v);
  };

  const submitHostGuess = (y) => {
    if (!playerSocketRef.current) return;
    setHostLocked(true);
    playerSocketRef.current.emit('player:guess', { year: y }, (resp) => {
      if (!resp?.ok) setHostLocked(false);
    });
  };

  // ── Connect socket ──
  React.useEffect(() => {
    const sock = io({ transports: ['websocket', 'polling'] });
    socketRef.current = sock;

    // Re-attach on every (re)connect so iOS Safari + tab-restore + bfcache
    // don't leave the server thinking this socket is anonymous. Read the
    // code afresh each time — closure capture would freeze a stale value
    // (or null) at mount and stop reattaching once the host actually
    // created a room. Without this, host:start_round fires while
    // `attached.role !== 'host'` server-side → "Failed to start round".
    sock.on('connect', () => {
      const cur = localStorage.getItem(SAVED_ROOM_KEY);
      if (!cur) return;
      sock.emit('host:reattach', { code: cur }, (resp) => {
        if (resp?.ok) {
          setCode(cur);
        } else {
          // Server doesn't know the room any more — wipe local state so
          // we land cleanly on the splash instead of looping retries.
          localStorage.removeItem(SAVED_ROOM_KEY);
          setCode(null);
          setRoom(null);
        }
      });
    });

    sock.on('room:state', (state) => {
      setRoom(state);
    });
    sock.on('round:reveal', (payload) => {
      setReveal(payload);
    });
    return () => sock.disconnect();
  }, []);

  // Drive view from server room phase. After a successful host:reattach the
  // initial view is still 'start' (splash) — drop that into 'room' so the host
  // lands back in the lobby instead of getting stuck on the splash.
  React.useEffect(() => {
    if (!room || !code) return;
    if (room.phase === 'lobby') setView(v => (v === 'playlist' || v === 'device' || v === 'spotify') ? v : 'room');
    else if (room.phase === 'playing') setView('playing');
    else if (room.phase === 'revealed') setView('reveal');
    else if (room.phase === 'ended') setView('final');
    if (room.phase && room.phase !== 'ended') setShowStats(false);
  }, [room?.phase, code]);


  const refresh = async () => {
    if (!tokenRef.current) throw new Error('not_authed');
    return await Sp.refreshToken(config.spotifyClientId, tokenRef.current.refresh).then(t => {
      Sp.storeToken(t); setToken(t); tokenRef.current = t; return t;
    });
  };

  // Resolve allowlist membership once we have a Spotify token. Re-runs only on
  // token change so we don't hammer /me on every render.
  React.useEffect(() => {
    let cancelled = false;
    if (!token) { setFullAccess(null); return; }
    (async () => {
      try {
        const r = await Sp.resolveFullAccess(token, refresh);
        if (!cancelled) setFullAccess(!!r.allowed);
      } catch (_) { if (!cancelled) setFullAccess(false); }
    })();
    return () => { cancelled = true; };
  }, [token]);

  // Non-allowlisted users can't actually use Spotify mode — force them back
  // to preview rather than letting the UI lie.
  React.useEffect(() => {
    if (fullAccess === false && mode === 'spotify') setModePersist('preview');
  }, [fullAccess]);

  // Clamp stored round timer when switching to preview mode — saved 45s/60s/
  // 90s would otherwise outlast Spotify's 30s preview clip.
  React.useEffect(() => {
    if (mode === 'preview' && roundTimerSec && roundTimerSec > 30) {
      setRoundTimerPersist(30);
    }
  }, [mode]);

  // ── Actions ──
  const createRoom = () => {
    if (mode === 'spotify' && !token) { setView('spotify'); return; }
    socketRef.current.emit('host:create', {}, (resp) => {
      if (resp?.ok) {
        setCode(resp.code);
        localStorage.setItem(SAVED_ROOM_KEY, resp.code);
        // Push saved scoring choice into the freshly created room so it
        // takes effect from round 1, not whatever default server picked.
        socketRef.current.emit('host:set_scoring', { rule: scoringRule }, () => {});
        if (tracks.length === 0) setView('playlist');
        else if (mode === 'spotify' && !pickedDevice) setView('device');
        else setView('room');
      }
    });
  };
  const resumeRoom = () => {
    const saved = localStorage.getItem(SAVED_ROOM_KEY);
    if (!saved) return;
    socketRef.current.emit('host:reattach', { code: saved }, (resp) => {
      if (resp?.ok) {
        setCode(saved);
        // Navigate based on whatever phase we already know about (room state may
        // already be set from the auto-reattach on connect). Default to 'room' for
        // lobby/missing-state.
        const phase = room?.phase;
        if (phase === 'playing') setView('playing');
        else if (phase === 'revealed') setView('reveal');
        else if (phase === 'ended') setView('final');
        else setView('room');
      } else {
        localStorage.removeItem(SAVED_ROOM_KEY);
        setAuthError('Saved room is gone — create a new one.');
      }
    });
  };

  const connectSpotify = async () => {
    if (!config?.spotifyClientId) {
      setAuthError('Spotify not configured on server.');
      return;
    }
    setAuthBusy(true);
    try {
      await Sp.startAuth(config.spotifyClientId, config.redirectUri);
    } catch (e) {
      setAuthError(e.message);
      setAuthBusy(false);
    }
  };

  // Server pages /v1/playlists/{id}/tracks via the host's Spotify token, then
  // enriches each track with embed-scraped year + preview URL. Multiple IDs are
  // merged + deduped.
  const loadPlaylist = async (idOrIds) => {
    const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
    setPlaylistBusy(true); setPlaylistError(null);
    try {
      // Token is optional: with one the server pages the full Web API; without
      // one it scrapes the public embed (public playlists only, large lists
      // may cap ~100 tracks). Free-mode hosts can paste a playlist without
      // ever connecting Spotify.
      let access = token?.access;
      if (token && Date.now() >= token.expiresAt - 5000) {
        try { access = (await refresh()).access; } catch (_) { access = null; }
      }
      const responses = await Promise.all(ids.map(async (id) => {
        const r = await fetch(`/api/spotify-playlist?id=${encodeURIComponent(id)}`, {
          headers: access ? { Authorization: `Bearer ${access}` } : {},
        });
        if (!r.ok) {
          const j = await r.json().catch(() => ({}));
          throw new Error(`Couldn't read playlist ${id} (${j.error || r.status}).`);
        }
        return r.json();
      }));

      const seen = new Set();
      const pool = [];
      for (const resp of responses) {
        for (const t of resp.tracks || []) {
          if (seen.has(t.id)) continue;
          seen.add(t.id);
          pool.push(t);
        }
      }
      if (pool.length === 0) throw new Error('No tracks found.');

      const name = ids.length === 1 ? responses[0].name : `${ids.length} playlists · ${pool.length} tracks`;

      setTracks(pool);
      setUsedTrackIds(new Set());
      setPlaylistNamePersist(name);
      resetVerifyForNewDeck();
      const next2 = [
        { ids, name },
        ...recentPlaylists.filter(p => JSON.stringify(p.ids || [p.id]) !== JSON.stringify(ids)),
      ].slice(0, 5);
      setRecentPlaylists(next2);
      saveJson(SAVED_PLAYLIST_KEY, next2);

      // Bookmark under MY PLAYLISTS so the host finds it again next time.
      const idsKey = JSON.stringify(ids);
      setLastLoadedIdsKey(idsKey);
      const bookmark = {
        ids, name,
        trackCount: pool.length,
        verifiedPct: yearTiers(pool).pct,
        lastUsed: Date.now(),
      };
      const nextSaved = [
        bookmark,
        ...savedPlaylists.filter(p => JSON.stringify(p.ids) !== idsKey),
      ].slice(0, 12);
      setSavedPlaylists(nextSaved);
      saveJson(SAVED_MY_PLAYLISTS_KEY, nextSaved);

      if (mode === 'spotify' && !pickedDevice) { setView('device'); refreshDevices(); }
      else if (!code) createRoom();
      else setView('room');
    } catch (e) {
      setPlaylistError(e.message || 'Loading playlist failed.');
    } finally {
      setPlaylistBusy(false);
    }
  };

  // Curated path: tracks with operator-verified years served from
  // data/playlists/<slug>.json — no Spotify Web API hit, year is frozen.
  const loadCuratedPlaylist = async (slug) => {
    setPlaylistBusy(true); setPlaylistError(null);
    try {
      const r = await fetch(`/api/playlists/${encodeURIComponent(slug)}`);
      if (!r.ok) throw new Error(`Couldn't load curated playlist (${r.status}).`);
      const data = await r.json();
      const all = (data.tracks || []).filter(t => Number.isFinite(t.year)).map(t => ({
        id: t.id, uri: t.uri, title: t.title, artist: t.artist, year: t.year,
        previewUrl: t.previewUrl || null,
        yearStatus: 'curated', // operator-verified years — already gold-tier
      }));
      if (!all.length) throw new Error('Curated playlist has no tracks with year metadata.');
      setTracks(all);
      setUsedTrackIds(new Set());
      setPlaylistNamePersist(data.name || slug);
      resetVerifyForNewDeck();
      setLastLoadedIdsKey(null); // curated decks aren't bookmarked
      // Preview mode doesn't need a Spotify device — only Premium/Connect does.
      if (mode === 'spotify' && !pickedDevice) { setView('device'); refreshDevices(); }
      else if (!code) createRoom();
      else setView('room');
    } catch (e) {
      setPlaylistError(e.message);
    } finally {
      setPlaylistBusy(false);
    }
  };

  // Kick off year verification for every album-tier track in the loaded deck,
  // then poll the job until it finishes and fold the verified years back into
  // the deck (and the bookmark's verifiedPct). See server/year-verify.js.
  const startVerify = async () => {
    if (verifyState === 'running') return;
    const ids = tracks
      .filter(t => !t.yearStatus || t.yearStatus === 'album' || t.yearStatus === 'unknown')
      .map(t => t.id);
    if (!ids.length) { setVerifyState('done'); return; }
    // Capture the deck this run belongs to + the bookmark key at start, so a
    // mid-run deck swap can't apply these results to the wrong deck.
    const epoch = deckEpochRef.current;
    const idsKey = lastLoadedIdsKey;
    setVerifyState('running');
    setVerifyProgress({ done: 0, total: ids.length });
    try {
      // No Spotify token needed — verification hits iTunes + MusicBrainz, not
      // Spotify. Works even on a deck restored from a previous session whose
      // token has expired.
      const r = await fetch('/api/playlist-verify', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ids }),
      });
      const j = await r.json().catch(() => ({}));
      if (r.status === 409) {
        // verify_busy: an earlier job (often our own, surviving a page
        // reload) holds the per-IP lock. Wait it out and re-try instead of
        // failing — keeps auto-verify seamless across reloads.
        busyRetriesRef.current += 1;
        if (deckEpochRef.current === epoch && busyRetriesRef.current <= 20) {
          verifyPollRef.current = setTimeout(() => {
            setVerifyState('idle'); // re-enter startVerify cleanly
            startVerify();
          }, 3000);
          return;
        }
        throw new Error('verify_busy');
      }
      busyRetriesRef.current = 0;
      if (!r.ok || !j.ok) throw new Error(j.error || `verify failed (${r.status})`);

      const poll = async () => {
        // Bail if the host swapped decks while we were polling.
        if (deckEpochRef.current !== epoch) return;
        const sr = await fetch(`/api/playlist-verify/${j.jobId}`);
        const s = await sr.json().catch(() => ({}));
        if (!sr.ok) throw new Error(s.error || `status failed (${sr.status})`);
        if (deckEpochRef.current !== epoch) return; // swapped during the await
        setVerifyProgress({ done: s.done || 0, total: s.total || ids.length });
        if (s.status === 'finished') {
          setTracks((cur) => {
            const updated = cur.map(t => s.results && s.results[t.id]
              ? { ...t, year: s.results[t.id].year ?? t.year, yearStatus: s.results[t.id].yearStatus }
              : t);
            // Refresh the bookmark's verified percentage.
            if (idsKey) {
              setSavedPlaylists((prev) => {
                const next = prev.map(p => JSON.stringify(p.ids) === idsKey
                  ? { ...p, verifiedPct: yearTiers(updated).pct }
                  : p);
                saveJson(SAVED_MY_PLAYLISTS_KEY, next);
                return next;
              });
            }
            return updated;
          });
          setVerifyState('done');
          return;
        }
        if (s.status === 'failed') throw new Error(s.error || 'verify failed');
        verifyPollRef.current = setTimeout(() => poll().catch(() => {
          if (deckEpochRef.current === epoch) setVerifyState('failed');
        }), 1200);
      };
      await poll();
    } catch (e) {
      console.warn('verify:', e.message);
      if (deckEpochRef.current === epoch) setVerifyState('failed');
    }
  };

  // AUTO-verify: as soon as a deck with album-tier years lands (fresh paste
  // or restored from localStorage at boot), kick verification off without a
  // tap — the lobby banner flips straight to live progress. Runs once per
  // deck epoch; the VERIFY/RETRY button stays as the manual fallback.
  const autoVerifyEpochRef = React.useRef(-1);
  const busyRetriesRef = React.useRef(0);
  React.useEffect(() => {
    const tiers = yearTiers(tracks);
    if (verifyState === 'idle' && tiers.total > 0 && tiers.album > 0
        && autoVerifyEpochRef.current !== deckEpochRef.current) {
      autoVerifyEpochRef.current = deckEpochRef.current;
      startVerify();
    }
  }, [tracks, verifyState]);

  const reauthSpotify = () => {
    Sp.storeToken(null);
    setToken(null);
    setView('spotify');
  };

  // "Back to home" — drops the host on the splash. Keeps the saved room/tracks
  // in localStorage so they can hit RESUME to come back.
  const goHome = () => {
    stopPreview();
    setSettingsOpen(false);
    setReveal(null);
    setView('start');
  };

  // "New game" — end the room server-side, wipe local game state, return to
  // splash. From there the host can CREATE A ROOM (different playlist /
  // settings) or click "I'M JOINING INSTEAD" to leave the host flow entirely.
  const startFreshGame = () => {
    try { socketRef.current?.emit('host:end', {}, () => {}); } catch {}
    stopPreview();
    setSettingsOpen(false);
    setReveal(null);
    setRoom(null);
    setTracks([]);
    setUsedTrackIds(new Set());
    setCurrentSong(null);
    setPlaylistNamePersist('');
    // Clear the room code too — the view-driver effect runs on every
    // room:state broadcast and would otherwise bounce us back to 'final' the
    // moment the server's phase='ended' state lands (Back to setup looked
    // broken). The guard `if (!room || !code) return` only short-circuits
    // while code is null.
    setCode(null);
    localStorage.removeItem(SAVED_ROOM_KEY);
    setView('start');
  };

  const refreshDevices = async () => {
    setRefreshingDevices(true);
    try {
      const json = await Sp.spApi(token, refresh, '/me/player/devices');
      setDevices(json.devices || []);
      if (!pickedDevice && json.devices?.length === 1) {
        setPickedDevice(json.devices[0].id);
        localStorage.setItem('hf_device_id', json.devices[0].id);
      } else if (!pickedDevice) {
        const active = json.devices?.find(d => d.is_active);
        if (active) {
          setPickedDevice(active.id);
          localStorage.setItem('hf_device_id', active.id);
        }
      }
    } catch (e) {
      setAuthError(`Devices: ${e.message}`);
    } finally {
      setRefreshingDevices(false);
    }
  };

  const pickDevice = (id) => {
    setPickedDevice(id);
    localStorage.setItem('hf_device_id', id);
  };

  const pickRandomTrack = () => {
    let pool = tracks.filter(t => !usedTrackIds.has(t.id));
    if (mode === 'preview') pool = pool.filter(t => !!t.previewUrl);
    if (pool.length === 0) return null;
    return pool[Math.floor(Math.random() * pool.length)];
  };

  // Preview mode — play a 30s MP3 directly via HTML5 audio on the host's phone.
  // No Spotify auth, no Premium, no device picker.
  const playPreview = async (track) => {
    if (!track?.previewUrl) throw new Error('This track has no 30-second preview from Spotify. Skipping…');
    if (!audioRef.current) audioRef.current = new Audio();
    const a = audioRef.current;
    a.src = track.previewUrl;
    a.volume = 1;
    a.currentTime = 0;
    try { await a.play(); }
    catch (e) {
      throw new Error(`Browser blocked audio: ${e.message}. Tap the screen, then try again.`);
    }
  };

  const stopPreview = () => {
    if (audioRef.current) { try { audioRef.current.pause(); } catch (_) {} }
  };

  // Robust playback — verifies a Spotify device is reachable, transfers to it,
  // then plays. Throws with a clear message if nothing works.
  const playOnSpotify = async (uri) => {
    let deviceId = pickedDevice;

    // Verify the picked device still exists; otherwise fall back to whatever's available.
    let availableDevices;
    try {
      const json = await Sp.spApi(token, refresh, '/me/player/devices');
      availableDevices = json?.devices || [];
    } catch (e) {
      throw new Error(`Couldn't list Spotify devices (${e.status || ''}): ${e.message}`);
    }
    if (availableDevices.length === 0) {
      throw new Error("No Spotify device is reachable. Open Spotify on your phone (or a speaker), tap play on any track for a moment, then try again.");
    }
    if (!deviceId || !availableDevices.find(d => d.id === deviceId)) {
      const fresh = availableDevices.find(d => d.is_active) || availableDevices[0];
      deviceId = fresh.id;
      setPickedDevice(deviceId);
      localStorage.setItem('hf_device_id', deviceId);
    }

    // Transfer playback to the chosen device first — this guarantees it's the active target.
    try {
      await Sp.transferPlayback(token, refresh, deviceId, false);
      await new Promise(r => setTimeout(r, 350));
    } catch (e) {
      // Some devices (e.g. iOS Spotify app in background) don't accept transfer when
      // they've been quiet for a while. Surface the error rather than silently failing.
      throw new Error(`Couldn't take over playback on ${availableDevices.find(d=>d.id===deviceId)?.name || 'device'}. Open Spotify and tap play on any song to wake it, then try again. (${e.status || ''} ${e.message})`);
    }

    // Now play the URI on that device.
    try {
      await Sp.playTrack(token, refresh, uri, deviceId);
    } catch (e) {
      // Premium-required errors look like 403 here.
      const lower = (e.body?.error?.message || e.message || '').toLowerCase();
      if (lower.includes('premium')) {
        throw new Error('Playback requires Spotify Premium on the host account.');
      }
      throw new Error(`Couldn't start track: ${e.message} (${e.status || ''})`);
    }
  };

  const advanceRound = (t) => {
    setUsedTrackIds(s => new Set([...s, t.id]));
    setCurrentSong(t);
    setReveal(null);
    const sock = socketRef.current;
    if (!sock) { setAuthError('Not connected.'); return; }
    const send = () => sock.emit('host:start_round',
      { song: t, timerSec: roundTimerSec || null },
      (resp) => {
        if (resp?.ok) return;
        // Most "Failed to start round" cases are a stale host attachment
        // after a reconnect — server's `attached.role !== 'host'` for this
        // socket. Re-attach and retry once before surfacing an error.
        if (!code) { setAuthError('Failed to start round.'); return; }
        sock.emit('host:reattach', { code }, (rr) => {
          if (!rr?.ok) { setAuthError('Failed to start round.'); return; }
          sock.emit('host:set_scoring', { rule: scoringRule }, () => {});
          sock.emit('host:start_round',
            { song: t, timerSec: roundTimerSec || null },
            (resp2) => { if (!resp2?.ok) setAuthError('Failed to start round.'); }
          );
        });
      });
    send();
  };

  const startRound = async () => {
    setAuthError(null);
    const t = pickRandomTrack();
    if (!t) {
      setAuthError(mode === 'preview'
        ? 'Out of tracks with previews. Try a different playlist or switch to Spotify Premium mode.'
        : 'Out of fresh tracks. End game or load a different playlist.');
      return;
    }

    // Preview mode — audio.play() MUST run in the same JS tick as the user tap so
    // iOS Safari treats it as user-initiated. No awaits between tap and play().
    if (mode === 'preview') {
      if (!t.previewUrl) { setAuthError('That track has no preview from Spotify. Try Start again.'); return; }
      if (!audioRef.current) audioRef.current = new Audio();
      const a = audioRef.current;
      a.src = t.previewUrl;
      a.currentTime = 0;
      a.volume = 1;
      const p = a.play();
      Promise.resolve(p).then(() => {
        advanceRound(t);
      }).catch((e) => {
        setAuthError(`Browser blocked audio: ${e.message}. Tap the screen once, then try again.`);
      });
      return;
    }

    // Spotify Connect mode (full songs)
    try { await playOnSpotify(t.uri); }
    catch (e) { setAuthError(e.message || 'Playback failed.'); return; }
    advanceRound(t);
  };

  // Replay the current round's track — useful if audio dropped mid-round.
  const replayCurrent = async () => {
    if (!currentSong) return;
    setAuthError(null);
    try {
      if (mode === 'preview') await playPreview(currentSong);
      else await playOnSpotify(currentSong.uri);
    } catch (e) { setAuthError(e.message); }
  };

  const reveal_ = () => {
    if (mode === 'preview') stopPreview();
    const sock = socketRef.current;
    sock.emit('host:reveal', {}, (resp) => {
      if (resp?.ok) return;
      // Same stale-attachment recovery as advanceRound — re-attach and
      // retry once before complaining.
      if (!code) { setAuthError('Failed to reveal.'); return; }
      sock.emit('host:reattach', { code }, (rr) => {
        if (!rr?.ok) { setAuthError('Failed to reveal.'); return; }
        sock.emit('host:reveal', {}, (resp2) => {
          if (!resp2?.ok) setAuthError('Failed to reveal.');
        });
      });
    });
  };

  const nextRound = async () => {
    setReveal(null);
    await startRound();
  };
  const endGame = () => {
    socketRef.current.emit('host:end', {}, () => {});
  };
  const playAgain = () => {
    socketRef.current.emit('host:reset', {}, () => {
      setUsedTrackIds(new Set());
      setCurrentSong(null);
      setReveal(null);
      setView('room');
    });
  };

  // ── Render ──
  if (!config) return <Stage><GlobalRisoStyles /><Loading text="Loading…" /></Stage>;

  if (authBusy && !token) return <Stage><GlobalRisoStyles /><Loading text="Connecting Spotify…" /></Stage>;

  // Global error banner — shown above any view when authError is set.
  const errorBanner = authError ? (
    <div style={{
      position: 'fixed', left: 0, right: 0, top: 0, zIndex: 1000,
      padding: '12px 16px',
      background: window.R.pink, color: window.R.paper,
      fontSize: 12, fontWeight: 700, lineHeight: 1.45,
      borderBottom: `2px solid ${window.R.ink}`,
      display: 'flex', gap: 10, alignItems: 'flex-start',
    }}>
      <div style={{ flex: 1 }}>{authError}</div>
      <span onClick={() => setAuthError(null)} style={{
        cursor: 'pointer', fontWeight: 900, fontSize: 14, padding: '0 6px',
      }}>×</span>
    </div>
  ) : null;

  const savedRoom = localStorage.getItem(SAVED_ROOM_KEY);
  const joinUrl = `${config.publicUrl}/?code=${code || ''}`;
  const players = room?.players || [];

  let body = null;
  if (view === 'start') {
    body = <H.HostStart
      onCreate={createRoom}
      onResume={savedRoom ? resumeRoom : null}
      savedRoom={savedRoom}
      configError={configError}
      hostPlays={hostPlays}
      setHostPlays={setHostPlaysPersist}
      hostName={hostName}
      setHostName={setHostNamePersist}
      mode={mode}
      setMode={setModePersist}
      fullAccess={fullAccess}
    />;
  } else if (view === 'spotify') {
    body = <H.HostSpotify onConnect={connectSpotify} busy={authBusy} error={authError || configError} />;
  } else if (view === 'playlist') {
    body = <H.HostPlaylist
      onPick={loadPlaylist}
      onPickCurated={loadCuratedPlaylist}
      curatedPresets={curatedPresets}
      savedPlaylists={savedPlaylists}
      busy={playlistBusy}
      error={playlistError}
      onReauth={reauthSpotify}
    />;
  } else if (view === 'device') {
    body = <H.HostDevice
      devices={devices}
      picked={pickedDevice}
      onPick={pickDevice}
      onRefresh={refreshDevices}
      refreshing={refreshingDevices}
      onContinue={() => { if (!code) createRoom(); else setView('room'); }}
    />;
  } else if (view === 'room') {
    const previewableTracks = tracks.filter(t => !!t.previewUrl);
    const previewableUsed = previewableTracks.filter(t => usedTrackIds.has(t.id)).length;
    body = <H.HostRoom
      code={code}
      players={players}
      joinUrl={joinUrl}
      trackPoolCount={tracks.length}
      usedCount={usedTrackIds.size}
      previewablePoolCount={previewableTracks.length}
      previewableUsedCount={previewableUsed}
      mode={mode}
      pickedDevice={pickedDevice}
      devices={devices}
      onChangeDevice={() => setView('device')}
      onStart={startRound}
      onEnd={endGame}
      playlistName={playlistName}
      onChangePlaylist={() => setView('playlist')}
      roundTimerSec={roundTimerSec}
      onChangeRoundTimer={setRoundTimerPersist}
      scoringRule={scoringRule}
      onChangeScoringRule={setScoringRulePersist}
      yearTiers={yearTiers(tracks)}
      verifyState={verifyState}
      verifyProgress={verifyProgress}
      onVerifyYears={startVerify}
    />;
  } else if (view === 'playing') {
    body = hostPlays ? (
      <H.HostPlayingAsPlayer
        round={room?.round || 1}
        year={hostGuessYear}
        setYear={setHostGuessYear}
        locked={hostLocked}
        onLock={submitHostGuess}
        onReveal={reveal_}
        onReplay={replayCurrent}
        players={players}
        roundDeadline={room?.roundDeadline}
      />
    ) : (
      <H.HostPlaying
        round={room?.round || 1}
        song={currentSong}
        players={players}
        onReveal={reveal_}
        onReplay={replayCurrent}
        roundDeadline={room?.roundDeadline}
      />
    );
  } else if (view === 'reveal') {
    const guesses = reveal?.guesses || [];
    const song = reveal?.song || currentSong;
    body = <H.HostRevealed
      song={song}
      guesses={guesses}
      players={players}
      round={room?.round || 1}
      myPid={hostPlays ? HOST_PLAYER_ID : null}
      onNext={nextRound}
      onEnd={endGame}
    />;
  } else if (view === 'final') {
    if (showStats) {
      body = <window.FunStats history={room?.history || []} onBack={() => setShowStats(false)} onNewGame={playAgain} />;
    } else {
      body = <H.HostFinal players={players} onAgain={playAgain} onSeeStats={() => setShowStats(true)} onNewGame={startFreshGame} />;
    }
  } else {
    body = <Loading text="Hmm." />;
  }

  // Show the settings (⋯) menu once a room exists. The previous `token`
  // requirement broke Spotify-Free hosts (preview mode has no token) — they
  // got no ⋯ at all and no way to switch playlists, end the game, etc.
  const showSettingsBtn = !!code && view !== 'spotify' && view !== 'start';
  const resyncHost = () => {
    const sock = socketRef.current;
    if (!sock || !code) return;
    sock.emit('host:reattach', { code }, () => {});
  };

  const settingsOverlay = showSettingsBtn && settingsOpen ? (
    <H.SettingsSheet
      onClose={() => setSettingsOpen(false)}
      isPreviewMode={mode === 'preview'}
      phase={room?.phase}
      devices={devices}
      pickedDevice={pickedDevice}
      onPickDevice={(id) => { pickDevice(id); }}
      onRefreshDevices={refreshDevices}
      refreshingDevices={refreshingDevices}
      onChangeDevice={() => { setSettingsOpen(false); setView('device'); }}
      onChangePlaylist={() => { setSettingsOpen(false); setView('playlist'); }}
      onReauth={() => { setSettingsOpen(false); reauthSpotify(); }}
      onEndGame={() => { setSettingsOpen(false); setLeaveConfirmOpen(true); }}
      onNewGame={() => { setSettingsOpen(false); startFreshGame(); }}
      onJoinInstead={() => { startFreshGame(); window.location.href = '/'; }}
      onShowState={() => { setSettingsOpen(false); setDebugOpen(true); }}
      onResync={() => { setSettingsOpen(false); resyncHost(); }}
      onResetScores={() => {
        setSettingsOpen(false);
        socketRef.current.emit('host:reset', {}, () => {
          setUsedTrackIds(new Set());
          setReveal(null);
          setView('room');
        });
      }}
    />
  ) : null;

  const leaveConfirm = leaveConfirmOpen ? (
    <div
      style={{
        position: 'fixed', inset: 0, zIndex: 1200,
        background: 'rgba(26,26,26,.55)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}
      onClick={() => setLeaveConfirmOpen(false)}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          width: '90%', maxWidth: 360,
          background: window.R.paper, border: `2px solid ${window.R.ink}`,
          boxShadow: `4px 4px 0 ${window.R.ink}`,
          padding: '18px 18px 16px',
          display: 'flex', flexDirection: 'column', gap: 14,
        }}
      >
        <div style={{ fontSize: 16, fontWeight: 900, letterSpacing: '.04em' }}>End game?</div>
        <div style={{ fontSize: 13, color: window.R.muted, lineHeight: 1.4 }}>
          The room will close for everyone. Sure you want to end?
        </div>
        <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
          <window.Btn big={false} bg={window.R.paper2} onClick={() => setLeaveConfirmOpen(false)}>CANCEL</window.Btn>
          <window.Btn big={false} bg={window.R.pink} fg={window.R.paper} onClick={() => { setLeaveConfirmOpen(false); endGame(); }}>END GAME</window.Btn>
        </div>
      </div>
    </div>
  ) : null;

  const debugOverlay = debugOpen ? (
    <div style={{
      position: 'fixed', inset: 0, zIndex: 1100,
      background: 'rgba(26,26,26,.55)',
      display: 'flex', alignItems: 'flex-end',
    }} onClick={() => setDebugOpen(false)}>
      <div onClick={(e) => e.stopPropagation()} style={{
        width: '100%', maxWidth: 460, margin: '0 auto',
        background: window.R.paper, border: `2px solid ${window.R.ink}`,
        padding: '14px 18px calc(env(safe-area-inset-bottom, 0px) + 18px)',
        display: 'flex', flexDirection: 'column', gap: 10,
        fontFamily: 'ui-monospace, monospace', fontSize: 11, lineHeight: 1.5,
      }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <b style={{ fontFamily: 'inherit' }}>STATE · HOST</b>
          <span onClick={() => setDebugOpen(false)} style={{ cursor: 'pointer', fontSize: 16, padding: '0 8px' }}>×</span>
        </div>
        <div>view: <b>{view}</b></div>
        <div>socket: <b>{socketRef.current?.connected ? 'connected' : 'disconnected'}</b></div>
        <div>code: <b>{code || '—'}</b></div>
        <div>mode: <b>{mode}</b></div>
        <div>spotify token: <b>{token ? 'yes' : 'no'}</b></div>
        <div>device: <b>{pickedDevice || '—'}</b></div>
        <div>tracks: <b>{tracks.length}</b> (used {usedTrackIds.size})</div>
        <div>currentSong: <b>{currentSong ? `${currentSong.title} (${currentSong.year})` : '—'}</b></div>
        <div>room.phase: <b>{room?.phase || '—'}</b></div>
        <div>room.round: <b>{room?.round ?? '—'}</b></div>
        <div>players: <b>{players.length}</b> · locked: <b>{players.filter(p => p.locked).length}</b></div>
        <div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
          <window.Btn big={false} bg={window.R.green} onClick={() => { resyncHost(); setDebugOpen(false); }}>↻ RE-SYNC</window.Btn>
          <window.Btn big={false} bg={window.R.pink} fg={window.R.paper} onClick={() => { goHome(); setDebugOpen(false); }}>HOME</window.Btn>
        </div>
      </div>
    </div>
  ) : null;
  const settingsButton = showSettingsBtn ? (
    <div onClick={() => setSettingsOpen(true)} style={{
      position: 'fixed', top: 'calc(env(safe-area-inset-top, 0px) + 60px)', right: 10,
      width: 38, height: 38, zIndex: 900,
      background: window.R.paper, border: `2px solid ${window.R.ink}`,
      boxShadow: `2px 2px 0 ${window.R.ink}`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      cursor: 'pointer', fontSize: 16, fontWeight: 900, lineHeight: 1,
    }} title="Settings">⋯</div>
  ) : null;

  if (isDesktopHost && window.DesktopHost) {
    const desktopBody = <window.DesktopHost
      view={view}
      room={room}
      code={code}
      joinUrl={joinUrl}
      players={players}
      currentSong={currentSong}
      reveal={reveal}
      savedRoom={savedRoom}
      configError={configError}
      mode={mode}
      setMode={setModePersist}
      fullAccess={fullAccess}
      hostPlays={hostPlays}
      setHostPlays={setHostPlaysPersist}
      hostName={hostName}
      setHostName={setHostNamePersist}
      onCreate={createRoom}
      onResume={resumeRoom}
      onConnect={connectSpotify}
      authBusy={authBusy}
      authError={authError}
      onPick={loadPlaylist}
      onPickCurated={loadCuratedPlaylist}
      curatedPresets={curatedPresets}
      savedPlaylists={savedPlaylists}
      playlistBusy={playlistBusy}
      playlistError={playlistError}
      onReauth={reauthSpotify}
      devices={devices}
      pickedDevice={pickedDevice}
      onPickDevice={pickDevice}
      onRefreshDevices={refreshDevices}
      refreshingDevices={refreshingDevices}
      onDeviceContinue={() => { if (!code) createRoom(); else setView('room'); }}
      onStart={startRound}
      onChangePlaylist={() => setView('playlist')}
      playlistName={playlistName}
      scoringRule={scoringRule}
      onChangeScoringRule={setScoringRulePersist}
      roundTimerSec={roundTimerSec}
      onChangeRoundTimer={setRoundTimerPersist}
      onReveal={reveal_}
      onReplay={replayCurrent}
      onNext={nextRound}
      onEnd={endGame}
      onAgain={playAgain}
      onNewGame={startFreshGame}
      myPid={hostPlays ? HOST_PLAYER_ID : null}
    />;
    return (
      <div style={{ minHeight: '100dvh', background: window.R.paper, color: window.R.ink, fontFamily: window.R.font }}>
        <GlobalRisoStyles />
        {errorBanner}
        {desktopBody}
        {settingsButton}
        {settingsOverlay}
        {debugOverlay}
        {leaveConfirm}
      </div>
    );
  }

  return <Stage><GlobalRisoStyles />{errorBanner}{body}{settingsButton}{settingsOverlay}{debugOverlay}{leaveConfirm}</Stage>;
}

function Loading({ text = 'Loading…' }) {
  return (
    <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: window.R.muted, fontWeight: 700 }}>
      {text}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
})();
