// shared.jsx — formatting, common hooks (kept lean for the meme rebuild)

const { useState, useEffect, useRef, useMemo, useCallback } = React;

const COINS = ['SOL','USDC','USDT'];
const COIN_USD = { SOL: 168.42, USDC: 1.00, USDT: 1.00 };

// 'VideoPoker' → 'video poker' (so capitalize-CSS renders 'Video Poker').
// All other names lowercase cleanly. `gameColor` accepts both forms.
function normGameName(g) {
  if (!g) return '?';
  if (g === 'VideoPoker') return 'video poker';
  return g.toLowerCase();
}

function fmtUSD(n){ if (n>=1000) return '$'+n.toLocaleString('en-US',{maximumFractionDigits:0}); return '$'+n.toLocaleString('en-US',{maximumFractionDigits:2}); }

// Project the user's wallet.history into the live-feed shape. Wins only,
// newest-first (history is already newest-first per logPlay), capped at `max`.
function useLiveWins(history, username, max=8){
  return useMemo(() => {
    if (!history || !history.length) return [];
    const out = [];
    const u = username || 'you';
    for (const e of history) {
      if (!e.win) continue;
      const profit = (e.payout || 0) - (e.bet || 0);
      if (profit <= 0) continue;
      const coin = e.coin || 'SOL';
      const decimals = coin === 'SOL' ? 3 : 2;
      out.push({
        id: (e.hex || String(e.t)) + ':' + out.length,
        user: u,
        game: normGameName(e.game),
        coin,
        amt: +profit.toFixed(decimals),
        usd: +(profit * (COIN_USD[coin] || 0)).toFixed(2),
        mult: e.bet > 0 ? +(((e.payout || 0) / e.bet)).toFixed(2) : 1,
        t: e.t,
      });
      if (out.length >= max) break;
    }
    return out;
  }, [history, username, max]);
}
// Path B (§5.62): deployed Cloudflare Worker URL that owns RNG + outcome
// authority for migrated games. Phase 1 only flip is server-resolved; other
// games keep computing locally until they migrate. If this URL stops
// working, flip will fail closed (refunds the bet, surfaces an error) —
// other games still work because they don't use the Worker. The casino
// degrades gracefully to "no flip" rather than to "no casino."
const RC_SERVER_URL = 'https://run-casino-arbiter.runcasino-dreadco.workers.dev';

// callServerGame — POST to a server-resolved game endpoint. Attaches the
// current Firebase Anonymous Auth ID token in the Authorization header so
// the Worker can verify the caller. Throws on any non-OK response (caller
// handles refund + user-facing error).
async function callServerGame(path, body) {
  if (typeof firebase === 'undefined' || !firebase.auth) {
    throw new Error('firebase not loaded');
  }
  const user = firebase.auth().currentUser;
  if (!user) throw new Error('not signed in (firebase anon auth)');
  const idToken = await user.getIdToken();
  const res = await fetch(RC_SERVER_URL + path, {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer ' + idToken,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body || {}),
  });
  let data;
  try { data = await res.json(); }
  catch { data = { error: 'non-JSON response from server (HTTP ' + res.status + ')' }; }
  if (!res.ok || (data && data.error)) {
    throw new Error((data && data.error) || ('server error ' + res.status));
  }
  return data;
}

// useAnonAuth — exposes the Firebase Anonymous Auth UID once sign-in resolves.
// Sign-in itself is fired in index.html on boot; this hook just subscribes to
// the auth state change so consumers re-render when the UID becomes available.
// Returns null until sign-in completes (typically <300ms on warm caches; can be
// ~1s on a fresh device).
function useAnonAuth() {
  const [uid, setUid] = useState(() => {
    try { return firebase?.auth?.()?.currentUser?.uid || null; } catch { return null; }
  });
  useEffect(() => {
    if (typeof firebase === 'undefined' || !firebase.auth) return;
    const unsub = firebase.auth().onAuthStateChanged(u => setUid(u ? u.uid : null));
    return () => unsub();
  }, []);
  return uid;
}

// useOnlineCount — real Firebase presence count. Counts active entries under
// /presence/. Each connected client writes its own /presence/{uid} with an
// .onDisconnect().remove() so dropped clients clear within ~5s (Firebase's
// connection-loss detection window). Returns 0 until presence sync arrives.
function useOnlineCount() {
  const [n, setN] = useState(0);
  useEffect(() => {
    if (typeof firebase === 'undefined') return;
    const ref = firebase.database().ref('presence');
    const handler = ref.on('value', snap => {
      setN(snap.numChildren());
    }, () => {});
    return () => ref.off('value', handler);
  }, []);
  return n;
}

