// Bonfire Cloud — "AI or Not" booth game (/ai-or-not)
//
// Unlisted, chromeless, noindex. Built for a booth LAPTOP in landscape:
// a fixed 16:9 stage that letterboxes to the viewport, nothing scrolls.
//
// Controls — a 2-button macro keyboard mapped to ← and →:
//   quiz         ← = AI, → = NOT AI
//   every other  either button = continue (start / draw / retry)
//   (Enter/Space, A/D, 1/2 also accepted)
//
// Flow
//   login  → booth password (set in admin → Event → AI or Not); sets the
//            bc_aion cookie for 12 h so it's typed once per event day
//   start  → START (+ fullscreen)
//   quiz   → N photos (admin-set), 4:3 frame, per-photo timer (admin-set
//            seconds) shown as a draining bar + flipping hourglass;
//            time-out counts as wrong; rubber-stamp reveal after each
//   score  → the N photos with ✓/✗ badges + "X / N" → Draw your reward
//   reward → server-side weighted draw from the game's OWN prize table
//            (no pity, max_amount enforced) → confetti → Retry
//
// Image deck: 60 bundled photos (assets/games/ai-or-not/manifest.json)
// dealt from two shuffled decks (ai / real) persisted in localStorage,
// so the same laptop never repeats a photo until that deck is used up.

const AION_ASSET_BASE = 'assets/games/ai-or-not/';
const AION_DECK_KEY   = 'bc-aion-deck-v1';
const AION_GENERIC_ERR = 'Something went wrong. Please try again.';

