/* global React, ReactDOM */
const { useState, useEffect, useRef, useCallback } = React;

// =============================================================================
// Constants (from app/lib/constants.ts)
// =============================================================================
const DIFFICULTIES = {
  easy:   { tiles: 4, mines: 1, label: '3 safe, 1 bomb' },
  medium: { tiles: 3, mines: 1, label: '2 safe, 1 bomb' },
  hard:   { tiles: 2, mines: 1, label: '1 safe, 1 bomb' },
  expert: { tiles: 3, mines: 2, label: '1 safe, 2 bombs' },
  master: { tiles: 4, mines: 3, label: '1 safe, 3 bombs' },
};

const NUM_FLOORS = 8;
const MAX_BET = 100;
const MIN_BET = 1;
const HOUSE_EDGE = 0.035;

const GAME_UUID = 'tower_965';

// =============================================================================
// Helpers (from app/lib/game-logic.ts)
// =============================================================================
function getMultipliers(diff) {
  const { tiles, mines } = DIFFICULTIES[diff];
  const safeChance = (tiles - mines) / tiles;
  const targetRTP = 1 - HOUSE_EDGE;
  return Array.from({ length: NUM_FLOORS }, (_, i) =>
    +(targetRTP / Math.pow(safeChance, i + 1)).toFixed(2)
  );
}

function getReachProbability(diff, stage) {
  const { tiles, mines } = DIFFICULTIES[diff];
  const safeChance = (tiles - mines) / tiles;
  return Math.pow(safeChance, stage);
}

function fmt(n) {
  return '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

// =============================================================================
// Provably fair helpers (from app/lib/provably-fair.ts)
// =============================================================================
function randomHex(len) {
  const arr = new Uint8Array(len);
  crypto.getRandomValues(arr);
  return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
}

async function sha256(str) {
  const buf = new TextEncoder().encode(str);
  const hash = await crypto.subtle.digest('SHA-256', buf);
  return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('');
}

async function hmacSha256(key, message) {
  const keyBuf = new TextEncoder().encode(key);
  const msgBuf = new TextEncoder().encode(message);
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyBuf,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );
  const sig = await crypto.subtle.sign('HMAC', cryptoKey, msgBuf);
  return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, '0')).join('');
}

async function getMinePlacement(serverSeed, clientSeed, floor, nonce, tiles, mines) {
  const minePositions = new Set();
  let round = 0;
  while (minePositions.size < mines) {
    const msg = clientSeed + ':' + floor + ':' + nonce + ':' + round;
    const hmac = await hmacSha256(serverSeed, msg);
    let offset = 0;
    while (offset + 8 <= hmac.length && minePositions.size < mines) {
      const val = parseInt(hmac.substring(offset, offset + 8), 16);
      minePositions.add(val % tiles);
      offset += 8;
    }
    round++;
    if (round > 100) break; // safety limit
  }
  return minePositions;
}

// =============================================================================
// AudioEngine (from app/lib/audio.ts)
// =============================================================================
const AudioCtx = typeof window !== 'undefined'
  ? window.AudioContext || window.webkitAudioContext
  : undefined;

class AudioEngine {
  constructor() {
    this.ctx = null;
    this.masterGain = null;
    this.soundEnabled = true;
  }

  ensureCtx() {
    if (!this.ctx && AudioCtx) {
      this.ctx = new AudioCtx();
      this.masterGain = this.ctx.createGain();
      this.masterGain.gain.value = 0.5;
      this.masterGain.connect(this.ctx.destination);
    }
    if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume();
  }

  tone(freq, dur, type, vol, delay) {
    if (!this.soundEnabled) return;
    this.ensureCtx();
    if (!this.ctx || !this.masterGain) return;
    const t = this.ctx.currentTime + (delay || 0);
    const osc = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    osc.type = type || 'sine';
    osc.frequency.setValueAtTime(freq, t);
    g.gain.setValueAtTime(vol || 0.15, t);
    g.gain.setValueAtTime(vol || 0.15, t + dur * 0.3);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    osc.connect(g);
    g.connect(this.masterGain);
    osc.start(t);
    osc.stop(t + dur);
  }