// usePresence — write this client's presence to /presence/{uid} with the
// current channel + timestamp, refresh every 30s, and clear on disconnect.
// Called from App with the current chatChannel. Inert until the anon UID
// resolves. Channel-change re-runs the effect so the breakdown stays current.
function usePresence(uid, channel) {
  useEffect(() => {
    if (!uid || typeof firebase === 'undefined') return;
    const ref = firebase.database().ref('presence/' + uid);
    const write = () => ref.set({
      channel: channel || 'general',
      t: firebase.database.ServerValue.TIMESTAMP,
    }).catch(() => {});
    write();
    // onDisconnect must be registered every effect run (channel change)
    // because it's tied to the current connection's pending-op queue.
    ref.onDisconnect().remove();
    const id = setInterval(write, 30000);
    return () => { clearInterval(id); };
    // intentionally NOT removing the presence row on unmount — the React
    // tree unmounts on full page navigation away too, and we want the row
    // to live until the socket closes (which onDisconnect handles).
  }, [uid, channel]);
}

// logPlayServer — fire-and-forget write to /plays/{auto-id}. Mirrors the
// existing browser-side wallet.logPlay shape (game, bet, payout, win, coin,
// hex). Reads are admin-UID-gated by Firebase rules; writes are open with
// shape validation. Caveat (HANDOFF §16.5): self-reported telemetry — a
// user running modified JS can write arbitrary rows. Trustworthy data
// requires the Path B server-authoritative migration.
function logPlayServer(entry) {
  if (typeof firebase === 'undefined') return Promise.resolve(null);
  const uid = firebase.auth?.()?.currentUser?.uid || null;
  if (!uid) return Promise.resolve(null);
  const e = entry || {};
  // Coerce to the rule-validated shape. Unknown fields are dropped so the
  // shape validation in Firebase doesn't reject the whole write.
  const row = {
    game:   String(e.game || '?').slice(0, 24),
    bet:    Number(e.bet) || 0,
    payout: Number(e.payout) || 0,
    win:    !!e.win,
    coin:   String(e.coin || 'SOL').slice(0, 8),
    hex:    String(e.hex || '').slice(0, 64),
    user:   String(e.user || 'anon').slice(0, 24),
    uid,
    t: firebase.database.ServerValue.TIMESTAMP,
  };
  return firebase.database().ref('plays').push(row).catch(err => {
    // Don't spam the console — rule rejections are expected during dev
    // before the rules JSON is pasted. Log once per page load.
    if (!logPlayServer.__warned) {
      console.warn('[plays] write failed:', err && err.message);
      logPlayServer.__warned = true;
    }
  });
}

// logClientError — push a row to /errors/ with a 100-row ring cap. The cap is
// enforced client-side: before pushing, read /errors/ ordered by t ascending,
// limited to 100, and delete the oldest if at-or-over capacity. The rules also
// accept the write open + shape-validated (admin-UID-only read is set up so
// non-admin clients can't scrape error data).
function logClientError(payload) {
  if (typeof firebase === 'undefined' || !firebase.auth?.()?.currentUser) return;
  const e = payload || {};
  const row = {
    msg:  String(e.msg || '').slice(0, 500),
    page: String(e.page || location?.pathname || '/').slice(0, 80),
    ua:   String(e.ua || navigator?.userAgent || '').slice(0, 200),
    t:    firebase.database.ServerValue.TIMESTAMP,
  };
  const errsRef = firebase.database().ref('errors');
  // Trim then push. Read-then-delete-oldest keeps the path bounded at ~100
  // without a server-side cron. Non-atomic across clients but cap-violations
  // are bounded (worst case: N concurrent writers each adding 1 over cap).
  errsRef.orderByChild('t').limitToFirst(1).once('value', snap => {
    errsRef.once('value', allSnap => {
      const count = allSnap.numChildren();
      if (count >= 100) {
        snap.forEach(child => { errsRef.child(child.key).remove().catch(() => {}); });
      }
      errsRef.push(row).catch(() => {});
    }).catch(() => {});
  });
}