// Keyboard model — see header comment.
function aionIsLeft(e)  { return e.key === 'ArrowLeft'  || e.key === 'a' || e.key === 'A' || e.key === '1'; }
function aionIsRight(e) { return e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D' || e.key === '2'; }
function aionIsGoKey(e) {
  return e.key === 'Enter' || e.key === 'Return' || e.key === ' '
      || e.code === 'Enter' || e.code === 'NumpadEnter' || e.code === 'Space';
}
function aionIsAnyKey(e) { return aionIsGoKey(e) || aionIsLeft(e) || aionIsRight(e); }

// Fire `cb` on any button press. `armMs` ignores keys for a moment after
// the screen mounts so a press meant for the previous screen (or a
// held key repeating) can't skip straight through this one.
function useAionAnyKey(cb, enabled, armMs) {
  React.useEffect(() => {
    if (!enabled) return;
    const armedAt = Date.now() + (armMs === undefined ? 600 : armMs);
    const onKey = (e) => {
      aionSfx.ensure();   // first key press unlocks audio (autoplay policy)
      if (e.repeat || Date.now() < armedAt) return;
      if (aionIsAnyKey(e)) { e.preventDefault(); cb(); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [cb, enabled]);   // eslint-disable-line react-hooks/exhaustive-deps
}

/* ---------------------------------------------------------------
   Audio — everything synthesised with WebAudio: no files to download,
   nothing to cache, works offline. One AudioContext (created on the
   first key/click, per browser autoplay policy) feeding a compressor,
   with two buses:
     sfx   — game sounds, loud (~3x the first cut), compressor keeps
             them from clipping
     music — a looping 104 BPM synth bed on every screen. "calm" (pad +
             arpeggio) on menus, "play" (adds kick / bass / hats) in the
             quiz. Ducks under the bigger SFX. If a licensed track is
             dropped at assets/games/ai-or-not/bgm.mp3 it is used
             instead of the synth loop.
   Both mutes persist in localStorage.
--------------------------------------------------------------- */
const AION_MUTE_KEY  = 'bc-aion-mute';
const AION_MUSIC_KEY = 'bc-aion-music-mute';
const AION_BGM_FILE  = AION_ASSET_BASE + 'bgm.mp3';

const aionAudio = (() => {
  let ctx = null, comp = null, sfxBus = null, musicBus = null;
  let sfxMuted = false, musicMuted = false;
  try { sfxMuted = localStorage.getItem(AION_MUTE_KEY) === '1'; } catch (e) {}
  try { musicMuted = localStorage.getItem(AION_MUSIC_KEY) === '1'; } catch (e) {}
  const MUSIC_LEVEL = 0.34;        // menus
  const QUIZ_FACTOR = 0.5;         // during the quiz the bed slopes down to half
  const SLOPE_S     = 1.6;         // seconds for the up/down glide
  let level = MUSIC_LEVEL;         // current un-muted base level

  const ensure = () => {
    if (ctx) { if (ctx.state === 'suspended') ctx.resume().catch(() => {}); music.start(); return ctx; }
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return null;
    ctx = new AC();
    comp = ctx.createDynamicsCompressor();
    comp.threshold.value = -16; comp.knee.value = 12; comp.ratio.value = 5; comp.attack.value = 0.003; comp.release.value = 0.2;
    comp.connect(ctx.destination);
    sfxBus = ctx.createGain(); sfxBus.gain.value = 1.0; sfxBus.connect(comp);
    musicBus = ctx.createGain(); musicBus.gain.value = musicMuted ? 0 : level; musicBus.connect(comp);
    music.start();
    return ctx;
  };

  // ---- SFX primitives ------------------------------------------------
  const tone = (type, f0, f1, dur, gain, at, bus) => {
    const c = ensure(); if (!c) return;
    const t = c.currentTime + (at || 0);
    const o = c.createOscillator(); const g = c.createGain();
    o.type = type; o.frequency.setValueAtTime(f0, t);
    if (f1 && f1 !== f0) o.frequency.exponentialRampToValueAtTime(f1, t + dur);
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(gain, t + 0.008);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(bus || sfxBus);
    o.start(t); o.stop(t + dur + 0.02);
  };
  const noise = (dur, gain, at, hp, bus) => {
    const c = ensure(); if (!c) return;
    const t = c.currentTime + (at || 0);
    const n = Math.floor(c.sampleRate * dur);
    const buf = c.createBuffer(1, n, c.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
    const s = c.createBufferSource(); s.buffer = buf;
    const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = hp || 1200;
    const g = c.createGain(); g.gain.value = gain;
    s.connect(f); f.connect(g); g.connect(bus || sfxBus);
    s.start(t);
  };
  // Dip the music for a moment so the effect reads clearly (relative to
  // whatever the current base level is — menu or quiz).
  const duck = (depth, hold) => {
    if (!ctx || !musicBus || musicMuted) return;
    const t = ctx.currentTime;
    musicBus.gain.cancelScheduledValues(t);
    musicBus.gain.setTargetAtTime(level * depth, t, 0.015);
    musicBus.gain.setTargetAtTime(level, t + hold, 0.12);
    if (bgmEl) { bgmEl.volume = level * depth; setTimeout(() => { if (bgmEl && !musicMuted) bgmEl.volume = level; }, hold * 1000); }
  };
  // Glide the bed to a new base level over SLOPE_S seconds.
  let bgmRamp = null;
  const glideTo = (target) => {
    level = target;
    const goal = musicMuted ? 0 : target;
    if (musicBus && ctx) {
      const t = ctx.currentTime;
      musicBus.gain.cancelScheduledValues(t);
      musicBus.gain.setValueAtTime(musicBus.gain.value, t);
      musicBus.gain.linearRampToValueAtTime(goal, t + SLOPE_S);
    }
    if (bgmEl) {
      if (bgmRamp) clearInterval(bgmRamp);
      const from = bgmEl.volume, t0 = Date.now();
      bgmRamp = setInterval(() => {
        const k = Math.min(1, (Date.now() - t0) / (SLOPE_S * 1000));
        bgmEl.volume = from + (goal - from) * k;
        if (k >= 1) { clearInterval(bgmRamp); bgmRamp = null; }
      }, 50);
    }
  };

  const sfx = {
    ensure,
    play(name) {
      if (sfxMuted) return;
      switch (name) {
        case 'start':   duck(0.3, 0.4); tone('sine', 320, 960, 0.25, 0.7); noise(0.1, 0.35, 0.02, 2500); break;
        case 'tick':    tone('square', 880, 880, 0.035, 0.22); break;
        case 'tickHot': tone('square', 1320, 1320, 0.06, 0.45); noise(0.02, 0.2, 0, 4000); break;
        case 'correct': duck(0.3, 0.5); tone('sine', 523, 523, 0.12, 0.7); tone('sine', 659, 659, 0.12, 0.7, 0.1); tone('sine', 784, 784, 0.35, 0.8, 0.2); break;
        case 'wrong':   duck(0.3, 0.4); tone('sawtooth', 220, 110, 0.32, 0.55); tone('square', 165, 82, 0.32, 0.25); break;
        case 'timeout': duck(0.3, 0.5); tone('triangle', 240, 160, 0.2, 0.6); tone('triangle', 240, 160, 0.25, 0.6, 0.24); break;
        case 'click':   noise(0.035, 0.45, 0, 3000); tone('sine', 900, 700, 0.05, 0.25); break;
        case 'drum':    noise(0.03, 0.55, 0, 1500); tone('sine', 180, 110, 0.06, 0.4); break;
        case 'win':     duck(0.25, 1.2);
                        [523, 659, 784, 1046].forEach((f, i) => tone('sine', f, f, i === 3 ? 0.8 : 0.18, 0.8, i * 0.13));
                        [523, 659, 784, 1046].forEach((f, i) => tone('triangle', f * 2, f * 2, i === 3 ? 0.8 : 0.18, 0.25, i * 0.13));
                        noise(0.3, 0.45, 0.4, 800); break;
        default: break;
      }
    },
    setMuted(m) { sfxMuted = !!m; try { localStorage.setItem(AION_MUTE_KEY, sfxMuted ? '1' : '0'); } catch (e) {} },
    isMuted() { return sfxMuted; },
  };

  // ---- Music: synth loop or optional bgm.mp3 ---------------------------
  const BPM = 104, BEAT = 60 / BPM, BAR = 4 * BEAT;
  // C major, I–V–vi–IV; semitones above C4 (MIDI 60).
  const CHORDS = [[0, 4, 7], [7, 11, 14], [9, 12, 16], [5, 9, 12]];
  const ARP = [0, 1, 2, 3, 4, 5, 4, 3, 0, 1, 2, 3, 4, 5, 4, 3];   // index into chord tones over 2 octaves
  const hz = (semi) => 440 * Math.pow(2, (semi + 60 - 69) / 12);
  let timer = null, nextBar = 0, barIdx = 0, mode = 'calm', running = false;
  let bgmEl = null, bgmChecked = false, bgmAvailable = false;

  const scheduleBar = (t, bar) => {
    const chord = CHORDS[bar % CHORDS.length];
    // Pad: two slightly detuned triangles per chord tone through a low-pass.
    chord.forEach(semi => {
      [-6, 6].forEach(cents => {
        const o = ctx.createOscillator(); const g = ctx.createGain(); const f = ctx.createBiquadFilter();
        o.type = 'triangle'; o.frequency.value = hz(semi); o.detune.value = cents;
        f.type = 'lowpass'; f.frequency.value = 1300;
        g.gain.setValueAtTime(0.0001, t);
        g.gain.exponentialRampToValueAtTime(0.055, t + 0.3);
        g.gain.setValueAtTime(0.055, t + BAR - 0.25);
        g.gain.exponentialRampToValueAtTime(0.0001, t + BAR);
        o.connect(f); f.connect(g); g.connect(musicBus);
        o.start(t); o.stop(t + BAR + 0.05);
      });
    });
    // Arpeggio: 16ths over two octaves of the chord.
    const tones = chord.concat(chord.map(s => s + 12));
    for (let i = 0; i < 16; i++) {
      const at = t + i * BEAT / 4;
      const semi = tones[ARP[i] % tones.length];
      tone('triangle', hz(semi + 12), hz(semi + 12), 0.16, i % 4 === 0 ? 0.11 : 0.07, at - ctx.currentTime, musicBus);
    }
    if (mode === 'play') {
      // Kick on 1 and 3, ghost on the "and" of 2; bass with the kick; hats on 8ths.
      [0, 1.5, 2].forEach((b, i) => {
        const at = t + b * BEAT;
        const o = ctx.createOscillator(); const g = ctx.createGain();
        o.type = 'sine'; o.frequency.setValueAtTime(140, at); o.frequency.exponentialRampToValueAtTime(45, at + 0.14);
        g.gain.setValueAtTime(i === 1 ? 0.35 : 0.7, at); g.gain.exponentialRampToValueAtTime(0.0001, at + 0.18);
        o.connect(g); g.connect(musicBus); o.start(at); o.stop(at + 0.2);
      });
      [0, 2].forEach(b => tone('sine', hz(chord[0] - 24), hz(chord[0] - 24), 0.45, 0.5, t + b * BEAT - ctx.currentTime, musicBus));
      [0.5, 2.5].forEach(b => tone('triangle', hz(chord[0] - 12), hz(chord[0] - 12), 0.2, 0.22, t + b * BEAT - ctx.currentTime, musicBus));
      for (let i = 0; i < 8; i++) noise(0.03, i % 2 ? 0.14 : 0.08, t + i * BEAT / 2 - ctx.currentTime, 6000, musicBus);
    }
  };
  const tickScheduler = () => {
    if (!running || !ctx) return;
    while (nextBar < ctx.currentTime + 0.6) { scheduleBar(nextBar, barIdx++); nextBar += BAR; }
  };
  const checkBgmFile = () => {
    if (bgmChecked) return; bgmChecked = true;
    fetch(AION_BGM_FILE, { method: 'HEAD', cache: 'no-cache' })
      .then(r => { bgmAvailable = r.ok && /audio/.test(r.headers.get('content-type') || ''); if (bgmAvailable && running) music.start(); })
      .catch(() => { bgmAvailable = false; });
  };
  const music = {
    start() {
      if (!ctx || running) return;
      running = true;
      checkBgmFile();
      if (bgmAvailable) {
        if (!bgmEl) { bgmEl = new Audio(AION_BGM_FILE); bgmEl.loop = true; }
        bgmEl.volume = musicMuted ? 0 : level;
        bgmEl.play().catch(() => {});
        return;
      }
      nextBar = ctx.currentTime + 0.05; barIdx = 0;
      timer = setInterval(tickScheduler, 100);
      tickScheduler();
    },
    stop() {
      running = false;
      if (timer) { clearInterval(timer); timer = null; }
      if (bgmEl) { bgmEl.pause(); }
      if (musicBus && ctx) { musicBus.gain.setTargetAtTime(0, ctx.currentTime, 0.2); }
    },
    // 'play' (quiz) slopes the bed down to half; 'calm' brings it back.
    setMode(m) {
      mode = m === 'play' ? 'play' : 'calm';
      glideTo(mode === 'play' ? MUSIC_LEVEL * QUIZ_FACTOR : MUSIC_LEVEL);
    },
    setMuted(m) {
      musicMuted = !!m;
      try { localStorage.setItem(AION_MUSIC_KEY, musicMuted ? '1' : '0'); } catch (e) {}
      if (musicBus && ctx) musicBus.gain.setTargetAtTime(musicMuted ? 0 : level, ctx.currentTime, 0.05);
      if (bgmEl) bgmEl.volume = musicMuted ? 0 : level;
    },
    isMuted() { return musicMuted; },
    level() { return musicBus ? musicBus.gain.value : (bgmEl ? bgmEl.volume : 0); },
  };

  return { sfx, music, ensure };
})();
const aionSfx   = aionAudio.sfx;
const aionMusic = aionAudio.music;

function isAiOrNotEnabled() {
  return !(typeof window !== 'undefined'
    && window.BC_PAGES_FLAGS
    && window.BC_PAGES_FLAGS.aiOrNotEnabled === false);
}

function aionConfig() {
  const c = (typeof window !== 'undefined' && window.BC_AION_CONFIG) || {};
  return {
    imagesPerRound:  Math.max(1, Math.min(20,  parseInt(c.imagesPerRound,  10) || 3)),
    secondsPerImage: Math.max(3, Math.min(120, parseInt(c.secondsPerImage, 10) || 10)),
    hasPassword:     !!c.hasPassword,
    prizeImages:     Array.isArray(c.prizes) ? c.prizes.map(p => p && p.image).filter(Boolean) : [],
  };
}

/* ---------------------------------------------------------------
   One-time asset load.
   Every photo URL carries the manifest version (?v=…) so the server's
   1-year immutable cache is safe: a re-crop bumps the version and the
   URL, an unchanged photo is served from the laptop's cache forever.
   The preloader fetches + decodes every photo and reward image once,
   keeps the Image objects referenced (so the decoded bitmaps stay warm),
   and reports progress for the start screen. It starts as soon as the
   manifest arrives — before the booth password is even typed — so by
   the time the operator hits START everything is already local.
--------------------------------------------------------------- */
const AION_IMG_CACHE = new Map();
function aionPhotoUrl(manifest, type, name) {
  return AION_ASSET_BASE + type + '/' + name + '?v=' + (manifest && manifest.version ? manifest.version : 0);
}
function useAionPreload(manifest, extraUrls) {
  const [state, setState] = React.useState({ loaded: 0, failed: 0, total: 0, done: false });
  React.useEffect(() => {
    if (!manifest) return;
    const urls = manifest.ai.map(n => aionPhotoUrl(manifest, 'ai', n))
      .concat(manifest.real.map(n => aionPhotoUrl(manifest, 'real', n)))
      .concat(extraUrls || []);
    const total = urls.length;
    let loaded = 0, failed = 0, cancelled = false;
    setState({ loaded: 0, failed: 0, total, done: total === 0 });
    const tick = () => {
      if (cancelled) return;
      setState({ loaded, failed, total, done: loaded + failed >= total });
    };
    // Modest concurrency so 60 requests don't stampede a booth Wi-Fi link.
    let idx = 0;
    const next = () => {
      if (cancelled || idx >= urls.length) return;
      const url = urls[idx++];
      if (AION_IMG_CACHE.has(url)) { loaded++; tick(); next(); return; }
      const im = new Image();
      // Count on onload and move on. We do NOT await img.decode(): Chrome
      // can leave that promise pending indefinitely for a background tab,
      // which froze the whole pipeline. decode() is still kicked off so
      // the bitmap is warm, but nothing waits on it. A per-image timeout
      // guarantees a stalled request can't wedge a slot either.
      let settled = false;
      const finish = (ok) => {
        if (settled) return;
        settled = true; clearTimeout(tm);
        if (ok) loaded++; else { failed++; console.warn('[ai-or-not] preload failed/timed out:', url); }
        tick(); next();
      };
      im.onload = () => {
        AION_IMG_CACHE.set(url, im);
        if (im.decode) { try { im.decode().catch(() => {}); } catch (e) {} }
        finish(true);
      };
      im.onerror = () => finish(false);
      const tm = setTimeout(() => finish(false), 20000);
      im.src = url;
    };
    for (let i = 0; i < 6; i++) next();
    return () => { cancelled = true; };
  }, [manifest]);   // eslint-disable-line react-hooks/exhaustive-deps
  return state;
}

/* ---------------------------------------------------------------
   Deck — no repeats until a pool is exhausted.
--------------------------------------------------------------- */
function aionShuffle(arr) {
  const a = arr.slice();
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    const t = a[i]; a[i] = a[j]; a[j] = t;
  }
  return a;
}
function aionLoadDeck(manifest) {
  let d = null;
  try { d = JSON.parse(localStorage.getItem(AION_DECK_KEY) || 'null'); } catch (e) { d = null; }
  if (!d || d.version !== manifest.version || !Array.isArray(d.ai) || !Array.isArray(d.real)) {
    d = { version: manifest.version, ai: aionShuffle(manifest.ai), real: aionShuffle(manifest.real) };
  }
  return d;
}
function aionSaveDeck(d) {
  try { localStorage.setItem(AION_DECK_KEY, JSON.stringify(d)); } catch (e) {}
}
function aionDeal(manifest, n) {
  const deck = aionLoadDeck(manifest);
  const nAi = Math.random() < 0.5 ? Math.ceil(n / 2) : Math.floor(n / 2);
  const types = aionShuffle(Array.from({ length: n }, (_, i) => i < nAi ? 'ai' : 'real'));
  const usedThisRound = [];
  const cards = types.map(type => {
    const full = type === 'ai' ? manifest.ai : manifest.real;
    if (deck[type].length === 0) {
      deck[type] = aionShuffle(full.filter(name => usedThisRound.indexOf(name) === -1));
      if (deck[type].length === 0) deck[type] = aionShuffle(full);
    }
    const name = deck[type].shift();
    usedThisRound.push(name);
    return { src: aionPhotoUrl(manifest, type, name), isAi: type === 'ai', name };
  });
  aionSaveDeck(deck);
  return cards;
}

/* ---------------------------------------------------------------
   Page
--------------------------------------------------------------- */
function PageAiOrNot() {
  if (!isAiOrNotEnabled()) {
    return <AionShell><AionNotice title="AI or Not" body="This game is closed for now. Thanks for visiting Bonfire Cloud." /></AionShell>;
  }

  const cfg = aionConfig();
  const [auth, setAuth] = React.useState({ loading: true, authed: false, hasPassword: cfg.hasPassword });
  const [manifest, setManifest] = React.useState(null);
  const [phase, setPhase] = React.useState('start');   // start | quiz | score | drawing | reward | soldout
  const [cards, setCards] = React.useState([]);
  const [results, setResults] = React.useState([]);     // [{ card, correct, timeout }]
  const [drawResult, setDrawResult] = React.useState(null);
  const [errMsg, setErrMsg] = React.useState('');
  const [progress, setProgress] = React.useState(0);   // current photo index, for the header pips

  React.useEffect(() => {
    document.body.classList.add('aion-lock');
    // Any first gesture on the page (mouse or key) unlocks audio and
    // starts the background music.
    const wake = () => aionAudio.ensure();
    window.addEventListener('pointerdown', wake);
    window.addEventListener('keydown', wake);
    return () => {
      document.body.classList.remove('aion-lock');
      window.removeEventListener('pointerdown', wake);
      window.removeEventListener('keydown', wake);
      aionMusic.stop();   // leaving the page stops the music
    };
  }, []);

  // Music intensity follows the screen: full beat in the quiz, calm elsewhere.
  React.useEffect(() => { aionMusic.setMode(phase === 'quiz' ? 'play' : 'calm'); }, [phase]);

  React.useEffect(() => {
    fetch('api/ai_or_not_status.php')
      .then(r => r.json())
      .then(j => setAuth({ loading: false, authed: !!(j && j.ok && j.authed), hasPassword: !!(j && j.has_password) }))
      .catch(e => { console.warn('[ai-or-not] status error:', e); setAuth({ loading: false, authed: false, hasPassword: cfg.hasPassword }); });
    fetch(AION_ASSET_BASE + 'manifest.json', { cache: 'no-cache' })
      .then(r => r.json())
      .then(m => { if (m && Array.isArray(m.ai) && Array.isArray(m.real)) setManifest(m); })
      .catch(e => console.warn('[ai-or-not] manifest error:', e));
  }, []);

  // One-time asset load: all photos + reward images, with progress.
  const preload = useAionPreload(manifest, cfg.prizeImages);
  const assetsReady = !!manifest && preload.done;

  const startRound = () => {
    if (!assetsReady) return;
    aionSfx.play('start');
    const dealt = aionDeal(manifest, cfg.imagesPerRound);
    setCards(dealt);
    setResults([]);
    setErrMsg('');
    setPhase('quiz');
  };

  const finishRound = (res) => { setResults(res); setPhase('score'); };
  const score = results.filter(r => r.correct).length;

  const draw = async () => {
    aionSfx.play('click');
    setPhase('drawing');
    setErrMsg('');
    try {
      const res = await fetch('api/ai_or_not_draw.php', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ score, total: cards.length }),
      });
      const j = await res.json();
      if (!j || !j.ok) {
        console.warn('[ai-or-not] draw failed:', j);
        if (res.status === 403) { setAuth(a => ({ ...a, authed: false })); setPhase('start'); return; }
        if (res.status === 409) { setPhase('soldout'); return; }
        setErrMsg((j && j.error) || AION_GENERIC_ERR);
        setPhase('score');
        return;
      }
      setDrawResult({ prize: j.prize, pool: j.pool || [] });
      setPhase('reward');
    } catch (e) {
      console.warn('[ai-or-not] draw network/parse error:', e);
      setErrMsg(AION_GENERIC_ERR);
      setPhase('score');
    }
  };

  const retry = () => { aionSfx.play('click'); setDrawResult(null); setCards([]); setResults([]); setPhase('start'); };

  const logout = async () => {
    try { await fetch('api/ai_or_not_status.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'logout' }) }); } catch (e) {}
    setAuth(a => ({ ...a, authed: false }));
    setPhase('start');
  };

  let body, headerRight = null;
  if (auth.loading) {
    body = <AionNotice title="…" body="Loading." />;
  } else if (!auth.authed) {
    body = <AionLogin hasPassword={auth.hasPassword} onUnlocked={() => setAuth(a => ({ ...a, authed: true }))} />;
  } else if (phase === 'start') {
    body = <AionStart cfg={cfg} ready={assetsReady} preload={preload} onStart={startRound} onLogout={logout} />;
  } else if (phase === 'quiz') {
    body = <AionQuiz cards={cards} seconds={cfg.secondsPerImage} onDone={finishRound} onProgress={(i) => setProgress(i)} />;
  } else if (phase === 'score' || phase === 'drawing') {
    body = <AionScore results={results} busy={phase === 'drawing'} error={errMsg} onDraw={draw} />;
  } else if (phase === 'reward') {
    body = <AionReward result={drawResult} onRetry={retry} />;
  } else if (phase === 'soldout') {
    body = <AionNotice title="All rewards claimed" body="Every reward for this event has been given out. Thanks for playing!" onAnyKey={retry}
             action={<><button className="aion-btn aion-btn-primary" onClick={retry}>Retry</button><p className="aion-press">Press any button</p></>} />;
  }

  // Header right slot: round pips during the quiz.
  if (phase === 'quiz' && cards.length) {
    headerRight = (
      <div className="aion-pips" aria-label={`Photo ${progress + 1} of ${cards.length}`}>
        {cards.map((_, i) => <span key={i} className={i < progress ? 'done' : (i === progress ? 'now' : '')} />)}
        <em>{progress + 1}<span>/</span>{cards.length}</em>
      </div>
    );
  }

  return <AionShell headerRight={headerRight} showTitle={phase !== 'start' && auth.authed}>{body}</AionShell>;
}

/* ---------------------------------------------------------------
   Shell — fixed 16:9 stage, letterboxed, CSS vars for scale
--------------------------------------------------------------- */
function AionShell({ children, headerRight, showTitle }) {
  const [dims, setDims] = React.useState({ w: 1280, h: 720 });
  React.useEffect(() => {
    const calc = () => {
      const vw = window.innerWidth, vh = window.innerHeight;
      let w = vw, h = Math.round(vw * 9 / 16);
      if (h > vh) { h = vh; w = Math.round(vh * 16 / 9); }
      setDims({ w, h });
    };
    calc();
    window.addEventListener('resize', calc);
    return () => window.removeEventListener('resize', calc);
  }, []);
  const ev = (typeof window !== 'undefined' && window.BC_EVENT_CONFIG) || {};
  return (
    <div className="aion-root">
      <style>{AION_CSS}</style>
      <div className="aion-stage" style={{ width: dims.w, height: dims.h, '--sh': dims.h + 'px' }}>
        <div className="aion-bg" aria-hidden="true" />
        <header className="aion-top">
          <div className="aion-brand">
            <img src="assets/bonfire-cloud-single-line.png" alt="Bonfire Cloud" className="aion-logo-bc" />
            <span className="aion-x" aria-hidden="true">×</span>
            <img src="assets/tencentcloud-white.png" alt="Tencent Cloud" className="aion-logo-tc" />
            {ev.eventLogo && (<>
              <span className="aion-x" aria-hidden="true">×</span>
              <img src={ev.eventLogo} alt={ev.eventName || 'Event'} className="aion-logo-ev" />
            </>)}
          </div>
          <div className="aion-top-title">{showTitle && <span className="aion-wordmark-sm">AI <i>or</i> NOT</span>}</div>
          <div className="aion-top-right">{headerRight}</div>
        </header>
        <main className="aion-main">{children}</main>
        <footer className="aion-foot">Bonfire Cloud · Tencent Cloud Authorized Partner · Thailand</footer>
      </div>
    </div>
  );
}

function AionNotice({ title, body, action, onAnyKey }) {
  useAionAnyKey(onAnyKey || (() => {}), !!onAnyKey);
  return (
    <div className="aion-card">
      <h1 className="aion-title">{title}</h1>
      <p className="aion-sub">{body}</p>
      {action}
    </div>
  );
}

/* ---------------------------------------------------------------
   Icons (inline SVG so nothing depends on emoji fonts)
--------------------------------------------------------------- */
function AionIconRobot() {
  return (
    <svg viewBox="0 0 64 64" className="aion-ico" aria-hidden="true">
      <rect x="10" y="20" width="44" height="34" rx="8" fill="currentColor" opacity="0.18" />
      <rect x="10" y="20" width="44" height="34" rx="8" fill="none" stroke="currentColor" strokeWidth="4" />
      <circle cx="24" cy="36" r="5" fill="currentColor" />
      <circle cx="40" cy="36" r="5" fill="currentColor" />
      <rect x="22" y="45" width="20" height="4" rx="2" fill="currentColor" />
      <rect x="30" y="8" width="4" height="12" rx="2" fill="currentColor" />
      <circle cx="32" cy="7" r="4" fill="currentColor" />
      <rect x="2" y="30" width="8" height="14" rx="3" fill="currentColor" />
      <rect x="54" y="30" width="8" height="14" rx="3" fill="currentColor" />
    </svg>
  );
}
function AionIconCamera() {
  return (
    <svg viewBox="0 0 64 64" className="aion-ico" aria-hidden="true">
      <path d="M12 20h9l4-6h14l4 6h9a6 6 0 0 1 6 6v22a6 6 0 0 1-6 6H12a6 6 0 0 1-6-6V26a6 6 0 0 1 6-6z" fill="currentColor" opacity="0.18" />
      <path d="M12 20h9l4-6h14l4 6h9a6 6 0 0 1 6 6v22a6 6 0 0 1-6 6H12a6 6 0 0 1-6-6V26a6 6 0 0 1 6-6z" fill="none" stroke="currentColor" strokeWidth="4" />
      <circle cx="32" cy="36" r="10" fill="none" stroke="currentColor" strokeWidth="4" />
      <circle cx="32" cy="36" r="4" fill="currentColor" />
      <rect x="44" y="25" width="7" height="4" rx="2" fill="currentColor" />
    </svg>
  );
}

/* ---------------------------------------------------------------
   Login — booth password
--------------------------------------------------------------- */
function AionLogin({ hasPassword, onUnlocked }) {
  const [pw, setPw] = React.useState('');
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState('');

  const submit = async (e) => {
    if (e && e.preventDefault) e.preventDefault();
    if (!pw) { setError('Please enter the booth password.'); return; }
    setBusy(true); setError('');
    try {
      const r = await fetch('api/ai_or_not_login.php', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ password: pw }),
      });
      const j = await r.json();
      if (!j || !j.ok) { console.warn('[ai-or-not] login failed:', j); setError((j && j.error) || AION_GENERIC_ERR); return; }
      onUnlocked();
    } catch (err) {
      console.warn('[ai-or-not] login network/parse error:', err);
      setError(AION_GENERIC_ERR);
    } finally { setBusy(false); }
  };

  return (
    <form className="aion-card aion-panel" onSubmit={submit}>
      <div className="aion-eyebrow">Booth unlock</div>
      <h1 className="aion-wordmark">AI <i>or</i> NOT</h1>
      {hasPassword ? (<>
        <p className="aion-sub">Enter the booth password to unlock this laptop for the day.</p>
        <input type="password" className="aion-input" value={pw} onChange={e => setPw(e.target.value)}
               placeholder="Booth password" autoFocus autoComplete="current-password" />
        {error && <p className="aion-err">{error}</p>}
        <button type="submit" className="aion-btn aion-btn-primary" disabled={busy}>{busy ? 'Checking…' : 'Unlock →'}</button>
      </>) : (
        <p className="aion-sub">No booth password has been set yet.<br/>Set one in Admin → Event → <strong>AI or Not</strong>, then reload this page.</p>
      )}
    </form>
  );
}