  sweep(startFreq, endFreq, dur, type, vol, delay) {
    if (!this.soundEnabled) return;
    this.ensureCtx();
    if (!this.ctx || !this.masterGain) return;
    const t = this.ctx.currentTime + (delay || 0);
    const osc = this.ctx.createOscillator();
    const g = this.ctx.createGain();
    osc.type = type || 'sine';
    osc.frequency.setValueAtTime(startFreq, t);
    osc.frequency.exponentialRampToValueAtTime(endFreq, t + dur);
    g.gain.setValueAtTime(vol || 0.1, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    osc.connect(g);
    g.connect(this.masterGain);
    osc.start(t);
    osc.stop(t + dur);
  }

  noise(dur, vol, delay) {
    if (!this.soundEnabled) return;
    this.ensureCtx();
    if (!this.ctx || !this.masterGain) return;
    const t = this.ctx.currentTime + (delay || 0);
    const sz = Math.ceil(this.ctx.sampleRate * dur);
    const buf = this.ctx.createBuffer(1, sz, this.ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i = 0; i < sz; i++) d[i] = Math.random() * 2 - 1;
    const src = this.ctx.createBufferSource();
    const g = this.ctx.createGain();
    const filt = this.ctx.createBiquadFilter();
    filt.type = 'bandpass';
    filt.frequency.value = 3000;
    filt.Q.value = 0.7;
    src.buffer = buf;
    g.gain.setValueAtTime(vol || 0.05, t);
    g.gain.exponentialRampToValueAtTime(0.001, t + dur);
    src.connect(filt);
    filt.connect(g);
    g.connect(this.masterGain);
    src.start(t);
  }

  sndBet() {
    this.tone(880, 0.08, 'sine', 0.18);
    this.tone(1100, 0.1, 'sine', 0.2, 0.07);
    this.noise(0.04, 0.03);
  }

  sndClick() {
    this.tone(1200, 0.04, 'sine', 0.1);
    this.noise(0.025, 0.04);
  }

  sndWin() {
    this.tone(659,  0.12, 'sine', 0.18);
    this.tone(784,  0.12, 'sine', 0.2,  0.09);
    this.tone(988,  0.12, 'sine', 0.22, 0.18);
    this.tone(1319, 0.25, 'sine', 0.2,  0.27);
    this.tone(1319, 0.3, 'triangle', 0.06, 0.27);
    this.noise(0.06, 0.03, 0.27);
  }

  sndLose() {
    if (!this.soundEnabled) return;
    this.ensureCtx();
    if (!this.ctx || !this.masterGain) return;
    const t = this.ctx.currentTime;

    // Initial glass crack -- sharp high noise burst
    const crackLen = Math.ceil(this.ctx.sampleRate * 0.06);
    const crackBuf = this.ctx.createBuffer(1, crackLen, this.ctx.sampleRate);
    const crackData = crackBuf.getChannelData(0);
    for (let i = 0; i < crackLen; i++) crackData[i] = (Math.random() * 2 - 1) * Math.exp(-i / (crackLen * 0.15));
    const crackSrc = this.ctx.createBufferSource();
    const crackGain = this.ctx.createGain();
    const crackFilt = this.ctx.createBiquadFilter();
    crackFilt.type = 'highpass'; crackFilt.frequency.value = 4000;
    crackSrc.buffer = crackBuf;
    crackGain.gain.setValueAtTime(0.5, t);
    crackGain.gain.exponentialRampToValueAtTime(0.001, t + 0.06);
    crackSrc.connect(crackFilt); crackFilt.connect(crackGain); crackGain.connect(this.masterGain);
    crackSrc.start(t);

    // Shatter -- longer filtered noise with falling pitch
    const shatterLen = Math.ceil(this.ctx.sampleRate * 0.35);
    const shatterBuf = this.ctx.createBuffer(1, shatterLen, this.ctx.sampleRate);
    const shatterData = shatterBuf.getChannelData(0);
    for (let i = 0; i < shatterLen; i++) shatterData[i] = (Math.random() * 2 - 1);
    const shSrc = this.ctx.createBufferSource();
    const shGain = this.ctx.createGain();
    const shFilt = this.ctx.createBiquadFilter();
    shFilt.type = 'bandpass'; shFilt.frequency.setValueAtTime(6000, t + 0.03);
    shFilt.frequency.exponentialRampToValueAtTime(1500, t + 0.35); shFilt.Q.value = 1.2;
    shSrc.buffer = shatterBuf;
    shGain.gain.setValueAtTime(0.35, t + 0.03);
    shGain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
    shSrc.connect(shFilt); shFilt.connect(shGain); shGain.connect(this.masterGain);
    shSrc.start(t + 0.03);

    // Tinkle -- small glass pieces falling
    for (let i = 0; i < 5; i++) {
      const d = 0.08 + Math.random() * 0.25;
      const freq = 2000 + Math.random() * 4000;
      this.tone(freq, 0.04, 'sine', 0.14, d);
    }
  }

  sndCashout() {
    this.tone(523,  0.1,  'sine', 0.18);
    this.tone(659,  0.1,  'sine', 0.2,  0.08);
    this.tone(784,  0.1,  'sine', 0.2,  0.16);
    this.tone(1047, 0.12, 'sine', 0.22, 0.24);
    this.tone(1319, 0.3,  'sine', 0.25, 0.32);
    this.tone(1568, 0.25, 'triangle', 0.08, 0.36);
    this.tone(2093, 0.3,  'sine', 0.05, 0.4);
    this.noise(0.1, 0.04, 0.35);
    this.sweep(4000, 2000, 0.15, 'sine', 0.04, 0.34);
  }

  sndMaxWin() {
    this.sndCashout();
    this.tone(1047, 0.15, 'square', 0.06, 0.5);
    this.tone(1319, 0.15, 'square', 0.06, 0.6);
    this.tone(1568, 0.15, 'square', 0.06, 0.7);
    this.tone(2093, 0.4,  'sine',   0.1,  0.8);
    this.noise(0.2, 0.05, 0.8);
  }

  toggle() {
    this.soundEnabled = !this.soundEnabled;
    if (this.soundEnabled) this.sndClick();
    return this.soundEnabled;
  }
}

// =============================================================================
// RGS client (adapted to supernova-rgs endpoints)
// =============================================================================
class RgsError extends Error {
  constructor(message, status, code) {
    super(message);
    this.status = status;
    this.code = code;
  }
}

function getBaseUrl() {
  const runtimeUrl =
    (typeof window !== 'undefined' && window.RGS_CONFIG && window.RGS_CONFIG.apiBase) ||
    (typeof window !== 'undefined' && window.RGS_API_BASE);
  // Empty string means "same origin" — that's valid; only undefined/null is unconfigured.
  if (runtimeUrl === undefined || runtimeUrl === null) {
    throw new RgsError(
      'RGS base URL is not configured (window.RGS_CONFIG.apiBase or window.RGS_API_BASE)',
      0,
      'RGS_NOT_CONFIGURED',
    );
  }
  return String(runtimeUrl).replace(/\/$/, '');
}

function isRgsConfigured() {
  if (typeof window === 'undefined') return false;
  if (window.RGS_CONFIG && typeof window.RGS_CONFIG.apiBase === 'string') return true;
  if (typeof window.RGS_API_BASE === 'string') return true;
  return false;
}

async function rgsRequest(path, init) {
  init = init || {};
  const { token, headers, ...rest } = init;
  const finalHeaders = {
    'Content-Type': 'application/json',
    ...(headers || {}),
  };
  if (token) finalHeaders['Authorization'] = `Bearer ${token}`;

  let res;
  try {
    res = await fetch(`${getBaseUrl()}${path}`, { ...rest, headers: finalHeaders });
  } catch (e) {
    const msg = e instanceof Error ? e.message : 'network error';
    throw new RgsError(`RGS unreachable: ${msg}`, 0, 'NETWORK');
  }

  let body = null;
  const text = await res.text();
  if (text) {
    try { body = JSON.parse(text); } catch { body = text; }
  }

  if (!res.ok) {
    const errBody = body;
    const errMsg =
      (errBody && typeof errBody === 'object' && errBody.error)
        ? errBody.error
        : (errBody && typeof errBody === 'object' && errBody.error_description)
        ? errBody.error_description
        : `RGS ${res.status} ${res.statusText}`;
    const code =
      (errBody && typeof errBody === 'object' && (errBody.code || errBody.error_code))
        || undefined;
    throw new RgsError(errMsg, res.status, code);
  }

  return body;
}

const rgs = {
  version() {
    return rgsRequest('/api/v1/version', { method: 'GET' });
  },
  odds(merchant) {
    const m = merchant || 'DEMO';
    return rgsRequest(`/api/v1/odds?merchant=${encodeURIComponent(m)}`, { method: 'GET' });
  },
  state(token) {
    return rgsRequest('/api/v1/state', { method: 'GET', token });
  },
  bet(token, body) {
    return rgsRequest('/api/v1/bet', {
      method: 'POST',
      body: JSON.stringify(body),
      token,
    });
  },
  // supernova-rgs: POST /api/v1/round/:round_id/action  body {floor, tile}
  // Response envelope: { ..., game_data: { outcome, mines_revealed?, win_count, current_multiplier, next_multiplier, payout }, multiplier?, finished?, balance, ... }
  // Map game_data fields up to top-level so calling code can treat the response flat.
  async pick(token, body) {
    const { round_id, floor, tile } = body;
    const r = await rgsRequest(`/api/v1/round/${encodeURIComponent(round_id)}/action`, {
      method: 'POST',
      body: JSON.stringify({ floor, tile }),
      token,
    });
    const gd = (r && r.game_data) || {};
    return {
      ...r,
      round_id,
      floor,
      tile,
      outcome: gd.outcome != null ? gd.outcome : r.outcome,
      mine_positions: gd.mines_revealed != null ? gd.mines_revealed : (r.mine_positions || []),
      remaining_reveals: gd.remaining_reveals != null ? gd.remaining_reveals : r.remaining_reveals,
      win_count: gd.win_count != null ? gd.win_count : r.win_count,
      current_multiplier: gd.current_multiplier != null ? gd.current_multiplier : r.current_multiplier,
      next_floor_multiplier: gd.next_multiplier != null ? gd.next_multiplier : r.next_floor_multiplier,
      potential_payout: gd.payout != null ? gd.payout : r.potential_payout,
      finished: r.finished != null ? r.finished : gd.finished,
      multiplier: r.multiplier != null ? r.multiplier : gd.current_multiplier,
    };
  },
  cashout(token, body) {
    const { round_id } = body;
    return rgsRequest(`/api/v1/round/${encodeURIComponent(round_id)}/cashout`, {
      method: 'POST',
      body: JSON.stringify({}),
      token,
    });
  },
  setClientSeed(token, body) {
    return rgsRequest('/api/v1/client-seed', {
      method: 'POST',
      body: JSON.stringify(body),
      token,
    });
  },
  refreshSeed(token) {
    return rgsRequest('/api/v1/refresh-seed', { method: 'POST', token });
  },
  verify(body) {
    return rgsRequest('/api/v1/verify', {
      method: 'POST',
      body: JSON.stringify(body),
    });
  },
  async fetchFreeRounds(token) {
    // --- ?demo-free mock preview mode ---
    if (new URLSearchParams(window.location.search).has('demo-free')) {
      if (!rgs._demoFreeTracker) {
        // First call — initialise tracker, return full mock grant
        rgs._demoFreeTracker = { rounds_left: 5, rounds_used: 0 };
        return {
          _mock: true, id: 'mock', game_uuid: 'tower_965',
          bet_amount: '5.00', rounds_total: 5,
          rounds_used: 0, rounds_left: 5, status: 'active',
        };
      }
      // Subsequent calls — decrement rounds
      var t = rgs._demoFreeTracker;
      t.rounds_used += 1;
      t.rounds_left -= 1;
      if (t.rounds_left <= 0) {
        // Exhausted — clear tracker so summary triggers, return null
        rgs._demoFreeTracker = null;
        return null;
      }
      return {
        _mock: true, id: 'mock', game_uuid: 'tower_965',
        bet_amount: '5.00', rounds_total: 5,
        rounds_used: t.rounds_used, rounds_left: t.rounds_left, status: 'active',
      };
    }
    // --- end mock ---
    try {
      const r = await rgsRequest('/api/v1/freerounds', { method: 'GET', token });
      const grants = (r && r.grants) || [];
      const active = grants.find(
        (g) => g.status === 'active' && g.game_uuid === GAME_UUID && g.rounds_left > 0
      );
      return active || null;
    } catch {
      return null;
    }
  },
};

// =============================================================================
// Components
// =============================================================================
function Header(props) {
  const { balance, onFairPlayClick, onInfoClick } = props;
  return (
    <div className="header">
      <div className="header-left">
        <div className="game-name">
          <img className="ico" src="icon-tower.svg" alt="Tower" style={{height:20,width:20,filter:"brightness(0) invert(1)"}}/>
          <span>Tower</span>
        </div>
      </div>
      <div className="header-balance">
        <span className="header-bal-icon">{"💰"}</span>
        <span className="header-bal-value">{fmt(balance)}</span>
      </div>
      <div className="header-right">
        <div className="fairplay" onClick={onFairPlayClick}>
          Fair Play
        </div>
        <div className="info" onClick={onInfoClick}>
          i
        </div>
      </div>
    </div>
  );
}

// Banner component removed — replaced by result-badge inside the board area

function BottomBar(props) {
  const { soundEnabled, onSoundToggle, faved, setFaved } = props;
  return (
    <div className="bottom">
      <div className="bottom-icons">
        <div
          className={`ic sound-toggle${!soundEnabled ? " muted" : ""}`}
          title="Toggle Sound"
          onClick={onSoundToggle}
        >
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
            <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
            <path
              className="sound-waves"
              d="M15.54 8.46a5 5 0 0 1 0 7.07M19.07 4.93a10 10 0 0 1 0 14.14"
              style={{ display: soundEnabled ? undefined : "none" }}
            />
          </svg>
        </div>
        <div className={`ic${faved ? ' faved' : ''}`} title="Favorite" onClick={() => { const next = !faved; setFaved(next); localStorage.setItem('fav_tower', next); }}>
          <svg width="18" height="18" viewBox="0 0 24 24" fill={faved ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
            <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
          </svg>
        </div>
      </div>
      <div className="bottom-logo">MYBC</div>
    </div>
  );
}

const DIFF_OPTIONS = [
  { key: "easy", label: "Easy", safe: 3, broken: 1 },
  { key: "medium", label: "Medium", safe: 2, broken: 1 },
  { key: "hard", label: "Hard", safe: 1, broken: 1 },
  { key: "expert", label: "Expert", safe: 1, broken: 2 },
  { key: "master", label: "Master", safe: 1, broken: 3 },
];

function DiffDropdown(props) {
  const { value, onChange, disabled } = props;
  const [open, setOpen] = useState(false);
  const ref = useRef(null);

  useEffect(() => {
    function handleClickOutside(e) {
      if (ref.current && !ref.current.contains(e.target)) {
        setOpen(false);
      }
    }
    document.addEventListener("click", handleClickOutside);
    return () => document.removeEventListener("click", handleClickOutside);
  }, []);

  const found = DIFF_OPTIONS.find((o) => o.key === value);
  const currentLabel = (found && found.label) || "Easy";

  return (
    <div className="diff-dropdown" ref={ref}>
      <button
        className={`diff-dropdown-btn${open ? " open" : ""}`}
        onClick={() => {
          if (!disabled) setOpen(!open);
        }}
      >
        <span>{currentLabel}</span>
        <span className="diff-dropdown-arrow">&#9650;</span>
      </button>
      <div className={`diff-dropdown-list${open ? " show" : ""}`}>
        {DIFF_OPTIONS.map((opt) => (
          <button
            key={opt.key}
            className={`diff-dropdown-item${opt.key === value ? " active" : ""}`}
            data-diff={opt.key}
            onClick={() => {
              onChange(opt.key);
              setOpen(false);
            }}
          >
            <span>{opt.label}</span>
            <span className="diff-dropdown-icons">
              {Array.from({ length: opt.safe }, (_, i) => (
                <img key={`s${i}`} src="founded.png" alt="safe" />
              ))}
              {Array.from({ length: opt.broken }, (_, i) => (
                <img key={`b${i}`} src="broken.png" alt="bomb" />
              ))}
            </span>
          </button>
        ))}
      </div>
    </div>
  );
}

function Tile(props) {
  const { state, multiplier, onClick, disabled } = props;
  const classes = ["tile"];
  if (state === "safe") classes.push("revealed", "safe");
  else if (state === "mine") classes.push("revealed", "mine");
  else if (state === "dimmed") classes.push("dimmed");
  else if (state === "safe-path") classes.push("revealed", "safe-path");

  let content;
  if (state === "safe") {
    content = (
      <>
        <img src="founded.png" alt="gem" />
        <span className="tile-mult">x{multiplier}</span>
      </>
    );
  } else if (state === "mine") {
    content = (
      <>
        <img src="broken.png" alt="bomb" />
        <span className="tile-mult red">x{multiplier}</span>
      </>
    );
  } else if (state === "safe-path") {
    content = <img src="founded.png" alt="gem" />;
  } else {
    content = <img className="pile-icon" src="pile.svg" alt="pile" />;
  }

  return (
    <button className={classes.join(" ")} disabled={disabled} onClick={onClick}>
      {content}
    </button>
  );
}

function TowerFloor(props) {
  const {
    floorIdx,
    tilesCount,
    isActive,
    isCleared,
    isFailed,
    revealData,
    multiplier,
    onTilePick,
    disabled,
  } = props;
  const classes = ["tower-floor"];
  if (isActive) classes.push("active");
  if (isCleared) classes.push("cleared");
  if (isFailed) classes.push("failed");

  return (
    <div className={classes.join(" ")} data-floor={floorIdx}>
      <div className="floor-mult">{multiplier}x</div>
      <div className="tower-tiles">
        {Array.from({ length: tilesCount }, (_, t) => {
          const tileData = revealData[t];
          const tileState = tileData ? tileData.state : "unrevealed";
          const tileDisabled = disabled || !isActive || tileState !== "unrevealed";

          return (
            <Tile
              key={t}
              state={tileState}
              multiplier={multiplier}
              onClick={() => onTilePick(floorIdx, t)}
              disabled={tileDisabled}
            />
          );
        })}
      </div>
    </div>
  );
}

function TowerBoard(props) {
  const {
    tilesCount,
    currentFloor,
    floors,
    onTilePick,
    status,
    isPlaying,
    multipliers,
    disabled,
    resultBadge,
  } = props;
  const gridRef = useRef(null);

  useEffect(() => {
    if (currentFloor < 0 || !gridRef.current) return;
    const floorEl = gridRef.current.querySelector(`[data-floor="${currentFloor}"]`);
    if (!floorEl) return;
    const container = gridRef.current;
    const top =
      floorEl.offsetTop -
      container.offsetTop -
      container.clientHeight / 2 +
      floorEl.clientHeight / 2;
    container.scrollTo({ top, behavior: "smooth" });
  }, [currentFloor]);

  return (
    <div className="board">
      <div className="tower-zone">
        <div className="status">{status}</div>
        <div className="tower-grid" ref={gridRef}>
          {Array.from({ length: NUM_FLOORS }, (_, i) => {
            const floor = NUM_FLOORS - 1 - i;
            const floorData = floors[floor] || {
              isActive: false,
              isCleared: false,
              isFailed: false,
              tiles: [],
            };
            const mult = multipliers[floor];
            return (
              <TowerFloor
                key={floor}
                floorIdx={floor}
                tilesCount={tilesCount}
                isActive={floorData.isActive}
                isCleared={floorData.isCleared}
                isFailed={floorData.isFailed}
                revealData={floorData.tiles}
                multiplier={(mult != null ? mult : 0).toFixed(2)}
                onTilePick={onTilePick}
                disabled={disabled || !isPlaying}
              />
            );
          })}
        </div>
      </div>
      {resultBadge && (
        <div className={`result-badge ${resultBadge.type}`}>
          {resultBadge.text}
        </div>
      )}
    </div>
  );
}

function ManualPanel(props) {
  const {
    bet,
    difficulty,
    isPlaying,
    currentFloor,
    cashoutText,
    cashoutReady,
    onBetChange,
    onHalf,
    onDouble,
    onMax,
    onDiffChange,
    onPlaceBet,
    onCashOut,
    betInputFlash,
    freeGrant,
  } = props;

  const hasFree = freeGrant && freeGrant.rounds_left > 0;
  const freeProgress = hasFree ? (freeGrant.rounds_used / freeGrant.rounds_total) * 100 : 0;

  return (
    <div className="manual-panel">
      {hasFree && (
        <div className="free-bet-banner">
          <span className="free-bet-icon">{"🎁"}</span>
          <div className="free-bet-info">
            <div className="free-bet-title">FREE ROUNDS</div>
            <div className="free-bet-count">
              {freeGrant.rounds_left} of {freeGrant.rounds_total} remaining
            </div>
            <div className="free-bet-progress">
              <div
                className="free-bet-progress-fill"
                style={{ width: (100 - freeProgress) + "%" }}
              />
            </div>
          </div>
        </div>
      )}

      <div className="auto-section manual-bet-section">
        <div className="auto-section-label">Bet Amount</div>
        <div
          className={`input-row${hasFree ? " locked" : ""}`}
          style={
            betInputFlash
              ? { borderColor: "var(--red)", boxShadow: "0 0 0 2px rgba(221,65,65,0.3)" }
              : undefined
          }
        >
          <span className="currency">$</span>
          <input
            type="number"
            min="1"
            max="100"
            step="0.01"
            value={hasFree ? parseFloat(freeGrant.bet_amount).toFixed(2) : bet}
            placeholder="1.00"
            disabled={isPlaying || hasFree}
            onChange={(e) => onBetChange(e.target.value)}
          />
          <div className="chips">
            <button className="chip" disabled={isPlaying || hasFree} onClick={onHalf}>
              1/2
            </button>
            <button className="chip" disabled={isPlaying || hasFree} onClick={onDouble}>
              2x
            </button>
            <button className="chip" disabled={isPlaying || hasFree} onClick={onMax}>
              Max
            </button>
          </div>
        </div>
        <div style={{ display: "flex", justifyContent: "space-between", marginTop: "6px" }}>
          <span style={{ fontSize: "10px", color: "var(--text-dim)" }}>Min: $1.00</span>
          <span style={{ fontSize: "10px", color: "var(--text-dim)" }}>Max: $100.00</span>
        </div>
      </div>

      <div className="bet-amount-wrap manual-risk-section">
        <div className="label">Risk</div>
        <DiffDropdown
          value={difficulty}
          onChange={onDiffChange}
          disabled={currentFloor >= 0}
        />
      </div>

      <button
        className={`place-btn${hasFree ? " free-mode" : ""}`}
        disabled={isPlaying}
        onClick={onPlaceBet}
      >
        {hasFree ? "Free Round" : "Place Bet"}
      </button>
      <button
        className={`cashout-btn${cashoutReady ? " cashout-ready" : ""}`}
        disabled={!isPlaying}
        onClick={onCashOut}
      >
        {cashoutText}
      </button>
    </div>
  );
}

const CASHOUT_FLOORS = [1, 3, 5, 7, 8];
const ROUND_OPTIONS = [
  { value: 5, label: "5" },
  { value: 10, label: "10" },
  { value: 25, label: "25" },
  { value: 50, label: "50" },
  { value: 0, label: "∞" },
];

function AutoPanel(props) {
  const {
    bet,
    difficulty,
    onBetChange,
    onHalf,
    onDouble,
    onMax,
    onDiffChange,
    autoCashoutFloor,
    onCashoutFloorChange,
    autoTotalRounds,
    onTotalRoundsChange,
    stopWin,
    onStopWinChange,
    stopLoss,
    onStopLossChange,
    autoRunning,
    autoCurrentRound,
    autoWinCount,
    autoLossCount,
    autoStartBalance,
    currentBalance,
    onStart,
    onStop,
  } = props;
  const profitVal = currentBalance - autoStartBalance;
  const profitText = (profitVal >= 0 ? "+" : "") + fmt(profitVal);
  const profitClass = profitVal >= 0 ? "profit-pos" : "profit-neg";
  const progressWidth =
    autoTotalRounds > 0
      ? (autoCurrentRound / autoTotalRounds) * 100 + "%"
      : "100%";

  return (
    <div className="auto-panel">
      <div className="bet-amount-wrap">
        <div className="label">Bet Amount</div>
        <div className="input-row">
          <span className="currency">$</span>
          <input
            type="number"
            min="1"
            max="100"
            step="0.01"
            value={bet}
            placeholder="1.00"
            disabled={autoRunning}
            onChange={(e) => onBetChange(e.target.value)}
          />
          <div className="chips">
            <button className="chip" disabled={autoRunning} onClick={onHalf}>
              1/2
            </button>
            <button className="chip" disabled={autoRunning} onClick={onDouble}>
              2x
            </button>
            <button className="chip" disabled={autoRunning} onClick={onMax}>
              Max
            </button>
          </div>
        </div>
      </div>

      <div className="bet-amount-wrap">
        <div className="label">Risk</div>
        <DiffDropdown value={difficulty} onChange={onDiffChange} disabled={autoRunning} />
      </div>

      <div className="auto-section">
        <div className="auto-section-label">Auto Cash Out At Floor</div>
        <div className="auto-chips">
          {CASHOUT_FLOORS.map((f) => (
            <button
              key={f}
              className={`auto-chip${autoCashoutFloor === f ? " selected" : ""}`}
              data-floor={f}
              disabled={autoRunning}
              onClick={() => onCashoutFloorChange(f)}
            >
              {f}
            </button>
          ))}
        </div>
      </div>

      <div className="auto-section">
        <div className="auto-section-label">Number of Games</div>
        <div className="auto-chips">
          {ROUND_OPTIONS.map((opt) => (
            <button
              key={opt.value}
              className={`auto-chip${autoTotalRounds === opt.value ? " selected" : ""}`}
              data-rounds={opt.value}
              disabled={autoRunning}
              onClick={() => onTotalRoundsChange(opt.value)}
            >
              {opt.label}
            </button>
          ))}
        </div>
      </div>

      <div className="auto-section">
        <div className="auto-section-label">Stop Conditions</div>
        <div className="auto-stop-grid">
          <div className="auto-stop-item">
            <span>Profit &ge;</span>
            <input
              className="auto-stop-input"
              type="number"
              min="0"
              step="1"
              placeholder="No limit"
              value={stopWin}
              disabled={autoRunning}
              onChange={(e) => onStopWinChange(e.target.value)}
            />
          </div>
          <div className="auto-stop-item">
            <span>Loss &ge;</span>
            <input
              className="auto-stop-input"
              type="number"
              min="0"
              step="1"
              placeholder="No limit"
              value={stopLoss}
              disabled={autoRunning}
              onChange={(e) => onStopLossChange(e.target.value)}
            />
          </div>
        </div>
      </div>

      <div className="auto-progress" style={{ display: autoRunning ? "flex" : undefined }}>
        <div className="auto-stats">
          <span>
            Round <b>{autoCurrentRound}</b>/<b>{autoTotalRounds || "∞"}</b>
          </span>
          <span>
            W:<b>{autoWinCount}</b> L:<b>{autoLossCount}</b>
          </span>
          <span className={profitClass}>{profitText}</span>
        </div>
        <div className="auto-progress-bar">
          <div className="auto-progress-fill" style={{ width: progressWidth }}></div>
        </div>
      </div>

      <button
        className="auto-start-btn"
        style={{ display: autoRunning ? "none" : undefined }}
        onClick={onStart}
      >
        &#9654; Start Auto Play
      </button>
      <button
        className="auto-stop-btn"
        style={{ display: autoRunning ? "block" : "none" }}
        onClick={onStop}
      >
        &#9632; Stop
      </button>
    </div>
  );
}

function SidePanel(props) {
  const {
    balance,
    mode,
    onModeChange,
    onFairPlayClick,
    onInfoClick,
    autoRunning,
    currentFloor,
  } = props;

  return (
    <aside className="side">
      <div className="side-header">
        <div className="side-header-top">
          <div className="game-name">
            <img className="ico" src="icon-tower.svg" alt="Tower" style={{height:20,width:20,filter:"brightness(0) invert(1)"}}/>
            <span>Tower</span>
          </div>
          <div className="side-header-actions">
            <div className="fairplay" onClick={onFairPlayClick}>
              Fair Play
            </div>
            <div className="info" onClick={onInfoClick}>
              i
            </div>
          </div>
        </div>
        <div className="side-balance">
          <span className="side-balance-label">Balance</span>
          <span className="side-balance-value">{fmt(balance)}</span>
        </div>
      </div>

      <div className="mode-toggle">
        <button
          className={mode === "manual" ? "active" : ""}
          onClick={() => {
            if (!autoRunning) onModeChange("manual");
          }}
        >
          Manual
        </button>
        <button
          className={mode === "auto" ? "active" : ""}
          onClick={() => {
            if (currentFloor < 0) onModeChange("auto");
          }}
        >
          Auto
        </button>
      </div>

      <ManualPanel
        bet={props.bet}
        balance={props.balance}
        difficulty={props.difficulty}
        isPlaying={props.isPlaying}
        currentFloor={props.currentFloor}
        cashoutText={props.cashoutText}
        cashoutReady={props.cashoutReady}
        onBetChange={props.onBetChange}
        onHalf={props.onHalf}
        onDouble={props.onDouble}
        onMax={props.onMax}
        onDiffChange={props.onDiffChange}
        onPlaceBet={props.onPlaceBet}
        onCashOut={props.onCashOut}
        betInputFlash={props.betInputFlash}
        freeGrant={props.freeGrant}
      />

      <AutoPanel
        bet={props.autoBet}
        balance={props.balance}
        difficulty={props.difficulty}
        onBetChange={props.onAutoBetChange}
        onHalf={props.onAutoHalf}
        onDouble={props.onAutoDouble}
        onMax={props.onAutoMax}
        onDiffChange={props.onDiffChange}
        autoCashoutFloor={props.autoCashoutFloor}
        onCashoutFloorChange={props.onCashoutFloorChange}
        autoTotalRounds={props.autoTotalRounds}
        onTotalRoundsChange={props.onTotalRoundsChange}
        stopWin={props.stopWin}
        onStopWinChange={props.onStopWinChange}
        stopLoss={props.stopLoss}
        onStopLossChange={props.onStopLossChange}
        autoRunning={props.autoRunning}
        autoCurrentRound={props.autoCurrentRound}
        autoWinCount={props.autoWinCount}
        autoLossCount={props.autoLossCount}
        autoStartBalance={props.autoStartBalance}
        currentBalance={props.balance}
        onStart={props.onAutoStart}
        onStop={props.onAutoStop}
      />
    </aside>
  );
}

function GameInfoModal(props) {
  const { open, onClose } = props;
  return (
    <div
      className={`modal-overlay${open ? " show" : ""}`}
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div className="modal">
        <button className="modal-close" onClick={onClose}>
          &times;
        </button>
        <h2>Game Info</h2>
        <div className="modal-payout-box">
          <span className="label">Max Multiplier</span>
          <span className="value">49,283x (Master)</span>
        </div>
        <div className="modal-payout-box">
          <span className="label">Max Win</span>
          <span className="value">$4,928,315</span>
        </div>
        <div className="modal-payout-box">
          <span className="label">Bet Range</span>
          <span className="value">$1.00 — $100.00</span>
        </div>
        <div className="info-box">
          <span className="info-icon">
            <img src="founded.png" style={{ width: "22px", height: "22px" }} />
          </span>
          <p>
            Tower is a multiplier climbing game. Pick safe tiles to ascend the tower — each
            floor increases your payout. Cash out anytime or risk it all to reach the top.
          </p>
        </div>
        <h3>How to Play</h3>
        <ol>
          <li>
            <strong>Place a bet</strong> — enter your amount and tap Place Bet.
          </li>
          <li>
            <strong>Choose risk</strong> — select difficulty from the Risk dropdown. Higher
            risk = bigger multipliers.
          </li>
          <li>
            <strong>Pick a tile</strong> — on each floor, tap a tile to reveal it.
          </li>
          <li>
            <strong>
              <img
                src="founded.png"
                style={{ width: "14px", height: "14px", verticalAlign: "middle" }}
              />{" "}
              Safe
            </strong>{" "}
            — advance to the next floor, multiplier increases.
          </li>
          <li>
            <strong>
              <img
                src="broken.png"
                style={{ width: "14px", height: "14px", verticalAlign: "middle" }}
              />{" "}
              Broken
            </strong>{" "}
            — you lose your bet. Game over.
          </li>
          <li>
            <strong>Cash out</strong> — collect winnings anytime after clearing a floor.
          </li>
        </ol>
        <h3>Risk Levels</h3>
        <ul>
          <li>
            <strong>Easy</strong> — 4 tiles, 1 broken (3 safe)
          </li>
          <li>
            <strong>Medium</strong> — 3 tiles, 1 broken (2 safe)
          </li>
          <li>
            <strong>Hard</strong> — 2 tiles, 1 broken (1 safe)
          </li>
          <li>
            <strong>Expert</strong> — 3 tiles, 2 broken (1 safe)
          </li>
          <li>
            <strong>Master</strong> — 4 tiles, 3 broken (1 safe)
          </li>
        </ul>
        <h3>House Edge &amp; RTP</h3>
        <div className="info-box">
          <span className="info-icon">
            <img
              src="founded.png"
              style={{ width: "22px", height: "22px", filter: "brightness(1.5)" }}
            />
          </span>
          <p>
            <strong>RTP: 96.5%</strong> — House edge is 3.5% per floor. 8 floors to climb.
            Max bet $100. No win cap — your payout is determined by the floor multiplier.
          </p>
        </div>
        <h3>Tips</h3>
        <ul>
          <li>Every outcome is provably fair — tap Fair Play to verify.</li>
          <li>Each floor is independent — previous results don{"'"}t affect future ones.</li>
          <li>Higher floors = higher risk. Cash out when comfortable.</li>
          <li>Easy mode is great for consistent small wins.</li>
          <li>Use Auto mode to run multiple rounds automatically.</li>
        </ul>
      </div>
    </div>
  );
}

function ProvablyFairModal(props) {
  const {
    open,
    onClose,
    serverSeedHash,
    pfRandomEveryGame,
    onRegenClient,
    onSaveSeed,
    onCopyClient,
    onCopyHash,
    onRevealSeed,
    onToggleMode,
    clientSeedInputValue,
    onClientSeedInputChange,
    copyClientLabel,
    copyHashLabel,
    saveLabel,
    revealedText,
  } = props;
  return (
    <div
      className={`modal-overlay${open ? " show" : ""}`}
      onClick={(e) => {
        if (e.target === e.currentTarget) onClose();
      }}
    >
      <div className="modal">
        <button className="modal-close" onClick={onClose}>
          &times;
        </button>
        <h2>Provably Fair</h2>
        <p className="pf-desc">
          This game uses <strong>Provably Fair</strong> technology to determine mine
          placement. This tool gives you the ability to change your seed and check fairness
          of the game.
        </p>
        <div className="pf-section">
          <div className="pf-label">
            <span className="pf-icon">{"🖥"}</span> Next client seed:
          </div>
          <div className="pf-sublabel">Generated on your side</div>
          <div className="pf-toggle-row">
            <button
              className={`pf-toggle-btn${pfRandomEveryGame ? " active" : ""}`}
              onClick={() => onToggleMode(true)}
            >
              Random every game
            </button>
            <button
              className={`pf-toggle-btn${!pfRandomEveryGame ? " active" : ""}`}
              onClick={() => onToggleMode(false)}
            >
              Add nonce
            </button>
          </div>
          <div className="pf-input-row">
            <input
              type="text"
              value={clientSeedInputValue}
              readOnly={pfRandomEveryGame}
              onChange={(e) => onClientSeedInputChange(e.target.value)}
            />
            <button className="pf-btn-sm" title="Copy" onClick={onCopyClient}>
              {copyClientLabel}
            </button>
            <button className="pf-btn-sm" title="Regenerate" onClick={onRegenClient}>
              R
            </button>
          </div>
          <button className="pf-save-btn" onClick={onSaveSeed}>
            {saveLabel}
          </button>
        </div>
        <div className="pf-section">
          <div className="pf-label">
            <span className="pf-icon">{"🖥"}</span> Next server seed SHA256:
          </div>
          <div className="pf-sublabel">Encrypted seed, generated on our side</div>
          <div className="pf-hash-box">{revealedText || serverSeedHash || "loading..."}</div>
          <div className="pf-cp-row">
            <button className="pf-btn-sm" title="Copy" onClick={onCopyHash}>
              {copyHashLabel}
            </button>
            <button className="pf-btn-sm" title="Reveal (after game)" onClick={onRevealSeed}>
              R
            </button>
          </div>
          <p className="pf-note">
            This hash is committed <strong>before</strong> you bet. After the game, the
            server seed is revealed and you can verify SHA256(seed) matches this hash.
          </p>
        </div>
        <div className="pf-how">
          <div className="pf-how-title">How it works</div>
          <ol>
            <li>Server generates a seed and shows you its SHA256 hash</li>
            <li>You set your client seed (your influence on randomness)</li>
            <li>You place a bet</li>
            <li>
              Mine position = HMAC-SHA256(server_seed, your_seed:floor:nonce) % tiles
            </li>
            <li>Server reveals the seed — verify SHA256 matches</li>
            <li>New seed pre-generated for next bet</li>
          </ol>
        </div>
      </div>
    </div>
  );
}

// =============================================================================
// NoSessionScreen — shown when opened without a ?token=.
// =============================================================================
function NoSessionScreen() {
  return (
    <div style={{display:"flex",alignItems:"center",justifyContent:"center",minHeight:"100vh",
      background:"#101015",color:"#c8c8d0",fontFamily:"system-ui,-apple-system,sans-serif",
      textAlign:"center",padding:"24px"}}>
      <div>
        <h2 style={{margin:"0 0 8px",fontSize:"20px",fontWeight:700,color:"#fff"}}>No active session</h2>
        <p style={{margin:0,fontSize:"14px",opacity:0.6}}>Open this game through your casino.</p>
      </div>
    </div>
  );
}

// =============================================================================
// TowerApp — root component
// =============================================================================
function TowerApp() {
  // ==================== Game state ====================
  const [balance, setBalance] = useState(1000);
  const [bet, setBet] = useState("10.00");
  const [difficulty, setDifficulty] = useState("easy");
  const [currentFloor, setCurrentFloor] = useState(-1);
  const [winCount, setWinCount] = useState(0);
  const [wins, setWins] = useState(0);
  const [losses, setLosses] = useState(0);
  const [inFlight, setInFlight] = useState(false);
  const [isPlaying, setIsPlaying] = useState(false);
  const [mode, setMode] = useState("manual");
  const [betInputFlash, setBetInputFlash] = useState(false);

  // Floor reveal data
  const [floors, setFloors] = useState([]);
  const [status, setStatus] = useState("Place a bet to begin");

  // ==================== Provably fair state ====================
  const [clientSeed, setClientSeed] = useState("");
  const [serverSeed, setServerSeed] = useState("");
  const [serverSeedHash, setServerSeedHash] = useState("");
  const [prevServerSeed, setPrevServerSeed] = useState("");
  const [nonce, setNonce] = useState(0);
  const [pfRandomEveryGame, setPfRandomEveryGame] = useState(true);
  const [pfModalOpen, setPfModalOpen] = useState(false);
  const [pfClientSeedInput, setPfClientSeedInput] = useState("");
  const [pfCopyClientLabel, setPfCopyClientLabel] = useState("CP");
  const [pfCopyHashLabel, setPfCopyHashLabel] = useState("CP");
  const [pfSaveLabel, setPfSaveLabel] = useState("Save Seed");
  const [pfRevealedText, setPfRevealedText] = useState(null);

  // ==================== Modal state ====================
  const [gameInfoOpen, setGameInfoOpen] = useState(false);

  // ==================== RGS state ====================
  const [rgsStatus, setRgsStatus] = useState(isRgsConfigured() ? "connecting" : "disabled");
  const [noSession, setNoSession] = useState(false);
  const [rgsToken, setRgsToken] = useState(null);
  const [rgsRtpPct, setRgsRtpPct] = useState(null);
  const [rgsGameUuid, setRgsGameUuid] = useState(null);
  const [rgsError, setRgsError] = useState(null);
  const [rgsState, setRgsState] = useState(null);
  const rgsTokenRef = useRef(null);

  // ==================== Free bet state ====================
  const [freeGrant, setFreeGrant] = useState(null);
  const [freeBetSummary, setFreeBetSummary] = useState(null);
  const [freeWelcome, setFreeWelcome] = useState(false);
  const [faved, setFaved] = useState(localStorage.getItem('fav_tower') === 'true');
  const freeBetTrackerRef = useRef(null);

  // ==================== Result badge state ====================
  const [resultBadge, setResultBadge] = useState(null);

  // ==================== Auto-play state ====================
  const [autoRunning, setAutoRunning] = useState(false);
  const [autoStopping, setAutoStopping] = useState(false);
  const [autoBet, setAutoBet] = useState("10.00");
  const [autoCashoutFloor, setAutoCashoutFloor] = useState(3);
  const [autoTotalRounds, setAutoTotalRounds] = useState(10);
  const [autoCurrentRound, setAutoCurrentRound] = useState(0);
  const [autoWinCount, setAutoWinCount] = useState(0);
  const [autoLossCount, setAutoLossCount] = useState(0);
  const [autoStartBalance, setAutoStartBalance] = useState(0);
  const [stopWin, setStopWin] = useState("");
  const [stopLoss, setStopLoss] = useState("");

  // ==================== Audio ====================
  const audioRef = useRef(null);
  const [soundEnabled, setSoundEnabled] = useState(true);

  // ==================== Refs for async loops ====================
  const betRef = useRef(bet);
  const balanceRef = useRef(balance);
  const serverSeedRef = useRef(serverSeed);
  const clientSeedRef = useRef(clientSeed);
  const nonceRef = useRef(nonce);
  const difficultyRef = useRef(difficulty);
  const autoStoppingRef = useRef(autoStopping);
  const autoRunningRef = useRef(autoRunning);
  const pfRandomEveryGameRef = useRef(pfRandomEveryGame);
  const betAmtRef = useRef(0); // numeric bet for current game
  const winCountRef = useRef(0);
  const currentFloorRef = useRef(-1);
  const inFlightRef = useRef(false);
  const badgeTimerRef = useRef(null);
  const roundIdRef = useRef(null);

  // Keep refs in sync
  useEffect(() => { betRef.current = bet; }, [bet]);
  useEffect(() => { balanceRef.current = balance; }, [balance]);
  useEffect(() => { serverSeedRef.current = serverSeed; }, [serverSeed]);
  useEffect(() => { clientSeedRef.current = clientSeed; }, [clientSeed]);
  useEffect(() => { nonceRef.current = nonce; }, [nonce]);
  useEffect(() => { difficultyRef.current = difficulty; }, [difficulty]);
  useEffect(() => { autoStoppingRef.current = autoStopping; }, [autoStopping]);
  useEffect(() => { autoRunningRef.current = autoRunning; }, [autoRunning]);
  useEffect(() => { pfRandomEveryGameRef.current = pfRandomEveryGame; }, [pfRandomEveryGame]);
  useEffect(() => { winCountRef.current = winCount; }, [winCount]);
  useEffect(() => { currentFloorRef.current = currentFloor; }, [currentFloor]);
  useEffect(() => { inFlightRef.current = inFlight; }, [inFlight]);

  // ==================== Init ====================
  useEffect(() => {
    audioRef.current = new AudioEngine();

    // Initialize seeds
    const cs = "demo_" + randomHex(8);
    setClientSeed(cs);
    setPfClientSeedInput(cs);
    clientSeedRef.current = cs;

    const ss = randomHex(32);
    setServerSeed(ss);
    serverSeedRef.current = ss;
    sha256(ss).then((hash) => setServerSeedHash(hash));

    // Build initial floors
    rebuildFloors("easy");

    // Prevent double-tap zoom
    const preventDoubleTap = (e) => { e.preventDefault(); };
    document.addEventListener("dblclick", preventDoubleTap, { passive: false });
    let lastTouchEnd = 0;
    const preventTouchZoom = (e) => {
      const now = Date.now();
      if (now - lastTouchEnd <= 300) e.preventDefault();
      lastTouchEnd = now;
    };
    document.addEventListener("touchend", preventTouchZoom, { passive: false });

    return () => {
      document.removeEventListener("dblclick", preventDoubleTap);
      document.removeEventListener("touchend", preventTouchZoom);
    };
  }, []);

  // ==================== RGS connect ====================
  useEffect(() => {
    if (!isRgsConfigured()) {
      setRgsStatus("disabled");
      return;
    }
    // Session token comes from the URL only — the frontend never creates a
    // demo session itself. No token → show the no-session screen.
    const urlToken = new URLSearchParams(window.location.search).get("token");
    if (!urlToken) { setNoSession(true); return; }
    let cancelled = false;
    (async () => {
      try {
        rgsTokenRef.current = urlToken;
        setRgsToken(urlToken);

        const state = await rgs.state(urlToken);
        if (cancelled) return;
        setRgsState(state);
        setRgsGameUuid("tower_965");

        const rtpFraction = parseFloat(state.config && state.config.rtp);
        if (isFinite(rtpFraction)) {
          setRgsRtpPct(rtpFraction <= 1 ? rtpFraction * 100 : rtpFraction);
        }

        const serverBalance = parseFloat(state.balance);
        if (isFinite(serverBalance)) {
          setBalance(serverBalance);
          balanceRef.current = serverBalance;
        }
        if (state.client_seed) {
          setClientSeed(state.client_seed);
          clientSeedRef.current = state.client_seed;
          setPfClientSeedInput(state.client_seed);
        }
        if (state.next_seed_hash) setServerSeedHash(state.next_seed_hash);

        setRgsError(null);
        setRgsStatus("connected");

        // Fetch free round grants after successful connect
        const grant = await rgs.fetchFreeRounds(urlToken);
        if (!cancelled && grant) {
          setFreeGrant(grant);
          setBet(parseFloat(grant.bet_amount).toFixed(2));
          // Init tracker when we first discover a free bet grant
          if (!freeBetTrackerRef.current) {
            freeBetTrackerRef.current = { totalWinnings: 0, totalBet: 0, rounds: 0 };
            setFreeWelcome(true);
          }
        }
      } catch (e) {
        if (cancelled) return;
        const msg =
          e instanceof RgsError
            ? `${e.message}${e.status ? ` (HTTP ${e.status})` : ""}`
            : e instanceof Error
            ? e.message
            : "unknown error";
        setRgsError(msg);
        setRgsStatus("error");
      }
    })();
    return () => {
      cancelled = true;
    };
  }, []);

  // ==================== Keyboard ====================
  useEffect(() => {
    function handleKeyDown(e) {
      if (e.key === "Escape") {
        setGameInfoOpen(false);
        setPfModalOpen(false);
        setFreeBetSummary(null);
        return;
      }
      if (autoRunningRef.current) return;
      if (currentFloorRef.current < 0 || inFlightRef.current) {
        if (e.key === "Enter" && !isPlaying) {
          hideBanner();
          startGame();
        }
        return;
      }
      const { tiles } = DIFFICULTIES[difficultyRef.current];
      const num = parseInt(e.key);
      if (num >= 1 && num <= tiles) {
        onTilePick(currentFloorRef.current, num - 1);
      } else if (e.key.toLowerCase() === "c") {
        cashOut();
      }
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isPlaying]);

  // ==================== Helpers ====================
  function rebuildFloors(diff) {
    const { tiles } = DIFFICULTIES[diff];
    const newFloors = Array.from({ length: NUM_FLOORS }, () => ({
      isActive: false,
      isCleared: false,
      isFailed: false,
      tiles: Array.from({ length: tiles }, () => ({ state: "unrevealed" })),
    }));
    setFloors(newFloors);
  }

  function currentPayout() {
    if (winCountRef.current <= 0) return 0;
    const mults = getMultipliers(difficultyRef.current);
    return betAmtRef.current * mults[winCountRef.current - 1];
  }

  function getCashoutText(wc, cf, diff, betAmt) {
    if (wc > 0 && cf >= 0) {
      const mults = getMultipliers(diff);
      const payout = betAmt * mults[wc - 1];
      return { text: `Cash Out ${mults[wc - 1].toFixed(2)}x — ${fmt(payout)}`, ready: true };
    } else if (cf === 0 && wc === 0) {
      return { text: `Cancel Bet — Return ${fmt(betAmt)}`, ready: false };
    }
    return { text: "Cash Out", ready: false };
  }

  const [cashoutInfo, setCashoutInfo] = useState({ text: "Cash Out", ready: false });

  function updateCashout(wc, cf, diff, betAmt) {
    setCashoutInfo(getCashoutText(wc, cf, diff, betAmt));
  }

  function showBanner(kind, title, amount) {
    const text = amount ? `${title} ${amount}` : title;
    setResultBadge({ type: kind, text });
    if (badgeTimerRef.current) clearTimeout(badgeTimerRef.current);
    badgeTimerRef.current = setTimeout(() => setResultBadge(null), 2000);
  }

  function hideBanner() {
    setResultBadge(null);
    if (badgeTimerRef.current) clearTimeout(badgeTimerRef.current);
  }

  function flashInput() {
    setBetInputFlash(true);
    setTimeout(() => setBetInputFlash(false), 600);
  }

  async function newServerSeed() {
    const oldSeed = serverSeedRef.current;
    const ss = randomHex(32);
    setServerSeed(ss);
    serverSeedRef.current = ss;
    const hash = await sha256(ss);
    setServerSeedHash(hash);
    setPrevServerSeed(oldSeed);
    setNonce(0);
    nonceRef.current = 0;
  }

  function newClientSeed() {
    const cs = "demo_" + randomHex(8);
    setClientSeed(cs);
    clientSeedRef.current = cs;
    setPfClientSeedInput(cs);
  }

  async function getMinePlacementForFloor(floor) {
    const { tiles, mines } = DIFFICULTIES[difficultyRef.current];
    const result = await getMinePlacement(
      serverSeedRef.current,
      clientSeedRef.current,
      floor,
      nonceRef.current,
      tiles,
      mines
    );
    nonceRef.current++;
    setNonce(nonceRef.current);
    return result;
  }

  // ==================== Game flow ====================
  async function startGame() {
    const amt = parseFloat(betRef.current);
    if (!isFinite(amt) || amt < MIN_BET) { flashInput(); return; }
    if (amt > MAX_BET) { flashInput(); return; }
    if (balanceRef.current <= 0) { showBanner("lose", "NO BALANCE"); flashInput(); return; }
    if (amt > balanceRef.current) { showBanner("lose", "INSUFFICIENT FUNDS"); flashInput(); return; }
    if (!rgsTokenRef.current) { showBanner("lose", "NOT CONNECTED"); return; }

    // Reset inFlight in case it was stuck
    inFlightRef.current = false;
    setInFlight(false);
    setIsPlaying(true);

    // Server-authoritative bet: deduct on the server and get a round_id.
    let bet;
    try {
      bet = await rgs.bet(rgsTokenRef.current, { amount: amt.toFixed(2), difficulty: difficultyRef.current });
    } catch (e) {
      setIsPlaying(false);
      showBanner("lose", "BET FAILED");
      return;
    }

    roundIdRef.current = bet.round_id;
    betAmtRef.current = amt;
    if (bet.balance != null) {
      const newBalance = parseFloat(bet.balance);
      if (isFinite(newBalance)) { setBalance(newBalance); balanceRef.current = newBalance; }
    }
    if (bet.seed_hash) setServerSeedHash(bet.seed_hash);

    if (audioRef.current) audioRef.current.sndBet();

    if (pfRandomEveryGameRef.current) newClientSeed();
    nonceRef.current = 0;
    setNonce(0);

    rebuildFloors(difficultyRef.current);
    winCountRef.current = 0;
    setWinCount(0);
    currentFloorRef.current = 0;
    setCurrentFloor(0);

    // Activate floor 0
    setFloors((prev) => {
      const next = prev.map((f, i) => {
        if (i === 0) return { ...f, isActive: true };
        return f;
      });
      return next;
    });

    const mults = getMultipliers(difficultyRef.current);
    setStatus(`Floor 1 — pick a tile (${mults[0].toFixed(2)}x)`);
    updateCashout(0, 0, difficultyRef.current, amt);

    // Track free bet round if playing with a free grant
    if (freeBetTrackerRef.current) {
      var updated = { totalWinnings: freeBetTrackerRef.current.totalWinnings, totalBet: freeBetTrackerRef.current.totalBet + amt, rounds: freeBetTrackerRef.current.rounds + 1 };
      freeBetTrackerRef.current = updated;
    }

  }

  async function onTilePick(floor, tileIdx) {
    if (inFlightRef.current || currentFloorRef.current < 0 || floor !== currentFloorRef.current || autoRunningRef.current) return;
    if (!roundIdRef.current || !rgsTokenRef.current) return;
    inFlightRef.current = true;
    setInFlight(true);

    if (audioRef.current) audioRef.current.sndClick();

    try {
      // Server-authoritative pick: backend reveals safe/mine for this tile.
      const picked = await rgs.pick(rgsTokenRef.current, {
        round_id: roundIdRef.current,
        floor,
        tile: tileIdx,
      });
      const isMine = picked.outcome === "mine";
      const { tiles } = DIFFICULTIES[difficultyRef.current];
      const mults = getMultipliers(difficultyRef.current);
      // Server returns mines_revealed as [[floor0Mines...], [floor1Mines...], ...]
      // starting from the picked floor onward (terminal) or for just this floor (still active).
      const minesRevealed = picked.mine_positions || [];
      const minePositions = new Set(Array.isArray(minesRevealed[0]) ? minesRevealed[0] : []);

      if (isMine) {
        // Reveal mine and safe paths on current floor
        setFloors((prev) => {
          const next = [...prev];
          const floorData = { ...next[floor] };
          floorData.isActive = false;
          floorData.isFailed = true;
          const newTiles = [...floorData.tiles];
          for (let t = 0; t < tiles; t++) {
            if (t === tileIdx) {
              newTiles[t] = { state: "mine" };
            } else if (!minePositions.has(t)) {
              newTiles[t] = { state: "safe-path" };
            } else {
              newTiles[t] = { state: "dimmed" };
            }
          }
          floorData.tiles = newTiles;
          next[floor] = floorData;
          return next;
        });

        if (audioRef.current) audioRef.current.sndLose();
        setLosses((l) => l + 1);

        // Reveal safe paths on remaining floors from the server's mines_revealed
        // payload (positions for the picked floor are index 0; subsequent floors
        // follow in order).
        const revealed = [];
        for (let i = 1; floor + i < NUM_FLOORS && i < minesRevealed.length; i++) {
          revealed.push({ floor: floor + i, mines: new Set(minesRevealed[i] || []) });
        }
        setFloors((prev) => {
          const next = [...prev];
          for (const { floor: rf, mines: rfMines } of revealed) {
            const fd = { ...next[rf] };
            fd.isCleared = true;
            const newTiles = [...fd.tiles];
            for (let t = 0; t < tiles; t++) {
              if (!rfMines.has(t)) {
                newTiles[t] = { state: "safe-path" };
              } else {
                newTiles[t] = { state: "dimmed" };
              }
            }
            fd.tiles = newTiles;
            next[rf] = fd;
          }
          return next;
        });

        if (picked.balance != null) {
          const nb = parseFloat(picked.balance);
          if (isFinite(nb)) { setBalance(nb); balanceRef.current = nb; }
        }
        roundIdRef.current = null;

        setStatus(`Hit a mine! Lost ${fmt(betAmtRef.current)}`);
        inFlightRef.current = false;
        setInFlight(false);
        setIsPlaying(false);

        showBanner("lose", `BUST! -${fmt(betAmtRef.current)}`);
        endGame();
      } else {
        // Safe tile
        setFloors((prev) => {
          const next = [...prev];
          const floorData = { ...next[floor] };
          floorData.isActive = false;
          floorData.isCleared = true;
          const newTiles = [...floorData.tiles];
          for (let t = 0; t < tiles; t++) {
            if (t === tileIdx) {
              newTiles[t] = { state: "safe" };
            } else {
              newTiles[t] = { state: "dimmed" };
            }
          }
          floorData.tiles = newTiles;
          next[floor] = floorData;
          return next;
        });

        if (audioRef.current) audioRef.current.sndWin();
        winCountRef.current++;
        setWinCount(winCountRef.current);
        updateCashout(winCountRef.current, currentFloorRef.current, difficultyRef.current, betAmtRef.current);

        if (picked.finished) {
          // Server auto-cashout at top floor: trust server's total_payout / balance.
          const payout = parseFloat(picked.total_payout || picked.potential_payout || 0);
          if (picked.balance != null) {
            const nb = parseFloat(picked.balance);
            if (isFinite(nb)) { setBalance(nb); balanceRef.current = nb; }
          }
          roundIdRef.current = null;
          setWins((w) => w + 1);
          if (freeBetTrackerRef.current) {
            var updated = { totalWinnings: freeBetTrackerRef.current.totalWinnings + payout, totalBet: freeBetTrackerRef.current.totalBet, rounds: freeBetTrackerRef.current.rounds };
            freeBetTrackerRef.current = updated;
          }
          if (audioRef.current) audioRef.current.sndMaxWin();
          showBanner("win", `WIN +${fmt(payout)}`);
          inFlightRef.current = false;
          setInFlight(false);
          endGame();
        } else {
          const nextFloor = currentFloorRef.current + 1;
          currentFloorRef.current = nextFloor;
          setCurrentFloor(nextFloor);

          setFloors((prev) => {
            const next = [...prev];
            if (next[nextFloor]) {
              next[nextFloor] = { ...next[nextFloor], isActive: true };
            }
            return next;
          });
          const nextMults = getMultipliers(difficultyRef.current);
          const payoutSoFar = betAmtRef.current * nextMults[winCountRef.current - 1];
          setStatus(`Floor ${nextFloor + 1} — risk ${fmt(payoutSoFar)} for ${nextMults[nextFloor].toFixed(2)}x`);
          inFlightRef.current = false;
          setInFlight(false);
        }
      }
    } catch (err) {
      // Ensure inFlight is always reset even if something errors
      console.error("onTilePick error:", err);
      inFlightRef.current = false;
      setInFlight(false);
    }
  }

  async function cashOut() {
    if (inFlightRef.current) return;

    if (currentFloorRef.current === 0 && winCountRef.current === 0) {
      // No "cancel bet" path on the server — once /bet is committed, the bet
      // is debited. Let the player keep the round running.
      if (audioRef.current) audioRef.current.sndClick();
      setStatus("Pick a tile to start climbing");
      return;
    }

    if (winCountRef.current <= 0) return;
    if (!roundIdRef.current || !rgsTokenRef.current) return;

    inFlightRef.current = true;
    setInFlight(true);

    let resp;
    try {
      resp = await rgs.cashout(rgsTokenRef.current, { round_id: roundIdRef.current });
    } catch (e) {
      inFlightRef.current = false;
      setInFlight(false);
      showBanner("lose", "CASHOUT FAILED");
      return;
    }

    const payout = parseFloat(resp.total_payout || resp.payout || 0);
    if (resp.balance != null) {
      const nb = parseFloat(resp.balance);
      if (isFinite(nb)) { setBalance(nb); balanceRef.current = nb; }
    }
    roundIdRef.current = null;
    setWins((w) => w + 1);
    if (freeBetTrackerRef.current) {
      var updated = { totalWinnings: freeBetTrackerRef.current.totalWinnings + payout, totalBet: freeBetTrackerRef.current.totalBet, rounds: freeBetTrackerRef.current.rounds };
      freeBetTrackerRef.current = updated;
    }
    if (audioRef.current) audioRef.current.sndCashout();
    showBanner("cashout", `CASHED OUT +${fmt(payout)}`);
    inFlightRef.current = false;
    setInFlight(false);
    endGame();
  }

  function endGame() {
    currentFloorRef.current = -1;
    setCurrentFloor(-1);
    winCountRef.current = 0;
    setWinCount(0);
    inFlightRef.current = false;
    setInFlight(false);
    setIsPlaying(false);
    newServerSeed();
    // Don't rebuild floors — keep previous result visible until next bet
    setCashoutInfo({ text: "Cash Out", ready: false });
    setStatus("Place a bet to begin");

    // Refresh free round grants (may be exhausted)
    if (rgsTokenRef.current) {
      rgs.fetchFreeRounds(rgsTokenRef.current).then((grant) => {
        if (!grant && freeBetTrackerRef.current && freeBetTrackerRef.current.rounds > 0) {
          // Free bets are done — show summary
          setFreeBetSummary({ ...freeBetTrackerRef.current });
          freeBetTrackerRef.current = null;
        }
        setFreeGrant(grant);
        if (grant) {
          setBet(parseFloat(grant.bet_amount).toFixed(2));
          // Init tracker if needed
          if (!freeBetTrackerRef.current) {
            freeBetTrackerRef.current = { totalWinnings: 0, totalBet: 0, rounds: 0 };
            setFreeWelcome(true);
          }
        }
      });
    }
  }

  // ==================== Auto-play ====================
  async function autoPlayGame() {
    const betAmt = parseFloat(autoBetRef.current);
    if (!isFinite(betAmt) || betAmt < MIN_BET || betAmt > MAX_BET || betAmt > balanceRef.current) {
      autoRunningRef.current = false;
      return;
    }

    betAmtRef.current = betAmt;
    const newBal = balanceRef.current - betAmt;
    setBalance(newBal);
    balanceRef.current = newBal;

    if (pfRandomEveryGameRef.current) newClientSeed();
    nonceRef.current = 0;
    setNonce(0);

    rebuildFloors(difficultyRef.current);
    winCountRef.current = 0;
    setWinCount(0);
    currentFloorRef.current = 0;
    setCurrentFloor(0);
    setIsPlaying(true);

    // Activate floor 0
    setFloors((prev) => {
      const next = prev.map((f, i) => (i === 0 ? { ...f, isActive: true } : f));
      return next;
    });

    const mults = getMultipliers(difficultyRef.current);
    setStatus(`Floor 1 — pick a tile (${mults[0].toFixed(2)}x)`);
    updateCashout(0, 0, difficultyRef.current, betAmt);

    await autoPlayFloors(betAmt);
  }

  async function autoPlayFloors(betAmt) {
    const diff = difficultyRef.current;
    const { tiles } = DIFFICULTIES[diff];
    const autoMults = getMultipliers(diff);

    for (let floor = 0; floor < NUM_FLOORS; floor++) {
      if (autoStoppingRef.current) {
        if (winCountRef.current > 0) {
          const payout = betAmt * autoMults[winCountRef.current - 1];
          const newBal = balanceRef.current + payout;
          setBalance(newBal);
          balanceRef.current = newBal;
          setAutoWinCount((c) => c + 1);
          setWins((w) => w + 1);
        }
        endAutoRound();
        return;
      }

      const minePositions = await getMinePlacementForFloor(floor);
      const pick = Math.floor(Math.random() * tiles);
      const isMine = minePositions.has(pick);

      if (isMine) {
        setFloors((prev) => {
          const next = [...prev];
          const fd = { ...next[floor] };
          fd.isActive = false;
          fd.isFailed = true;
          const newTiles = [...fd.tiles];
          for (let t = 0; t < tiles; t++) {
            if (t === pick) newTiles[t] = { state: "mine" };
            else if (!minePositions.has(t)) newTiles[t] = { state: "safe-path" };
            else newTiles[t] = { state: "dimmed" };
          }
          fd.tiles = newTiles;
          next[floor] = fd;
          return next;
        });

        if (audioRef.current) audioRef.current.sndLose();
        setAutoLossCount((c) => c + 1);
        setLosses((l) => l + 1);

        // Reveal remaining floors
        for (let rf = floor + 1; rf < NUM_FLOORS; rf++) {
          const rfMines = await getMinePlacementForFloor(rf);
          setFloors((prev) => {
            const next = [...prev];
            const fd = { ...next[rf] };
            fd.isCleared = true;
            const newTiles = [...fd.tiles];
            for (let t = 0; t < tiles; t++) {
              if (!rfMines.has(t)) newTiles[t] = { state: "safe-path" };
              else newTiles[t] = { state: "dimmed" };
            }
            fd.tiles = newTiles;
            next[rf] = fd;
            return next;
          });
        }

        await new Promise((r) => setTimeout(r, 1200));
        endAutoRound();
        return;
      }

      // Safe
      setFloors((prev) => {
        const next = [...prev];
        const fd = { ...next[floor] };
        fd.isActive = false;
        fd.isCleared = true;
        const newTiles = [...fd.tiles];
        for (let t = 0; t < tiles; t++) {
          if (t === pick) newTiles[t] = { state: "safe" };
          else newTiles[t] = { state: "dimmed" };
        }
        fd.tiles = newTiles;
        next[floor] = fd;
        return next;
      });

      if (audioRef.current) audioRef.current.sndWin();
      winCountRef.current++;
      setWinCount(winCountRef.current);
      updateCashout(winCountRef.current, floor, diff, betAmt);

      // Check cashout
      if (winCountRef.current >= autoCashoutFloorRef.current || winCountRef.current >= NUM_FLOORS) {
        const payout = betAmt * autoMults[winCountRef.current - 1];
        const newBal = balanceRef.current + payout;
        setBalance(newBal);
        balanceRef.current = newBal;
        setAutoWinCount((c) => c + 1);
        setWins((w) => w + 1);
        if (winCountRef.current >= NUM_FLOORS) {
          if (audioRef.current) audioRef.current.sndMaxWin();
        } else {
          if (audioRef.current) audioRef.current.sndCashout();
        }
        await new Promise((r) => setTimeout(r, 400));
        endAutoRound();
        return;
      }

      currentFloorRef.current = floor + 1;
      setCurrentFloor(floor + 1);
      setFloors((prev) => {
        const next = [...prev];
        if (next[floor + 1]) next[floor + 1] = { ...next[floor + 1], isActive: true };
        return next;
      });
      const nextMults = getMultipliers(diff);
      const nm = nextMults[floor + 1];
      setStatus(`Floor ${floor + 2} — pick a tile (${nm != null ? nm.toFixed(2) : ""}x)`);
      await new Promise((r) => setTimeout(r, 400));
    }
  }

  // Use a ref-backed local counter for auto rounds
  const autoCurrentRoundRef = useRef(0);
  const autoTotalRoundsRef = useRef(10);
  const autoStartBalanceRef = useRef(0);
  const autoCashoutFloorRef = useRef(3);
  const stopWinRef = useRef("");
  const stopLossRef = useRef("");
  const autoBetRef = useRef("10.00");

  useEffect(() => { autoCurrentRoundRef.current = autoCurrentRound; }, [autoCurrentRound]);
  useEffect(() => { autoTotalRoundsRef.current = autoTotalRounds; }, [autoTotalRounds]);
  useEffect(() => { autoStartBalanceRef.current = autoStartBalance; }, [autoStartBalance]);
  useEffect(() => { autoCashoutFloorRef.current = autoCashoutFloor; }, [autoCashoutFloor]);
  useEffect(() => { stopWinRef.current = stopWin; }, [stopWin]);
  useEffect(() => { stopLossRef.current = stopLoss; }, [stopLoss]);
  useEffect(() => { autoBetRef.current = autoBet; }, [autoBet]);

  function endAutoRound() {
    currentFloorRef.current = -1;
    setCurrentFloor(-1);
    winCountRef.current = 0;
    setWinCount(0);
    setIsPlaying(false);

    // New server seed (save old one for provably fair verification)
    setPrevServerSeed(serverSeedRef.current);
    const ss = randomHex(32);
    setServerSeed(ss);
    serverSeedRef.current = ss;
    sha256(ss).then((hash) => setServerSeedHash(hash));
    nonceRef.current = 0;
    setNonce(0);

    setAutoCurrentRound((prev) => {
      const next = prev + 1;
      autoCurrentRoundRef.current = next;
      return next;
    });

    rebuildFloors(difficultyRef.current);
    setCashoutInfo({ text: "Cash Out", ready: false });
    setStatus("Place a bet to begin");
  }

  function shouldAutoStopCheck() {
    if (autoStoppingRef.current) return true;
    if (autoTotalRoundsRef.current > 0 && autoCurrentRoundRef.current >= autoTotalRoundsRef.current) return true;
    const profitVal = balanceRef.current - autoStartBalanceRef.current;
    const sw = parseFloat(stopWinRef.current);
    if (sw > 0 && profitVal >= sw) return true;
    const sl = parseFloat(stopLossRef.current);
    if (sl > 0 && profitVal <= -sl) return true;
    const betAmt = parseFloat(autoBetRef.current);
    if (balanceRef.current < betAmt) return true;
    return false;
  }

  async function startAuto() {
    const betAmt = parseFloat(autoBet);
    if (!isFinite(betAmt) || betAmt < MIN_BET || betAmt > MAX_BET) return;
    if (betAmt > balanceRef.current) return;

    autoRunningRef.current = true;
    setAutoRunning(true);
    autoStoppingRef.current = false;
    setAutoStopping(false);
    autoCurrentRoundRef.current = 0;
    setAutoCurrentRound(0);
    setAutoWinCount(0);
    setAutoLossCount(0);
    autoStartBalanceRef.current = balanceRef.current;
    setAutoStartBalance(balanceRef.current);

    // Run loop
    while (autoRunningRef.current && !shouldAutoStopCheck()) {
      await autoPlayGame();
      if (!autoRunningRef.current) break;
      if (shouldAutoStopCheck()) break;
      await new Promise((r) => setTimeout(r, 600));
    }
    stopAuto();
  }

  function stopAuto() {
    autoRunningRef.current = false;
    setAutoRunning(false);
    autoStoppingRef.current = false;
    setAutoStopping(false);
    setIsPlaying(false);
    rebuildFloors(difficultyRef.current);
    setCashoutInfo({ text: "Cash Out", ready: false });
    setStatus("Place a bet to begin");
  }

  // ==================== PF handlers ====================
  function handleRegenClient() {
    newClientSeed();
  }

  function handleSaveSeed() {
    setClientSeed(pfClientSeedInput);
    clientSeedRef.current = pfClientSeedInput;
    setPfSaveLabel("Saved!");
    setTimeout(() => setPfSaveLabel("Save Seed"), 1000);
  }

  function handleCopyClient() {
    navigator.clipboard.writeText(pfClientSeedInput);
    setPfCopyClientLabel("✓");
    setTimeout(() => setPfCopyClientLabel("CP"), 1000);
  }

  function handleCopyHash() {
    navigator.clipboard.writeText(serverSeedHash);
    setPfCopyHashLabel("✓");
    setTimeout(() => setPfCopyHashLabel("CP"), 1000);
  }

  function handleRevealSeed() {
    if (prevServerSeed) {
      setPfRevealedText("Seed: " + prevServerSeed);
      setTimeout(() => setPfRevealedText(null), 5000);
    }
  }

  function handleToggleMode(randomEveryGame) {
    setPfRandomEveryGame(randomEveryGame);
    pfRandomEveryGameRef.current = randomEveryGame;
  }

  // ==================== Bet helpers ====================
  function handleHalf() {
    const v = parseFloat(bet) || 0;
    setBet(Math.max(MIN_BET, v / 2).toFixed(2));
  }

  function handleDouble() {
    const v = parseFloat(bet) || 0;
    setBet(Math.min(balanceRef.current, MAX_BET, v * 2).toFixed(2));
  }

  function handleMax() {
    setBet(Math.min(balanceRef.current, MAX_BET).toFixed(2));
  }

  function handleAutoHalf() {
    const v = parseFloat(autoBet) || 0;
    setAutoBet(Math.max(MIN_BET, v / 2).toFixed(2));
  }

  function handleAutoDouble() {
    const v = parseFloat(autoBet) || 0;
    setAutoBet(Math.min(balanceRef.current, MAX_BET, v * 2).toFixed(2));
  }

  function handleAutoMax() {
    setAutoBet(Math.min(balanceRef.current, MAX_BET).toFixed(2));
  }

  function handleDiffChange(diff) {
    setDifficulty(diff);
    difficultyRef.current = diff;
    rebuildFloors(diff);
  }

  function handleSoundToggle() {
    if (audioRef.current) {
      const enabled = audioRef.current.toggle();
      setSoundEnabled(enabled);
    }
  }

  // ==================== Multipliers ====================
  const multipliers = getMultipliers(difficulty);
  const { tiles: tilesCount } = DIFFICULTIES[difficulty];

  // ==================== CSS classes ====================
  const appClasses = ["app"];
  if (isPlaying) appClasses.push("playing");
  if (mode === "auto") appClasses.push("auto-mode");
  if (autoRunning) appClasses.push("auto-running");

  if (noSession) return <NoSessionScreen/>;

  return (
    <div className={appClasses.join(" ")}>
      <div className="row">
        <SidePanel
          balance={balance}
          mode={mode}
          onModeChange={setMode}
          onFairPlayClick={() => setPfModalOpen(true)}
          onInfoClick={() => setGameInfoOpen(true)}
          bet={bet}
          difficulty={difficulty}
          isPlaying={isPlaying}
          currentFloor={currentFloor}
          cashoutText={cashoutInfo.text}
          cashoutReady={cashoutInfo.ready}
          onBetChange={setBet}
          onHalf={handleHalf}
          onDouble={handleDouble}
          onMax={handleMax}
          onDiffChange={handleDiffChange}
          onPlaceBet={() => { hideBanner(); startGame(); }}
          onCashOut={cashOut}
          betInputFlash={betInputFlash}
          freeGrant={freeGrant}
          autoBet={autoBet}
          onAutoBetChange={setAutoBet}
          onAutoHalf={handleAutoHalf}
          onAutoDouble={handleAutoDouble}
          onAutoMax={handleAutoMax}
          autoCashoutFloor={autoCashoutFloor}
          onCashoutFloorChange={setAutoCashoutFloor}
          autoTotalRounds={autoTotalRounds}
          onTotalRoundsChange={setAutoTotalRounds}
          stopWin={stopWin}
          onStopWinChange={setStopWin}
          stopLoss={stopLoss}
          onStopLossChange={setStopLoss}
          autoRunning={autoRunning}
          autoCurrentRound={autoCurrentRound}
          autoWinCount={autoWinCount}
          autoLossCount={autoLossCount}
          autoStartBalance={autoStartBalance}
          onAutoStart={startAuto}
          onAutoStop={() => { autoStoppingRef.current = true; setAutoStopping(true); }}
        />
        <main className="main">
          <Header
            balance={balance}
            onFairPlayClick={() => setPfModalOpen(true)}
            onInfoClick={() => setGameInfoOpen(true)}
          />
          <TowerBoard
            difficulty={difficulty}
            tilesCount={tilesCount}
            currentFloor={currentFloor}
            floors={floors}
            onTilePick={onTilePick}
            status={status}
            isPlaying={isPlaying}
            multipliers={multipliers}
            disabled={inFlight}
            resultBadge={resultBadge}
          />
        </main>
      </div>
      <BottomBar soundEnabled={soundEnabled} onSoundToggle={handleSoundToggle} faved={faved} setFaved={setFaved} />
      {/* Result badge is now rendered inside TowerBoard */}
      <GameInfoModal open={gameInfoOpen} onClose={() => setGameInfoOpen(false)} difficulty={difficulty} />
      <ProvablyFairModal
        open={pfModalOpen}
        onClose={() => setPfModalOpen(false)}
        clientSeed={clientSeed}
        serverSeedHash={serverSeedHash}
        prevServerSeed={prevServerSeed}
        pfRandomEveryGame={pfRandomEveryGame}
        onRegenClient={handleRegenClient}
        onSaveSeed={handleSaveSeed}
        onCopyClient={handleCopyClient}
        onCopyHash={handleCopyHash}
        onRevealSeed={handleRevealSeed}
        onToggleMode={handleToggleMode}
        clientSeedInputValue={pfClientSeedInput}
        onClientSeedInputChange={setPfClientSeedInput}
        copyClientLabel={pfCopyClientLabel}
        copyHashLabel={pfCopyHashLabel}
        saveLabel={pfSaveLabel}
        revealedText={pfRevealedText}
      />
      {freeWelcome && freeGrant && (
        <div className="modal-overlay show" onClick={() => setFreeWelcome(false)}>
          <div className="modal free-bet-welcome" onClick={(e) => e.stopPropagation()}>
            <div className="fbw-icon">{'\uD83C\uDF81'}</div>
            <h2>Free Rounds Available!</h2>
            <p className="fbw-desc">You have been awarded free rounds for this game. The bet amount is fixed at <strong>{fmt(parseFloat(freeGrant.bet_amount))}</strong> per round.</p>
            <div className="fbw-count">
              <span className="fbw-count-num">{freeGrant.rounds_total}</span>
              <span className="fbw-count-label">Free Rounds</span>
            </div>
            <button className="fbw-btn" onClick={() => setFreeWelcome(false)}>START PLAYING</button>
          </div>
        </div>
      )}
      {freeBetSummary && (
        <div className="modal-overlay show" onClick={() => setFreeBetSummary(null)}>
          <div className="modal free-bet-summary" onClick={(e) => e.stopPropagation()}>
            <button className="modal-close" onClick={() => setFreeBetSummary(null)}>&times;</button>
            <div className="fbs-icon">{'\uD83C\uDF81'}</div>
            <h2>Free Rounds Complete!</h2>
            <div className="fbs-stats">
              <div className="fbs-stat">
                <span className="fbs-stat-label">Rounds Played</span>
                <span className="fbs-stat-val">{freeBetSummary.rounds}</span>
              </div>
            </div>
            <div className="fbs-winnings">
              <span className="fbs-winnings-label">Total Won</span>
              <span className="fbs-winnings-val">{fmt(freeBetSummary.totalWinnings)}</span>
            </div>
            <button className="fbs-btn" onClick={() => setFreeBetSummary(null)}>CONTINUE PLAYING</button>
          </div>
        </div>
      )}
    </div>
  );
}

// =============================================================================
// Mount
// =============================================================================
ReactDOM.createRoot(document.getElementById('root')).render(<TowerApp />);