// useFirebaseList — generic live subscriber for any RTDB path. Returns the
// list of children as [{id, ...val}, ...] sorted by `orderBy` ascending. Used
// by the admin page to read /plays, /errors, /presence. The path is not
// subscribed until `enabled` is true (admin page mounts the hook eagerly but
// only enables it after the wallet-match auth gate passes — avoids triggering
// permission_denied reads for non-admin viewers).
function useFirebaseList(path, { orderBy = 't', limit = 1000, enabled = true } = {}) {
  const [items, setItems] = useState([]);
  const [err, setErr] = useState(null);
  useEffect(() => {
    if (!enabled || typeof firebase === 'undefined') { setItems([]); return; }
    const ref = firebase.database().ref(path).orderByChild(orderBy).limitToLast(limit);
    const handler = ref.on('value', snap => {
      const arr = [];
      snap.forEach(child => {
        const v = child.val();
        if (v) arr.push({ id: child.key, ...v });
      });
      setItems(arr);
      setErr(null);
    }, e => setErr(e && e.message));
    return () => ref.off('value', handler);
  }, [path, orderBy, limit, enabled]);
  return { items, err };
}

// useFirebaseConnected — Firebase's built-in connection state. true while the
// SDK has an open socket to the RTDB, false during reconnects. Used by the
// admin Ops tab as a live pill.
function useFirebaseConnected() {
  const [up, setUp] = useState(true);
  useEffect(() => {
    if (typeof firebase === 'undefined') return;
    const ref = firebase.database().ref('.info/connected');
    const handler = ref.on('value', snap => setUp(!!snap.val()), () => {});
    return () => ref.off('value', handler);
  }, []);
  return up;
}

// Auxiliary backend: Firebase Realtime Database. Each chat channel maps to
// `chats/{channel}/` in the DB. Messages are { u, m, t } where `t` is a
// server timestamp (rule-enforced). Pulls the last 50 messages live and
// fires a re-render on any change. The compat-build `firebase` global is
// initialized in index.html.
function useFirebaseChat(channel) {
  const [msgs, setMsgs] = useState([]);
  useEffect(() => {
    if (!channel || typeof firebase === 'undefined') return;
    const ref = firebase.database().ref('chats/' + channel).orderByChild('t').limitToLast(50);
    const handler = ref.on('value', snap => {
      const arr = [];
      snap.forEach(child => {
        const v = child.val();
        if (v && v.u && v.m && v.t) arr.push({ id: child.key, u: v.u, m: v.m, t: v.t });
      });
      setMsgs(arr);
    }, err => console.error('[chat] read failed:', err && err.message));
    return () => ref.off('value', handler);
  }, [channel]);
  return msgs;
}

// 500ms client-side guard so a stuck-on-enter doesn't flood the DB.
// Server rules cap message length too, but we trim here to surface errors
// before the round-trip.
let __chatLastSendMs = 0;
function sendChatMessage(channel, username, text) {
  if (!channel || typeof firebase === 'undefined') return Promise.resolve(null);
  const now = Date.now();
  if (now - __chatLastSendMs < 500) return Promise.resolve(null);
  __chatLastSendMs = now;
  const m = String(text || '').slice(0, 280).trim();
  if (!m) return Promise.resolve(null);
  const u = String(username || 'anon').slice(0, 24) || 'anon';
  return firebase.database().ref('chats/' + channel).push({
    u, m, t: firebase.database.ServerValue.TIMESTAMP,
  }).catch(err => console.error('[chat] send failed:', err && err.message));
}

// SVG crypto glyphs
function CoinIcon({ coin='SOL', size=20 }) {
  if (coin==='SOL') return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      <defs><linearGradient id={`solg${size}`} x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stopColor="#9945FF"/><stop offset="100%" stopColor="#14F195"/></linearGradient></defs>
      <path d="M5 7l3-2h12l-3 2H5z M5 13l3-2h12l-3 2H5z M5 19l3-2h12l-3 2H5z" fill={`url(#solg${size})`}/>
    </svg>
  );
  if (coin==='USDC') return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="10" fill="#2775ca"/>
      <text x="12" y="16.5" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="700" fontSize="13" fill="#fff">$</text>
    </svg>
  );
  if (coin==='USDT') return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="10" fill="#26a17b"/>
      <text x="12" y="16.5" textAnchor="middle" fontFamily="Arial, sans-serif" fontWeight="700" fontSize="13" fill="#fff">₮</text>
    </svg>
  );
  // Fallback — generic coin
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none">
      <circle cx="12" cy="12" r="10" fill="#888"/>
      <text x="12" y="17" textAnchor="middle" fontFamily="monospace" fontWeight="700" fontSize="13" fill="#fff">¤</text>
    </svg>
  );
}