/* ---------------------------------------------------------------
   Start
--------------------------------------------------------------- */
function AionStart({ cfg, ready, preload, onStart, onLogout }) {
  useAionAnyKey(onStart, ready);
  const pct = preload.total ? Math.round(((preload.loaded + preload.failed) / preload.total) * 100) : 0;
  const [muted, setMuted] = React.useState(aionSfx.isMuted());
  const [musicMuted, setMusicMuted] = React.useState(aionMusic.isMuted());
  const toggleMute = () => {
    const m = !muted; aionSfx.setMuted(m); setMuted(m);
    if (!m) { aionAudio.ensure(); aionSfx.play('click'); }
  };
  const toggleMusic = () => {
    const m = !musicMuted; aionAudio.ensure(); aionMusic.setMuted(m); setMusicMuted(m);
  };
  const goFull = () => {
    const el = document.documentElement;
    const fn = el.requestFullscreen || el.webkitRequestFullscreen;
    if (fn) { try { fn.call(el); } catch (e) {} }
  };
  // Cover kept deliberately sparse: name, one line of rules, START,
  // and the two keys. Everything a guest needs to read in two seconds.
  return (
    <div className="aion-start">
      <div className="aion-eyebrow">Can you tell?</div>
      <h1 className="aion-wordmark aion-wordmark-xl">AI <i>or</i> NOT</h1>
      <p className="aion-lead">
        Is the photo made by AI? {cfg.imagesPerRound} photo{cfg.imagesPerRound === 1 ? '' : 's'}, {cfg.secondsPerImage} seconds each.
        Everyone wins a reward.
      </p>
      <button className="aion-btn aion-btn-start" onClick={onStart} disabled={!ready}>{ready ? 'START' : 'Loading…'}</button>
      {ready
        ? <p className="aion-press">Press either button to start</p>
        : <div className="aion-loadbar" role="progressbar" aria-valuenow={pct} aria-valuemin="0" aria-valuemax="100">
            <div className="aion-loadbar-track"><div className="aion-loadbar-fill" style={{ width: pct + '%' }} /></div>
            <span>Loading photos {Math.min(preload.loaded + preload.failed, preload.total)} / {preload.total || '…'}</span>
          </div>}
      <div className="aion-keys-row" aria-hidden="true">
        <span><kbd className="aion-key aion-key-ai">←</kbd> AI</span>
        <span><kbd className="aion-key aion-key-real">→</kbd> NOT AI</span>
      </div>
      <div className="aion-start-tools">
        <button type="button" className="aion-link" onClick={goFull}>⛶ Fullscreen</button>
        <span className="aion-dot">·</span>
        <button type="button" className="aion-link" onClick={toggleMute}>{muted ? '🔇 SFX off' : '🔊 SFX on'}</button>
        <span className="aion-dot">·</span>
        <button type="button" className="aion-link" onClick={toggleMusic}>{musicMuted ? '🎵 Music off' : '🎵 Music on'}</button>
        <span className="aion-dot">·</span>
        <button type="button" className="aion-link" onClick={onLogout}>Lock booth</button>
      </div>
    </div>
  );
}