// PlayModeBar — shown at the top of every game so the FUN/REAL toggle is
// always visible in-context. No wallet required while in fun mode.
function PlayModeBar({ palette: M, wallet, onConnect }) {
  const fun = wallet.funMode;
  const accent = fun ? M.yellow : M.green;
  const c = wallet.activeCoin;
  const bag = wallet.balance[c] ?? 0;
  return (
    <div style={{
      display:'flex', alignItems:'center', flexWrap:'wrap', gap:10,
      padding:'10px 14px', marginBottom:14,
      background: M.panel, border:`2px solid ${accent}`,
      boxShadow:`4px 4px 0 ${accent}`,
    }}>
      <span style={{
        background:accent, color:M.bg, padding:'2px 8px', fontSize:13, fontWeight:700, letterSpacing:'0.08em',
      }}>{fun ? '◉ PLAY FOR FUN' : '● REAL BAG'}</span>
      <span style={{fontSize:13, color:M.ink2}}>
        {fun ? 'no wallet required · play money' : (wallet.connected ? 'wallet connected' : 'wallet not connected')}
      </span>
      <span style={{fontSize:14, color:accent, fontWeight:700, marginLeft:6}}>
        bag: {bag.toFixed(c==='SOL'?3:2)} {c}
      </span>
      <div style={{flex:1, minWidth:8}}/>
      {fun ? (
        <>
          <button onClick={wallet.resetFun} title="reset fun bag to 10 SOL" style={{
            background:M.bg, color:accent, border:`1px solid ${accent}`,
            padding:'4px 10px', fontFamily:'inherit', fontSize:13, cursor:'pointer', fontWeight:700,
          }}>↻ reset</button>
          <button onClick={wallet.toggleFunMode} style={{
            background:M.bg, color:M.green, border:`1px solid ${M.green}`,
            padding:'4px 10px', fontFamily:'inherit', fontSize:13, cursor:'pointer',
          }}>switch to REAL</button>
        </>
      ) : (
        <>
          {!wallet.connected && (
            <button onClick={onConnect} style={{
              background:M.hot, color:M.bg, border:`2px solid ${M.bg}`,
              padding:'4px 12px', fontFamily:'inherit', fontSize:13, cursor:'pointer', fontWeight:700,
              boxShadow:`3px 3px 0 ${M.green}`,
            }}>► connect wallet</button>
          )}
          <button onClick={wallet.toggleFunMode} style={{
            background:M.yellow, color:M.bg, border:`2px solid ${M.bg}`,
            padding:'4px 12px', fontFamily:'inherit', fontSize:13, cursor:'pointer', fontWeight:700,
            boxShadow:`3px 3px 0 ${M.yellow}`,
          }}>► PLAY FOR FUN</button>
        </>
      )}
    </div>
  );
}

// useBetError — error string state with auto-dismiss after `timeoutMs`.
// Set to a string to show the toast; pass null to clear immediately.
function useBetError(timeoutMs = 3800) {
  const [error, setError] = useState(null);
  useEffect(() => {
    if (!error) return;
    const t = setTimeout(() => setError(null), timeoutMs);
    return () => clearTimeout(t);
  }, [error, timeoutMs]);
  return [error, setError];
}

// BetErrorToast — fixed-position popup used by every game when a bet is invalid.
// `error` is a string (the message) or null. `title` is optional; defaults to INSUFFICIENT BAG.
function BetErrorToast({ error, onClose, palette: M, title }) {
  if (!error) return null;
  return (
    <>
      <style>{`@keyframes errorPop {
        0%   { transform: translateX(24px) scale(0.96); opacity: 0; }
        60%  { transform: translateX(-4px) scale(1.02); opacity: 1; }
        100% { transform: translateX(0)    scale(1);    opacity: 1; }
      }`}</style>
      <div role="alert" style={{
        position:'fixed', top:96, right:32, zIndex:200, maxWidth:340,
        background:M.bg, border:`3px solid ${M.red}`, padding:'12px 16px',
        boxShadow:`6px 6px 0 ${M.red}`, animation:'errorPop 0.28s ease-out',
      }}>
        <div style={{display:'flex', alignItems:'flex-start', justifyContent:'space-between', gap:12}}>
          <div>
            <div style={{color:M.red, fontWeight:700, fontSize:14, letterSpacing:'0.05em', marginBottom:2}}>⚠ {title || 'INSUFFICIENT BAG'}</div>
            <div style={{color:M.ink, fontSize:13, lineHeight:1.4}}>{error}</div>
          </div>
          <button onClick={onClose} aria-label="dismiss" style={{
            background:'transparent', color:M.red, border:0, cursor:'pointer', fontSize:18, lineHeight:1, padding:0,
          }}>×</button>
        </div>
      </div>
    </>
  );
}

// Audio system — Web Audio API with HTMLAudioElement fallback.
//
// Why Web Audio: HTMLAudioElement has 100-300ms per-play latency on mobile
// browsers even when fully decoded. Web Audio decodes each file once into
// an AudioBuffer, and every play creates a fresh BufferSourceNode → near-
// zero latency on every play.
//
// Why fallback: Web Audio can fail silently on some devices (decodeAudioData
// rejecting a WAV, AudioContext stuck suspended, autoplay rules blocking
// resume). When that happens the HTMLAudio path still works — slower, but
// guaranteed to make sound. __play() tries Web Audio first and falls
// through to HTMLAudio if anything's not ready.

const __RUN_AUDIO_FILES = [
  'sounds/hit.wav',
  'sounds/win.wav',
  'sounds/loss.wav',
  'sounds/streak.wav',
  'sounds/jackpot.wav',
  'sounds/slot-spin.wav',
  'sounds/roulette-spin.mp3',
];
const __buffers = {};            // file → AudioBuffer (Web Audio path)
const __htmlAudios = {};         // file → HTMLAudioElement (fallback path)
const __activeWebSources = {};   // file → BufferSourceNode (ambient sounds, Web Audio)
let __audioCtx = null;

function __getCtx() {
  if (__audioCtx) return __audioCtx;
  try {
    const Ctx = window.AudioContext || window.webkitAudioContext;
    if (!Ctx) return null;
    __audioCtx = new Ctx();
  } catch { return null; }
  return __audioCtx;
}

// Module-init preload. Both paths warm up in parallel so whichever one wins
// the race, audio is ready by the time the user clicks.
(function preload() {
  // HTMLAudio path — always create + load(), no API call required, works in
  // every browser. Acts as the guaranteed fallback.
  __RUN_AUDIO_FILES.forEach(file => {
    try {
      const a = new Audio();
      a.preload = 'auto';
      a.src = file;
      a.load();
      __htmlAudios[file] = a;
    } catch {}
  });

  // Web Audio path — fetch + decode into AudioBuffer. decodeAudioData works
  // on a suspended AudioContext, so this can run pre-gesture.
  const ctx = __getCtx();
  if (ctx) {
    __RUN_AUDIO_FILES.forEach(file => {
      fetch(file)
        .then(r => r.arrayBuffer())
        .then(buf => new Promise((resolve, reject) => {
          // Callback form — better older-Safari compatibility than the Promise form.
          ctx.decodeAudioData(buf, resolve, reject);
        }))
        .then(decoded => { __buffers[file] = decoded; })
        .catch(() => {});
    });
  }
})();

// First-gesture unlock — resume the suspended AudioContext so Web Audio plays.
// The Web Audio path is what every gameplay sound uses, so this single call
// is enough. Earlier versions also did a muted play+pause on every HTMLAudio
// element to unlock the HTMLAudioElement subsystem for setTimeout-driven
// plays in the fallback path — but on some iOS builds the muted state isn't
// fully honored, producing an audible tick on the user's first tap anywhere
// on the page (including bet placements). Since Web Audio handles the
// gameplay path and the fallback HTMLAudio still plays correctly when
// triggered synchronously from a click handler, the mute-play unlock is
// removed.
(function attachAudioUnlock() {
  if (typeof document === 'undefined') return;
  function unlock() {
    const ctx = __getCtx();
    if (ctx && ctx.state === 'suspended') {
      try { ctx.resume(); } catch {}
    }
    document.removeEventListener('touchstart', unlock, true);
    document.removeEventListener('click', unlock, true);
  }
  document.addEventListener('touchstart', unlock, { capture: true });
  document.addEventListener('click', unlock, { capture: true });
})();

let __runSoundsMuted = (() => {
  try { return localStorage.getItem('run!soundsMuted') === '1'; } catch { return false; }
})();
function setSoundsMuted(muted) {
  __runSoundsMuted = !!muted;
  try { localStorage.setItem('run!soundsMuted', __runSoundsMuted ? '1' : '0'); } catch {}
}
function getSoundsMuted() { return __runSoundsMuted; }