/* ---------------------------------------------------------------
   Quiz — [AI][photo 4:3][NOT AI] + timer bar + hourglass
--------------------------------------------------------------- */
function AionQuiz({ cards, seconds, onDone, onProgress }) {
  const [idx, setIdx] = React.useState(0);
  const [left, setLeft] = React.useState(seconds);
  const [reveal, setReveal] = React.useState(null);   // { correct, isAi, timeout }
  const resultsRef = React.useRef([]);
  const lockRef = React.useRef(false);
  const armedAtRef = React.useRef(Date.now() + 500);

  const card = cards[idx];

  React.useEffect(() => { if (onProgress) onProgress(idx); }, [idx]);   // eslint-disable-line react-hooks/exhaustive-deps

  React.useEffect(() => {
    if (reveal) return;
    setLeft(seconds);
    lockRef.current = false;
    const t = setInterval(() => {
      setLeft(s => {
        if (s <= 1) { clearInterval(t); answer(null); return 0; }
        // Soft tick each second; sharper, higher tick in the last 3 s.
        aionSfx.play((s - 1) <= 3 ? 'tickHot' : 'tick');
        return s - 1;
      });
    }, 1000);
    return () => clearInterval(t);
  }, [idx, reveal]);   // eslint-disable-line react-hooks/exhaustive-deps

  const answer = (saidAi) => {
    if (lockRef.current || !card) return;
    if (saidAi !== null && Date.now() < armedAtRef.current) return;
    lockRef.current = true;
    const timeout = saidAi === null;
    const correct = !timeout && saidAi === card.isAi;
    aionSfx.play(timeout ? 'timeout' : (correct ? 'correct' : 'wrong'));
    resultsRef.current.push({ card, correct, timeout });
    setReveal({ correct, isAi: card.isAi, timeout });
    setTimeout(() => {
      if (idx + 1 >= cards.length) { onDone(resultsRef.current.slice()); return; }
      setReveal(null);
      setIdx(idx + 1);
    }, 1400);
  };

  React.useEffect(() => {
    const onKey = (e) => {
      aionSfx.ensure();
      if (e.repeat) return;
      if (aionIsLeft(e))  { e.preventDefault(); answer(true); }
      if (aionIsRight(e)) { e.preventDefault(); answer(false); }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  });

  if (!card) return null;
  const frac = Math.max(0, Math.min(1, left / seconds));
  const tone = frac > 0.5 ? 'calm' : (frac > 0.25 ? 'warn' : 'hot');

  return (
    <div className="aion-quiz">
      <AionGauge frac={frac} seconds={left} total={seconds} tone={tone} paused={!!reveal} />
      <div className="aion-row">
        <button className={'aion-side aion-side-ai' + (reveal && reveal.isAi ? ' truth' : '')} onClick={() => answer(true)} disabled={!!reveal}>
          <AionIconRobot />
          <span className="aion-side-label">AI</span>
          <kbd className="aion-key aion-key-ai">←</kbd>
        </button>

        <div className="aion-center">
          <div className={'aion-frame' + (reveal ? (reveal.correct ? ' ok' : ' bad') : '')}>
            <img src={card.src} alt="" className="aion-photo" draggable="false" />
            {reveal && (
              <div className="aion-reveal">
                <div className={'aion-stamp ' + (reveal.isAi ? 'ai' : 'real')}>{reveal.isAi ? 'AI' : 'NOT AI'}</div>
                <div className={'aion-verdict ' + (reveal.correct ? 'ok' : (reveal.timeout ? 'late' : 'bad'))}>
                  {reveal.correct ? '✓  Correct' : (reveal.timeout ? '⏱  Time\'s up' : '✗  Not quite')}
                </div>
              </div>
            )}
          </div>
        </div>

        <button className={'aion-side aion-side-real' + (reveal && !reveal.isAi ? ' truth' : '')} onClick={() => answer(false)} disabled={!!reveal}>
          <AionIconCamera />
          <span className="aion-side-label">NOT AI</span>
          <kbd className="aion-key aion-key-real">→</kbd>
        </button>
      </div>
    </div>
  );
}

// Rundown gauge: a wide bar across the top of the board that drains
// right-to-left one second at a time (width transition = 1 s linear so
// it glides between ticks), tick marks per second, big number at the
// end. Colour shifts cyan → amber → red as it empties.
function AionGauge({ frac, seconds, total, tone, paused }) {
  const ticks = Math.min(60, Math.max(1, total));
  return (
    <div className={'aion-gauge ' + tone + (paused ? ' paused' : '')} role="timer" aria-label={`${seconds} seconds left`}>
      <div className="aion-gauge-bar">
        <div className="aion-gauge-fill" style={{ width: (frac * 100) + '%' }} />
        <div className="aion-gauge-ticks" aria-hidden="true">
          {Array.from({ length: ticks - 1 }).map((_, i) => <span key={i} />)}
        </div>
      </div>
      <div className="aion-gauge-num"><b>{seconds}</b><span>s</span></div>
    </div>
  );
}

/* ---------------------------------------------------------------
   Score → draw
--------------------------------------------------------------- */
function AionScore({ results, busy, error, onDraw }) {
  useAionAnyKey(onDraw, !busy);
  const total = results.length;
  const score = results.filter(r => r.correct).length;
  const all = total > 0 && score === total;
  return (
    <div className="aion-score">
      <div className="aion-eyebrow">{all ? 'Perfect round' : (score === 0 ? 'Tricky, right?' : 'Nice try')}</div>
      <div className="aion-bigscore"><b>{score}</b><span>/ {total}</span></div>
      <div className="aion-results">
        {results.map((r, i) => (
          <div key={i} className={'aion-res ' + (r.correct ? 'ok' : 'bad')}>
            <img src={r.card.src} alt="" />
            <span className="aion-res-badge">{r.correct ? '✓' : (r.timeout ? '⏱' : '✗')}</span>
            <span className={'aion-res-truth ' + (r.card.isAi ? 'ai' : 'real')}>{r.card.isAi ? 'AI' : 'NOT AI'}</span>
          </div>
        ))}
      </div>
      <p className="aion-sub">{all ? 'You beat the machines. ' : ''}Everyone who plays gets a reward — let's see what you got.</p>
      {error && <p className="aion-err">{error}</p>}
      <button className="aion-btn aion-btn-primary aion-btn-big" onClick={onDraw} disabled={busy}>
        {busy ? 'Drawing…' : 'Draw your reward →'}
      </button>
      <p className="aion-press">{busy ? ' ' : 'Press any button to continue'}</p>
    </div>
  );
}

// Prize picture with a graceful fallback: no image, or an image that
// fails to load (typo'd path, not uploaded yet), shows the gift glyph
// instead of a broken-image icon on the booth screen.
function AionPrizeImage({ src, alt }) {
  const [broken, setBroken] = React.useState(false);
  React.useEffect(() => { setBroken(false); }, [src]);
  if (!src || broken) return <div className="aion-prize-ph">🎁</div>;
  return <img src={src} alt={alt || ''} className="aion-prize-img" onError={() => setBroken(true)} />;
}

/* ---------------------------------------------------------------
   Reward — shuffle through the pool, land, confetti, Retry
--------------------------------------------------------------- */
function AionReward({ result, onRetry }) {
  const prize = result && result.prize;
  const pool  = (result && result.pool && result.pool.length) ? result.pool : (prize ? [prize] : []);
  const [spin, setSpin] = React.useState(pool.length > 1);
  const [shown, setShown] = React.useState(pool.length ? pool[0] : prize);
  const canvasRef = React.useRef(null);

  React.useEffect(() => {
    if (!spin) return;
    let i = 0, delay = 70, cancelled = false;
    const step = () => {
      if (cancelled) return;
      i++;
      setShown(pool[i % pool.length]);
      aionSfx.play('drum');
      delay = Math.round(delay * 1.13);
      if (delay > 420) { setShown(prize); setSpin(false); aionSfx.play('win'); return; }
      setTimeout(step, delay);
    };
    setTimeout(step, delay);
    return () => { cancelled = true; };
  }, []);   // eslint-disable-line react-hooks/exhaustive-deps

  React.useEffect(() => {
    if (spin) return;
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    const W = canvas.width, H = canvas.height;
    const colors = ['#3CA0C8', '#78C8F0', '#F08700', '#F05A00', '#2ECC71', '#ffffff'];
    const bits = Array.from({ length: 110 }, () => ({
      x: Math.random() * W, y: -Math.random() * H, vx: (Math.random() - 0.5) * 1.4,
      vy: 1.2 + Math.random() * 2.2, s: 4 + Math.floor(Math.random() * 4),
      c: colors[Math.floor(Math.random() * colors.length)], r: Math.random() * Math.PI, vr: (Math.random() - 0.5) * 0.2,
    }));
    let raf;
    const loop = () => {
      ctx.clearRect(0, 0, W, H);
      bits.forEach(p => {
        p.x += p.vx; p.y += p.vy; p.r += p.vr;
        if (p.y > H + 10) { p.y = -10; p.x = Math.random() * W; }
        ctx.fillStyle = p.c;
        ctx.fillRect(Math.round(p.x), Math.round(p.y), Math.max(1, Math.round(Math.abs(Math.cos(p.r)) * p.s + 1)), p.s);
      });
      raf = requestAnimationFrame(loop);
    };
    loop();
    return () => cancelAnimationFrame(raf);
  }, [spin]);

  // 5-second hold after the prize lands: the guest gets to see what they
  // won, staff read the name, and an eager press can't skip it. The
  // prompt counts down, then "any button" (and Retry) go live.
  const [hold, setHold] = React.useState(5);
  React.useEffect(() => {
    if (spin) return;
    setHold(5);
    const t = setInterval(() => setHold(h => { if (h <= 1) { clearInterval(t); return 0; } return h - 1; }), 1000);
    return () => clearInterval(t);
  }, [spin]);
  const canContinue = !spin && hold === 0;
  useAionAnyKey(onRetry, canContinue, 0);

  if (!prize) return null;
  const p = shown || prize;
  return (
    <div className="aion-reward">
      {!spin && <canvas ref={canvasRef} width={800} height={450} className="aion-confetti" />}
      <div className={'aion-prize-wrap' + (spin ? ' spinning' : ' landed')}>
        <div className="aion-prize-card">
          <div className="aion-eyebrow">{spin ? 'Drawing…' : '★  You won  ★'}</div>
          <div className="aion-prize-frame">
            <AionPrizeImage src={p.image} alt={p.name} />
          </div>
          <h2 className="aion-prize-name">{p.name}</h2>
          <p className={'aion-sub' + (spin ? ' aion-invisible' : '')}>Show this screen at the counter to claim it.</p>
        </div>
      </div>
      {/* Always in the layout (invisible while spinning) so the card and
          its frame sit at the same spot before and after the prize lands. */}
      <button className={'aion-btn aion-btn-primary aion-retry' + (spin ? ' aion-invisible' : '')} onClick={onRetry} disabled={!canContinue}>Retry</button>
      <p className={'aion-press' + (spin ? ' aion-invisible' : (canContinue ? '' : ' aion-press-hold'))} style={{ position: 'relative' }}>
        {canContinue ? 'Press any button to play again' : ('Continue in ' + (spin ? 5 : hold))}
      </p>
    </div>
  );
}

/* ---------------------------------------------------------------
   Styles — everything scales from --sh (stage height)
--------------------------------------------------------------- */
const AION_CSS = `
  body.aion-lock { overflow: hidden !important; }
  .aion-root {
    position: fixed; inset: 0; z-index: 50;
    display: flex; align-items: center; justify-content: center;
    background: #06091A;
    font-family: "Exo 2", var(--font-latin, -apple-system, "Segoe UI", Roboto, sans-serif);
    color: #fff;
    user-select: none; -webkit-user-select: none;
  }
  .aion-stage {
    position: relative; overflow: hidden;
    display: flex; flex-direction: column;
    background: #0A0E24;
    box-shadow: 0 0 0 1px rgba(255,255,255,0.05), 0 0 120px rgba(0,0,0,0.6);
  }
  /* Layered background: glows + dot grid + vignette */
  .aion-bg {
    position: absolute; inset: 0; pointer-events: none;
    background:
      radial-gradient(ellipse 60% 50% at 18% 12%, rgba(60,160,200,0.30), transparent 65%),
      radial-gradient(ellipse 45% 40% at 85% 90%, rgba(240,90,0,0.20), transparent 65%),
      radial-gradient(ellipse 70% 60% at 50% 110%, rgba(40,120,180,0.18), transparent 60%),
      linear-gradient(170deg, #14143C 0%, #121a44 55%, #0A0E24 100%);
  }
  .aion-bg::before {
    content: ''; position: absolute; inset: 0;
    background-image: radial-gradient(rgba(255,255,255,0.07) 1px, transparent 1.4px);
    background-size: calc(var(--sh) * 0.034) calc(var(--sh) * 0.034);
    mask-image: radial-gradient(ellipse 70% 70% at 50% 50%, #000 30%, transparent 100%);
    -webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 50%, #000 30%, transparent 100%);
  }
  .aion-bg::after {
    content: ''; position: absolute; inset: 0;
    box-shadow: inset 0 0 calc(var(--sh) * 0.3) rgba(0,0,0,0.55);
  }

  /* Header — full width: brand | title | progress */
  .aion-top {
    position: relative; z-index: 1;
    display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
    height: calc(var(--sh) * 0.1); flex: 0 0 auto;
    padding: 0 calc(var(--sh) * 0.04);
  }
  .aion-brand { display: flex; align-items: center; gap: calc(var(--sh) * 0.016); }
  .aion-logo-bc { height: calc(var(--sh) * 0.04); width: auto; }
  .aion-logo-tc { height: calc(var(--sh) * 0.03); width: auto; }
  .aion-logo-ev { height: calc(var(--sh) * 0.04); width: auto; max-width: calc(var(--sh) * 0.3); object-fit: contain;
                  background: rgba(255,255,255,0.92); padding: calc(var(--sh) * 0.004) calc(var(--sh) * 0.01); border-radius: 6px; }
  .aion-x { color: rgba(255,255,255,0.4); font-size: calc(var(--sh) * 0.022); font-weight: 700; }
  .aion-top-title { text-align: center; }
  .aion-top-right { display: flex; justify-content: flex-end; }
  .aion-wordmark-sm {
    font-size: calc(var(--sh) * 0.03); font-weight: 900; letter-spacing: 0.16em; text-transform: uppercase;
    color: rgba(255,255,255,0.85);
  }
  .aion-wordmark-sm i, .aion-wordmark i { font-style: italic; font-weight: 300; letter-spacing: 0.04em; text-transform: none; color: #78C8F0; margin: 0 0.15em; }

  .aion-pips { display: flex; align-items: center; gap: calc(var(--sh) * 0.01); }
  .aion-pips span { width: calc(var(--sh) * 0.06); height: calc(var(--sh) * 0.012); border-radius: 999px; background: rgba(255,255,255,0.14); transition: background .2s; }
  .aion-pips span.done { background: #78C8F0; }
  .aion-pips span.now  { background: #F08700; box-shadow: 0 0 12px rgba(240,135,0,0.9); }
  .aion-pips em { font-style: normal; margin-left: calc(var(--sh) * 0.012); font-size: calc(var(--sh) * 0.03); font-weight: 800; color: #fff; }
  .aion-pips em span { color: rgba(255,255,255,0.45); margin: 0 0.15em; font-weight: 400; }

  .aion-main { position: relative; z-index: 1; flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 0 calc(var(--sh) * 0.04); }
  .aion-foot { position: relative; z-index: 1; height: calc(var(--sh) * 0.05); flex: 0 0 auto; display: flex; align-items: center; justify-content: center;
               font-size: calc(var(--sh) * 0.015); color: rgba(255,255,255,0.35); letter-spacing: 0.12em; text-transform: uppercase; }

  /* Generic pieces */
  .aion-card { text-align: center; max-width: calc(var(--sh) * 1.0); display: flex; flex-direction: column; align-items: center; gap: calc(var(--sh) * 0.02); }
  .aion-panel {
    padding: calc(var(--sh) * 0.05) calc(var(--sh) * 0.08);
    background: rgba(255,255,255,0.045); border: 1px solid rgba(120,200,240,0.22); border-radius: 24px;
    box-shadow: 0 30px 80px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.08);
    backdrop-filter: blur(6px);
  }
  .aion-eyebrow { font-size: calc(var(--sh) * 0.019); letter-spacing: 0.26em; text-transform: uppercase; color: #78C8F0; font-weight: 800; }
  .aion-title { margin: 0; font-size: calc(var(--sh) * 0.07); font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; }
  .aion-wordmark {
    margin: 0; font-size: calc(var(--sh) * 0.09); font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; line-height: 0.95;
    background: linear-gradient(180deg, #ffffff 0%, #dbeeff 55%, #9fd4f2 100%);
    -webkit-background-clip: text; background-clip: text; color: transparent;
    filter: drop-shadow(0 6px 0 rgba(20,20,60,0.6)) drop-shadow(0 18px 40px rgba(60,160,200,0.35));
  }
  .aion-wordmark-xl { font-size: calc(var(--sh) * 0.17); letter-spacing: 0.04em; }
  .aion-sub { margin: 0; font-size: calc(var(--sh) * 0.026); color: rgba(255,255,255,0.78); line-height: 1.5; }
  .aion-lead { margin: 0; font-size: calc(var(--sh) * 0.03); color: rgba(255,255,255,0.82); line-height: 1.5; }
  .aion-lead strong { color: #FFD166; }
  .aion-err { margin: 0; font-size: calc(var(--sh) * 0.022); color: #FFB3A8; background: rgba(210,45,0,0.18);
              border: 1px solid rgba(210,45,0,0.4); border-radius: 8px; padding: calc(var(--sh) * 0.012) calc(var(--sh) * 0.02); }
  .aion-input {
    width: calc(var(--sh) * 0.5); padding: calc(var(--sh) * 0.022); text-align: center;
    font-size: calc(var(--sh) * 0.03); letter-spacing: 0.12em; color: #fff; font-family: inherit;
    background: rgba(0,0,0,0.25); border: 2px solid rgba(120,200,240,0.32); border-radius: 12px; outline: none;
  }
  .aion-input:focus { border-color: rgba(120,200,240,0.85); box-shadow: 0 0 0 4px rgba(120,200,240,0.15); }

  .aion-btn {
    border: 0; cursor: pointer; color: #fff; font-family: inherit; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em;
    font-size: calc(var(--sh) * 0.028); padding: calc(var(--sh) * 0.024) calc(var(--sh) * 0.06); border-radius: 14px;
    transition: transform .08s ease, box-shadow .08s ease, filter .12s;
  }
  .aion-btn:disabled { opacity: 0.55; cursor: not-allowed; }
  .aion-btn-primary {
    background: linear-gradient(180deg, #F08700 0%, #F05A00 55%, #D22D00 100%);
    box-shadow: 0 5px 0 #7a1a00, 0 14px 32px rgba(210,45,0,0.4), inset 0 1px 0 rgba(255,255,255,0.35);
  }
  .aion-btn-primary:hover:not(:disabled) { transform: translateY(-1px); filter: brightness(1.05); }
  .aion-btn-primary:active:not(:disabled) { transform: translateY(3px); box-shadow: 0 2px 0 #7a1a00; }
  .aion-btn-big { font-size: calc(var(--sh) * 0.034); }
  .aion-btn-start {
    background: linear-gradient(180deg, #F08700 0%, #F05A00 55%, #D22D00 100%);
    box-shadow: 0 8px 0 #7a1a00, 0 20px 50px rgba(210,45,0,0.45), inset 0 1px 0 rgba(255,255,255,0.35);
    font-size: calc(var(--sh) * 0.06); padding: calc(var(--sh) * 0.03) calc(var(--sh) * 0.16); border-radius: 22px;
    animation: aion-pulse 1.6s ease-in-out infinite;
  }
  .aion-btn-start:disabled { animation: none; }
  @keyframes aion-pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.03); } }
  .aion-press {
    margin: 0; font-size: calc(var(--sh) * 0.022); font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
    color: #78C8F0; min-height: calc(var(--sh) * 0.03);
    animation: aion-blink 1.4s ease-in-out infinite;
  }
  @keyframes aion-blink { 0%,100% { opacity: 0.5; } 50% { opacity: 1; } }
  .aion-press-hold { animation: none; color: rgba(255,255,255,0.5); font-variant-numeric: tabular-nums; }
  .aion-invisible { visibility: hidden !important; animation: none !important; }
  .aion-link { background: none; border: 0; color: rgba(255,255,255,0.5); cursor: pointer; font-family: inherit; font-size: inherit; padding: 4px 6px; }
  .aion-link:hover { color: #fff; text-decoration: underline; }
  .aion-dot { opacity: 0.4; }

  /* Keycaps */
  .aion-key {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: calc(var(--sh) * 0.05); height: calc(var(--sh) * 0.05); padding: 0 calc(var(--sh) * 0.012);
    border-radius: calc(var(--sh) * 0.012); font-family: inherit; font-weight: 900; font-size: calc(var(--sh) * 0.03);
    background: rgba(10,14,36,0.65); color: #fff;
    border: 2px solid rgba(255,255,255,0.35); border-bottom-width: 5px;
    box-shadow: inset 0 1px 0 rgba(255,255,255,0.25);
  }
  /* Choice colours are the BRAND pair — cloud cyan for AI, flame amber
     for NOT AI. Deliberately not green/red: those imply right/wrong and
     are reserved for the verdict (correct / not quite) only. */
  .aion-key-ai   { border-color: rgba(120,200,240,0.85); color: #CFEBFA; }
  .aion-key-real { border-color: rgba(255,209,102,0.9);  color: #FFE7B0; }

  /* Start — one centred column, nothing competing with START */
  .aion-start { width: 100%; height: 100%; position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: calc(var(--sh) * 0.026); }
  .aion-start .aion-lead { max-width: calc(var(--sh) * 1.1); }
  .aion-keys-row { display: flex; gap: calc(var(--sh) * 0.06); font-size: calc(var(--sh) * 0.026); font-weight: 800; letter-spacing: 0.1em; color: rgba(255,255,255,0.75); margin-top: calc(var(--sh) * 0.01); }
  .aion-keys-row span { display: inline-flex; align-items: center; gap: calc(var(--sh) * 0.014); }
  .aion-keys-row .aion-key { min-width: calc(var(--sh) * 0.05); height: calc(var(--sh) * 0.05); font-size: calc(var(--sh) * 0.028); }
  .aion-start-tools { position: absolute; right: calc(var(--sh) * 0.01); bottom: calc(var(--sh) * 0.005); display: flex; align-items: center; gap: calc(var(--sh) * 0.01); font-size: calc(var(--sh) * 0.017); color: rgba(255,255,255,0.4); }
  .aion-loadbar { display: flex; flex-direction: column; align-items: center; gap: calc(var(--sh) * 0.01); min-height: calc(var(--sh) * 0.03); }
  .aion-loadbar-track { width: calc(var(--sh) * 0.5); height: calc(var(--sh) * 0.012); border-radius: 999px; background: rgba(255,255,255,0.12); overflow: hidden; }
  .aion-loadbar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, #3CA0C8, #78C8F0); transition: width .25s ease; }
  .aion-loadbar span { font-size: calc(var(--sh) * 0.02); letter-spacing: 0.14em; text-transform: uppercase; color: rgba(255,255,255,0.55); font-weight: 700; }

  /* Quiz board: [ gauge across the top ] then [AI][photo][NOT AI] */
  .aion-quiz { width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(var(--sh) * 0.02); }
  .aion-row { width: calc(var(--sh) * 1.46); display: grid; grid-template-columns: calc(var(--sh) * 0.24) auto calc(var(--sh) * 0.24); justify-content: space-between; gap: calc(var(--sh) * 0.035); align-items: stretch; }

  /* Rundown gauge */
  .aion-gauge { width: calc(var(--sh) * 1.46); display: flex; align-items: center; gap: calc(var(--sh) * 0.024); }
  .aion-gauge-bar {
    position: relative; flex: 1 1 auto; height: calc(var(--sh) * 0.04); border-radius: 999px; overflow: hidden;
    background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.12);
    box-shadow: inset 0 2px 6px rgba(0,0,0,0.6);
  }
  .aion-gauge-fill {
    height: 100%; border-radius: 999px;
    transition: width 1s linear, background .4s, box-shadow .4s;
    background: linear-gradient(90deg, #2878B4, #78C8F0); box-shadow: 0 0 18px rgba(120,200,240,0.7);
  }
  .aion-gauge.paused .aion-gauge-fill { transition: width .2s ease; }
  .aion-gauge.warn .aion-gauge-fill { background: linear-gradient(90deg, #F08700, #FFD166); box-shadow: 0 0 18px rgba(240,135,0,0.7); }
  .aion-gauge.hot  .aion-gauge-fill { background: linear-gradient(90deg, #D22D00, #FF6B5B); box-shadow: 0 0 22px rgba(255,107,91,0.85); }
  .aion-gauge-ticks { position: absolute; inset: 0; display: flex; justify-content: space-evenly; pointer-events: none; }
  .aion-gauge-ticks span { width: 2px; height: 100%; background: rgba(10,14,36,0.55); }
  .aion-gauge-num { flex: 0 0 auto; min-width: calc(var(--sh) * 0.13); text-align: right; line-height: 1; }
  .aion-gauge-num b { font-size: calc(var(--sh) * 0.07); font-weight: 900; color: #fff; text-shadow: 0 3px 0 rgba(0,0,0,0.35); font-variant-numeric: tabular-nums; }
  .aion-gauge-num span { font-size: calc(var(--sh) * 0.03); font-weight: 700; color: rgba(255,255,255,0.55); margin-left: 2px; }
  .aion-gauge.hot .aion-gauge-num b { color: #FF6B5B; animation: aion-throb .5s ease-in-out infinite alternate; display: inline-block; }
  @keyframes aion-throb { from { transform: scale(1); } to { transform: scale(1.12); } }
  .aion-side {
    border: 0; border-radius: 24px; cursor: pointer; color: #fff; font-family: inherit;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(var(--sh) * 0.022);
    font-weight: 900; letter-spacing: 0.1em;
    transition: transform .08s ease, filter .12s ease, box-shadow .2s ease;
    position: relative; overflow: hidden;
  }
  .aion-side::before { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,0.22), rgba(255,255,255,0) 40%); pointer-events: none; }
  .aion-side:disabled { cursor: default; filter: saturate(0.35) brightness(0.55); }
  .aion-side.truth:disabled { filter: none; box-shadow: 0 0 0 4px #fff, 0 0 40px rgba(255,255,255,0.5); }
  .aion-side:hover:not(:disabled) { transform: scale(1.02); filter: brightness(1.08); }
  .aion-side:active:not(:disabled) { transform: scale(0.98); }
  .aion-side-ai   { background: linear-gradient(180deg, #78C8F0 0%, #3CA0C8 50%, #1F5F94 100%); box-shadow: 0 8px 0 #143C60, 0 18px 44px rgba(60,160,200,0.45); }
  .aion-side-real { background: linear-gradient(180deg, #FFD166 0%, #F08700 55%, #C25A00 100%); box-shadow: 0 8px 0 #7A3A00, 0 18px 44px rgba(240,135,0,0.45); }
  .aion-side-real .aion-side-label, .aion-side-real .aion-ico { color: #1a0d00; text-shadow: 0 2px 0 rgba(255,255,255,0.25); }
  .aion-side-real .aion-ico { filter: none; }
  .aion-ico { width: calc(var(--sh) * 0.11); height: calc(var(--sh) * 0.11); color: #fff; filter: drop-shadow(0 4px 8px rgba(0,0,0,0.3)); }
  .aion-side-label { font-size: calc(var(--sh) * 0.052); line-height: 1; text-align: center; text-shadow: 0 3px 0 rgba(0,0,0,0.25); }
  .aion-side .aion-key { margin-top: calc(var(--sh) * 0.006); }

  .aion-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(var(--sh) * 0.018); min-height: 0; }
  /* Explicit 4:3 size from the stage height — deterministic, no
     aspect-ratio-inside-grid guesswork. 0.88 x 0.66 of --sh leaves
     room for the timer under it and the buttons beside it. */
  .aion-frame {
    position: relative; width: calc(var(--sh) * 0.907); height: calc(var(--sh) * 0.68); flex: 0 0 auto; border-radius: 20px; overflow: hidden;
    background: #0A0E24; border: calc(var(--sh) * 0.008) solid #fff;
    box-shadow: 0 0 0 calc(var(--sh) * 0.006) #14143C, 0 0 0 calc(var(--sh) * 0.009) rgba(120,200,240,0.7), 0 24px 60px rgba(0,0,0,0.55);
    transition: box-shadow .18s ease, border-color .18s ease;
  }
  .aion-frame.ok  { border-color: #34D399; box-shadow: 0 0 0 calc(var(--sh) * 0.006) #14143C, 0 0 0 calc(var(--sh) * 0.011) #34D399, 0 0 70px rgba(52,211,153,0.6); }
  .aion-frame.bad { border-color: #FF6B5B; box-shadow: 0 0 0 calc(var(--sh) * 0.006) #14143C, 0 0 0 calc(var(--sh) * 0.011) #FF6B5B, 0 0 70px rgba(255,107,91,0.6); }
  .aion-photo { width: 100%; height: 100%; object-fit: cover; display: block; }
  .aion-reveal {
    position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(var(--sh) * 0.02);
    background: rgba(10,14,36,0.5);
    animation: aion-pop .18s ease-out;
  }
  .aion-stamp {
    font-size: calc(var(--sh) * 0.13); font-weight: 900; letter-spacing: 0.12em; line-height: 1;
    padding: calc(var(--sh) * 0.01) calc(var(--sh) * 0.04);
    border: calc(var(--sh) * 0.01) solid currentColor; border-radius: 12px;
    transform: rotate(-9deg); text-shadow: 0 4px 0 rgba(0,0,0,0.25);
    box-shadow: 0 0 0 calc(var(--sh) * 0.004) rgba(0,0,0,0.25), 0 20px 40px rgba(0,0,0,0.4);
    background: rgba(10,14,36,0.35);
    animation: aion-stamp .28s cubic-bezier(.2,1.6,.4,1);
  }
  .aion-stamp.ai   { color: #9FDCFF; }
  .aion-stamp.real { color: #FFD166; }
  @keyframes aion-stamp { from { transform: rotate(-9deg) scale(1.6); opacity: 0; } to { transform: rotate(-9deg) scale(1); opacity: 1; } }
  .aion-verdict {
    font-size: calc(var(--sh) * 0.034); font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase;
    padding: calc(var(--sh) * 0.01) calc(var(--sh) * 0.03); border-radius: 999px; color: #0A0E24;
  }
  .aion-verdict.ok   { background: #34D399; }
  .aion-verdict.bad  { background: #FF6B5B; }
  .aion-verdict.late { background: #FFD166; }
  @keyframes aion-pop { from { opacity: 0; } to { opacity: 1; } }

  /* Score */
  .aion-score { text-align: center; display: flex; flex-direction: column; align-items: center; gap: calc(var(--sh) * 0.02); }
  .aion-bigscore { display: flex; align-items: baseline; gap: calc(var(--sh) * 0.02); line-height: 1; }
  .aion-bigscore b { font-size: calc(var(--sh) * 0.2); font-weight: 900; background: linear-gradient(180deg, #fff, #9fd4f2); -webkit-background-clip: text; background-clip: text; color: transparent; filter: drop-shadow(0 6px 0 rgba(20,20,60,0.6)); }
  .aion-bigscore span { font-size: calc(var(--sh) * 0.07); font-weight: 300; color: rgba(255,255,255,0.6); }
  .aion-results { display: flex; gap: calc(var(--sh) * 0.024); justify-content: center; flex-wrap: wrap; max-width: calc(var(--sh) * 1.5); }
  .aion-res { position: relative; width: calc(var(--sh) * 0.22); aspect-ratio: 4 / 3; border-radius: 12px; overflow: hidden; border: calc(var(--sh) * 0.005) solid #fff; box-shadow: 0 12px 30px rgba(0,0,0,0.5); }
  .aion-res img { width: 100%; height: 100%; object-fit: cover; display: block; }
  .aion-res.ok  { border-color: #34D399; }
  .aion-res.bad { border-color: #FF6B5B; filter: saturate(0.7); }
  .aion-res-badge {
    position: absolute; top: calc(var(--sh) * 0.01); right: calc(var(--sh) * 0.01);
    width: calc(var(--sh) * 0.045); height: calc(var(--sh) * 0.045); border-radius: 50%;
    display: flex; align-items: center; justify-content: center; font-size: calc(var(--sh) * 0.026); font-weight: 900; color: #0A0E24;
    background: #34D399; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
  }
  .aion-res.bad .aion-res-badge { background: #FF6B5B; }
  .aion-res-truth {
    position: absolute; left: calc(var(--sh) * 0.01); bottom: calc(var(--sh) * 0.01);
    font-size: calc(var(--sh) * 0.018); font-weight: 900; letter-spacing: 0.12em;
    padding: calc(var(--sh) * 0.004) calc(var(--sh) * 0.012); border-radius: 6px; background: rgba(10,14,36,0.8);
  }
  .aion-res-truth.ai { color: #9FDCFF; } .aion-res-truth.real { color: #FFD166; }

  /* Reward */
  .aion-reward { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: calc(var(--sh) * 0.024); }
  .aion-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; image-rendering: pixelated; }
  .aion-prize-wrap { position: relative; padding: calc(var(--sh) * 0.006); border-radius: 26px; background: linear-gradient(135deg, rgba(120,200,240,0.5), rgba(240,135,0,0.5)); }
  .aion-prize-wrap.landed { background: conic-gradient(from 0deg, #78C8F0, #F08700, #FFD166, #3CA0C8, #78C8F0); animation: aion-land .4s cubic-bezier(.2,1.4,.4,1); box-shadow: 0 0 60px rgba(240,135,0,0.35), 0 30px 80px rgba(0,0,0,0.55); }
  @keyframes aion-land { from { transform: scale(0.88); } to { transform: scale(1); } }
  /* Fixed geometry: the card has a set width and every text slot a set
     height, so the picture frame never shifts while the shuffle cycles
     through names of different lengths. */
  .aion-prize-card {
    text-align: center; display: flex; flex-direction: column; align-items: center; gap: calc(var(--sh) * 0.016);
    width: calc(var(--sh) * 0.78); box-sizing: border-box;
    padding: calc(var(--sh) * 0.03) calc(var(--sh) * 0.05); border-radius: 22px;
    background: linear-gradient(180deg, #1a2456, #0f1533);
  }
  .aion-prize-card .aion-eyebrow { height: calc(var(--sh) * 0.026); line-height: calc(var(--sh) * 0.026); }
  .aion-prize-frame { width: calc(var(--sh) * 0.32); height: calc(var(--sh) * 0.32); flex: 0 0 auto; border-radius: 18px; background: #fff; display: flex; align-items: center; justify-content: center; overflow: hidden; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06); }
  .aion-prize-img { width: 100%; height: 100%; object-fit: contain; }
  .aion-prize-ph { font-size: calc(var(--sh) * 0.17); }
  .aion-prize-name {
    margin: 0; font-size: calc(var(--sh) * 0.046); font-weight: 900; letter-spacing: 0.04em; line-height: 1.15;
    height: calc(var(--sh) * 0.106); width: 100%;
    display: flex; align-items: center; justify-content: center; overflow: hidden;
  }
  .aion-prize-card .aion-sub { height: calc(var(--sh) * 0.04); line-height: calc(var(--sh) * 0.04); font-size: calc(var(--sh) * 0.024); }
  .spinning .aion-prize-name { opacity: 0.85; }
  .aion-retry { position: relative; }
`;

Object.assign(window, { PageAiOrNot });