// Play a sound. Tries Web Audio first; if the buffer isn't decoded yet or
// the AudioContext isn't running, falls back to HTMLAudioElement. `track`
// = true stores the active source so stop() can halt it (ambient sounds).
// `offset` = start position in seconds (roulette spin skips 0.5s fade-in).
function __play(file, volume = 0.7, offset = 0, track = false) {
  if (__runSoundsMuted) return;

  // Path A — Web Audio (preferred, near-zero latency)
  const ctx = __getCtx();
  const buf = __buffers[file];
  if (ctx && ctx.state === 'running' && buf) {
    try {
      const src = ctx.createBufferSource();
      src.buffer = buf;
      const gain = ctx.createGain();
      gain.gain.value = volume;
      src.connect(gain).connect(ctx.destination);
      src.start(0, offset);
      if (track) {
        const prev = __activeWebSources[file];
        if (prev) { try { prev.stop(); } catch {} }
        __activeWebSources[file] = src;
        src.onended = () => { if (__activeWebSources[file] === src) __activeWebSources[file] = null; };
      }
      return;
    } catch {}
  }

  // Path B — HTMLAudio fallback. For ambient (track=true) we reuse the
  // cached element so stop() has a target to pause; for one-shot sounds we
  // cloneNode so overlapping plays don't interrupt each other.
  const a = __htmlAudios[file];
  if (!a) return;
  try {
    if (track) {
      a.volume = volume;
      a.currentTime = offset;
      const p = a.play();
      if (p && typeof p.catch === 'function') p.catch(() => {});
    } else {
      const clone = a.cloneNode();
      clone.volume = volume;
      if (offset) clone.currentTime = offset;
      const p = clone.play();
      if (p && typeof p.catch === 'function') p.catch(() => {});
    }
  } catch {}
}

function __stop(file) {
  const src = __activeWebSources[file];
  if (src) {
    try { src.stop(); } catch {}
    __activeWebSources[file] = null;
  }
  const a = __htmlAudios[file];
  if (a) {
    try { a.pause(); a.currentTime = 0; } catch {}
  }
}

// playOutcomeSound — game-resolved sound. `true` = win, anything else = loss.
function playOutcomeSound(won) {
  __play(won ? 'sounds/win.wav' : 'sounds/loss.wav', 0.5);
}

// playHitSound — short tick for intermediate events (blackjack hits, dealing
// cadences, roulette spin start). Web Audio path mixes overlapping plays
// natively; HTMLAudio fallback uses cloneNode for the same effect.
function playHitSound() {
  __play('sounds/hit.wav', 0.5);
}

// playSlotSpinSound — slot-machine reel ambient when SPIN is clicked. Tracked
// so stopSlotSpinSound() can halt it.
function playSlotSpinSound() {
  __play('sounds/slot-spin.wav', 1.0, 0, true);
}
function stopSlotSpinSound() {
  __stop('sounds/slot-spin.wav');
}

// playRouletteSpinSound — wheel ambient when SPIN is clicked. Source mp3 has
// a fade-in soft-start; we skip past the first 0.5s so the spin opens at
// consistent full volume. stopRouletteSpinSound() fires at 3.0s (just before
// the 3.2s wheel settle) so playback covers a clean 3.0s window.
function playRouletteSpinSound() {
  __play('sounds/roulette-spin.mp3', 0.7, 0.5, true);
}
function stopRouletteSpinSound() {
  __stop('sounds/roulette-spin.mp3');
}

// playStreakSound — louder celebratory sound for mid-tier slot wins (5×–9×).
function playStreakSound() {
  __play('sounds/streak.wav', 0.75);
}

// playJackpotSound — top-tier slots fanfare: 20× on 3-reel, 200× on 6-reel.
function playJackpotSound() {
  __play('sounds/jackpot.wav', 0.85);
}

// coinMinBet — minimum bet in the active coin. SOL keeps the granular 0.01
// floor (sub-dollar variance is fine in SOL terms). USDC/USDT bump up to 1 so
// stablecoin tables don't accept silly fractional-cent stakes.
function coinMinBet(coin) {
  return coin === 'SOL' ? 0.01 : 1;
}

// useBetInput — shared bet-amount state for game pages. Tracks the displayed
// value as a string so the user can backspace to empty (without React snapping
// it back to "0"); exposes the parsed number for game logic. setBet accepts a
// number OR a functional updater (which receives the current parsed number).
//
// Input is sanitised to a positive plain-decimal pattern so pasted scientific
// notation ("1e10") and Infinity don't sneak through parseFloat (audit LOW-14).
// The sanitised string is what's stored — so the input field also reflects
// the cleaned value rather than a hidden divergence between display and parse.
function sanitiseBetStr(raw) {
  if (typeof raw !== 'string') raw = String(raw ?? '');
  // Drop everything except digits and at most one decimal point. Cap length
  // at 20 chars to limit DoS on absurd paste.
  let out = '';
  let dotSeen = false;
  for (const ch of raw.slice(0, 20)) {
    if (ch >= '0' && ch <= '9') out += ch;
    else if (ch === '.' && !dotSeen) { out += ch; dotSeen = true; }
  }
  return out;
}
function useBetInput(initial) {
  const [betStr, setBetStrRaw] = React.useState(String(initial));
  const setBetStr = React.useCallback((v) => setBetStrRaw(sanitiseBetStr(v)), []);
  const bet = parseFloat(betStr);
  const safeBet = Number.isFinite(bet) && bet >= 0 ? bet : 0;
  const setBet = (v) => setBetStr(String(typeof v === 'function' ? v(safeBet) : v));
  return { bet: safeBet, setBet, betStr, setBetStr };
}

// maxBetForCoin — convert a SOL-denominated max into the active coin. USDC
// and USDT are pegged 1:1 with USD, so a SOL cap of 10 becomes 10 × COIN_USD.SOL
// (≈ 1684.2) when the active coin is USDC/USDT.
function maxBetForCoin(maxSol, coin) {
  return coin === 'SOL' ? maxSol : maxSol * COIN_USD.SOL;
}

// LastRoundsTable — compact "last N rounds" history table used inside every
// game's stats panel. Pass already-filtered rounds (most-recent-first), the
// header strings (excluding the implicit "#" column), and a renderRow function
// that returns an array of cell objects in the same order as the headers.
//   cell = { value, color?, fontWeight? }
// First column is left-aligned (typically the game-specific outcome label),
// the rest are right-aligned numerics.
function LastRoundsTable({ M, rounds, headers, renderRow, limit = 10 }) {
  if (!rounds || rounds.length === 0) return null;
  const slice = rounds.slice(0, limit);
  return (
    <div style={{marginTop:14, paddingTop:14, borderTop:`1px dashed ${M.green}44`}}>
      <div style={{fontSize:11, color:M.ink2, letterSpacing:'0.08em', marginBottom:6, fontWeight:700}}>┤ LAST {slice.length} ROUND{slice.length===1?'':'S'} ├</div>
      <div style={{display:'grid', gridTemplateColumns:`28px repeat(${headers.length}, 1fr)`, columnGap:12, rowGap:3, fontSize:13, fontFamily:'JetBrains Mono, monospace', alignItems:'center'}}>
        <span style={{color:M.ink2, fontSize:11}}>#</span>
        {headers.map((h, i) => (
          <span key={i} style={{color:M.ink2, fontSize:11, textAlign: i === 0 ? 'left' : 'right'}}>{h}</span>
        ))}
        {slice.map((r, i) => {
          const cells = renderRow(r, i);
          return (
            <React.Fragment key={i}>
              <span style={{color:M.ink2}}>{i+1}</span>
              {cells.map((c, j) => (
                <span key={j} style={{textAlign: j === 0 ? 'left' : 'right', color: c.color || M.ink, fontWeight: c.fontWeight || 400}}>{c.value}</span>
              ))}
            </React.Fragment>
          );
        })}
      </div>
    </div>
  );
}

// useSolPrice — polls CoinGecko every 5 minutes for the current SOL/USD spot
// price and mutates COIN_USD.SOL so maxBetForCoin, fmtUSD, and any other
// consumers pick up the live rate automatically. Falls back to the seed value
// (168.42) if the network call fails.
function useSolPrice() {
  const [price, setPrice] = useState(COIN_USD.SOL);
  const [updatedAt, setUpdatedAt] = useState(null);
  useEffect(() => {
    let cancelled = false;
    async function fetchPrice() {
      try {
        const r = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=solana&vs_currencies=usd');
        if (!r.ok) return;
        const j = await r.json();
        const p = j?.solana?.usd;
        if (!cancelled && typeof p === 'number' && p > 0) {
          COIN_USD.SOL = p; // shared constant — readers (maxBetForCoin, etc.) pick this up
          setPrice(p);
          setUpdatedAt(Date.now());
        }
      } catch {}
    }
    fetchPrice();
    const id = setInterval(fetchPrice, 5 * 60 * 1000);
    return () => { cancelled = true; clearInterval(id); };
  }, []);
  return { price, updatedAt };
}

// Standard quick-bet preset values used across every game.
const QUICK_BET_VALUES = [0.1, 0.25, 0.5, 1, 2];

// QuickBetButtons — horizontal row of preset bet sizes. Click to set the bet
// directly. Active value highlights yellow.
function QuickBetButtons({ values = QUICK_BET_VALUES, current, onPick, palette: M, disabled }) {
  return (
    <div style={{display:'grid', gridTemplateColumns:`repeat(${values.length}, 1fr)`, gap:4}}>
      {values.map(v => {
        const active = current === v;
        return (
          <button key={v} onClick={()=>onPick(v)} disabled={disabled} style={{
            background: active ? M.yellow : M.bg,
            color:      active ? M.bg     : M.yellow,
            border:`2px solid ${M.yellow}`, fontFamily:'inherit', fontSize:14, fontWeight:700,
            padding:'6px 0', cursor: disabled?'not-allowed':'pointer',
            boxShadow: active ? `3px 3px 0 ${M.bg}` : 'none',
            letterSpacing:'0.03em',
          }}>{v}</button>
        );
      })}
    </div>
  );
}

// HashPanel — provably-fair section shown on every game page.
//   round hash = sha256(server_seed : client_seed : nonce) — the round's outcome anchor
//   seed       = server-seed commitment (sha256 of the secret server seed)
//   salt       = the client seed used for this round (a.k.a. user-supplied salt)
//   client     = the client seed (same value as salt — exposed under both labels for clarity)
//   nonce      = round counter used in the hashing input
//
// IMPORTANT: rows are inlined (not extracted into a nested component) so that
// parent re-renders don't unmount/remount the value <span>s — that would lose
// the user's text selection mid-copy.
function HashPanel({ palette: M, fairness }) {
  const hash = (fairness.history && fairness.history[0]?.hex) || '— roll a round to populate —';
  const labelStyle = { color: M.ink2 };
  const valueStyle = { wordBreak:'break-all', overflowWrap:'anywhere', userSelect:'text', cursor:'text' };
  return (
    <div style={{
      marginTop:20, padding:'14px 16px 12px',
      background:'#1a1a1a', border:`2px solid ${M.ink2}77`,
      fontFamily:'JetBrains Mono, monospace', fontSize:12, lineHeight:1.55,
      position:'relative', userSelect:'text',
    }}>
      <div style={{
        position:'absolute', top:-11, left:14, background:M.bg, padding:'0 8px',
        color:M.ink2, fontSize:12, letterSpacing:'0.12em', fontWeight:700,
      }}>┤ HASH ├</div>
      <div style={{display:'grid', gridTemplateColumns:'92px 1fr', columnGap:10, rowGap:4, marginTop:2}}>
        <span style={labelStyle}>round hash:</span>
        <span style={{...valueStyle, color: M.green}}>{hash}</span>

        <span style={labelStyle}>commitment:</span>
        <span style={{...valueStyle, color: M.cyan}}>{fairness.serverHash}</span>

        <span style={labelStyle}>client seed:</span>
        <span style={{...valueStyle, color: M.yellow}}>{fairness.clientSeed}</span>

        <span style={labelStyle}>nonce:</span>
        <span style={{...valueStyle, color: M.ink}}>{String(fairness.nonce)}</span>
      </div>
      <div style={{marginTop:8, color:M.ink2, fontSize:11, opacity:0.85}}>
        {'>'} verify any round by recomputing hmac-sha256(server, client:nonce) on the fairness page.
      </div>
    </div>
  );
}

Object.assign(window, { COINS, COIN_USD, normGameName, fmtUSD, useLiveWins, useOnlineCount, useFirebaseChat, sendChatMessage, CoinIcon, PlayModeBar, useBetError, BetErrorToast, QUICK_BET_VALUES, QuickBetButtons, HashPanel, coinMinBet, maxBetForCoin, useBetInput, useSolPrice, LastRoundsTable, playOutcomeSound, playHitSound, playSlotSpinSound, stopSlotSpinSound, playRouletteSpinSound, stopRouletteSpinSound, playStreakSound, playJackpotSound, setSoundsMuted, getSoundsMuted, useAnonAuth, usePresence, logPlayServer, logClientError, useFirebaseList, useFirebaseConnected, RC_SERVER_URL, callServerGame });
