/* Quantum Arcade client.
 *
 * Identity is a keypair generated in the browser and kept in localStorage.
 * There is no account to create and no password to lose.
 *
 * The verifier recomputes round outcomes locally with WebCrypto. It never asks
 * the server whether a round was fair — it checks.
 *
 * All dynamic content is inserted with textContent or built as DOM nodes.
 * Nothing that originates from another player (nicknames, keys) or from the
 * server ever reaches innerHTML. */

import { Arcade3D } from '/scene3d.js';
import * as charts from '/charts.js';
import * as qr from '/qr.js';

const KEY_STORAGE = 'quantum-arcade-key';
const NAME_STORAGE = 'quantum-arcade-name';
const STATS_STORAGE = 'quantum-arcade-stats';

let keypair = null;
let token = null;
let nickname = '';
let stake = 5000;          // millisatoshis
let currentGame = 'rocket';
let socket = null;
let snapshot = null;
let myBet = null;          // 'in' | 'out' | null
let scene = null;
let balanceMsat = 0;

/* Session statistics, kept client-side. The ledger remains the authority on
 * money; this is only for the charts. */
const stats = loadStats();

function loadStats() {
  try {
    const raw = JSON.parse(localStorage.getItem(STATS_STORAGE) || '{}');
    return {
      crashes: raw.crashes || [],       // crash points seen, for distribution
      balances: raw.balances || [],     // balance samples over time
      wagered: raw.wagered || 0,
      plays: raw.plays || 0,
      wins: raw.wins || 0,
      losses: raw.losses || 0,
      best: raw.best || 0,
      sessionStart: null,               // set at sign-in, never persisted
    };
  } catch {
    return { crashes: [], balances: [], wagered: 0, plays: 0,
             wins: 0, losses: 0, best: 0, sessionStart: null };
  }
}

function saveStats() {
  // Cap the arrays so localStorage cannot grow without bound over a long night.
  stats.crashes = stats.crashes.slice(-300);
  stats.balances = stats.balances.slice(-300);
  localStorage.setItem(STATS_STORAGE, JSON.stringify(stats));
}

const $ = (id) => document.getElementById(id);
const sats = (msat) => Math.round(msat / 1000).toLocaleString();

/* Small DOM builder: el('div', {class: 'x'}, 'text', childNode, ...) */
function el(tag, attrs, ...children) {
  const node = document.createElement(tag);
  for (const [k, v] of Object.entries(attrs || {})) {
    if (k === 'class') node.className = v;
    else if (k === 'text') node.textContent = v;
    else node.setAttribute(k, v);
  }
  for (const c of children) {
    if (c == null) continue;
    node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
  }
  return node;
}

function clear(node) {
  while (node.firstChild) node.removeChild(node.firstChild);
}

/* A short haptic tap. Phones only, and silently absent elsewhere. */
function buzz(ms) {
  if (navigator.vibrate) navigator.vibrate(ms);
}

/* ---------------- identity ---------------- */

async function loadOrCreateKey() {
  const stored = localStorage.getItem(KEY_STORAGE);
  if (stored) {
    const jwk = JSON.parse(stored);
    const priv = await crypto.subtle.importKey('jwk', jwk, { name: 'Ed25519' }, true, ['sign']);
    return { privateKey: priv, publicKeyHex: jwk.qa_pub };
  }
  const kp = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
  const rawPub = new Uint8Array(await crypto.subtle.exportKey('raw', kp.publicKey));
  const pubHex = hex(rawPub);
  const jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
  jwk.qa_pub = pubHex;
  localStorage.setItem(KEY_STORAGE, JSON.stringify(jwk));
  return { privateKey: kp.privateKey, publicKeyHex: pubHex };
}

function hex(bytes) {
  return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function unhex(s) {
  const out = new Uint8Array(s.length / 2);
  for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
  return out;
}

async function signIn() {
  nickname = ($('nickname').value || 'anon').trim().slice(0, 20);
  localStorage.setItem(NAME_STORAGE, nickname);

  const chal = await api('POST', '/api/auth/challenge', { pubkey: keypair.publicKeyHex });
  const sig = new Uint8Array(await crypto.subtle.sign(
    { name: 'Ed25519' }, keypair.privateKey, unhex(chal.challenge)));

  const res = await api('POST', '/api/auth/verify', {
    pubkey: keypair.publicKeyHex,
    signature: hex(sig),
    nickname,
  });
  token = res.token;

  $('signin').hidden = true;
  $('app').hidden = false;
  $('tabs').hidden = false;
  $('balance-wrap').hidden = false;
  setBalance(res.balance_msat);
  stats.sessionStart = res.balance_msat;
  $('pubkey').textContent = keypair.publicKeyHex;

  scene = new Arcade3D($('scene'));
  scene.setGame(currentGame);
  scene.start();

  connect(currentGame);
  loadScratch();
  detectLightning();
}

/* ---------------- api ---------------- */

async function api(method, path, body) {
  const headers = { 'Content-Type': 'application/json' };
  if (token) headers['Authorization'] = 'Bearer ' + token;
  const res = await fetch(path, {
    method, headers,
    body: body ? JSON.stringify(body) : undefined,
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(data.error || res.statusText);
  return data;
}

function setBalance(msat) {
  balanceMsat = msat;
  $('balance').textContent = sats(msat);
  const last = stats.balances[stats.balances.length - 1];
  if (last !== msat) {
    stats.balances.push(msat);
    saveStats();
  }
}

/* ---------------- auto cash-out ---------------- */

function autoTarget() {
  const v = parseFloat($('auto-target').value);
  return Number.isFinite(v) && v > 1 ? v : 0;
}

function refreshAutoRow() {
  $('autorow').classList.toggle('armed', autoTarget() > 0);
}

/* ---------------- crash room ---------------- */

function connect(game) {
  if (socket) socket.close();
  currentGame = game;
  myBet = null;
  if (scene) scene.setGame(game);
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  socket = new WebSocket(`${proto}://${location.host}/ws/${game}`);
  socket.onmessage = (ev) => onSnapshot(JSON.parse(ev.data));
  socket.onclose = () => setTimeout(() => connect(currentGame), 1200);
}

/* The server sends a frame a few times a second; the multiplier curve is
 * deterministic, so between frames the client computes it locally from the
 * round's start time. This is why the animation is smooth without the server
 * pushing sixty frames a second to every phone. */
const TICK_HZ = 60;
const ROUND_TICKS = 60 * TICK_HZ;

function multiplierAtTick(tick) {
  if (tick <= 0) return 1;
  if (tick >= ROUND_TICKS) tick = ROUND_TICKS - 1;
  const remaining = 1 - tick / ROUND_TICKS;
  return 1 / (remaining * remaining);
}

function localMultiplier() {
  if (!snapshot || snapshot.state !== 'running' || !snapshot.started_unix_milli) {
    return snapshot ? parseFloat(snapshot.multiplier) : 1;
  }
  const elapsedMs = Date.now() - snapshot.started_unix_milli;
  return multiplierAtTick(Math.floor((elapsedMs / 1000) * TICK_HZ));
}

/* Runs every animation frame while a round is in flight, so the number and the
 * 3D scene update at display rate rather than at network rate. */
function interpolate() {
  if (!snapshot || snapshot.state !== 'running') return;
  const m = localMultiplier();
  $('multiplier').textContent = m.toFixed(2) + '×';
  if (scene) scene.setState(Math.log(Math.max(1, m)) / Math.log(25), false);
  if (myBet === 'in') {
    $('action').textContent = `Cash out ${sats(stake * m)}`;
  }
  requestAnimationFrame(interpolate);
}

function onSnapshot(s) {
  const roundChanged = !snapshot || snapshot.round_id !== s.round_id;
  const wasRunning = snapshot && snapshot.state === 'running';
  const wasSettled = snapshot && snapshot.state === 'settled';
  snapshot = s;
  if (roundChanged) myBet = null;

  const mult = $('multiplier');
  const current = parseFloat(s.multiplier);
  mult.textContent = current.toFixed(2) + '×';
  mult.className = 'multiplier';

  $('commitment').textContent = s.commitment || '—';
  $('revealed').textContent = s.server_seed || 'sealed until the round ends';

  // Counts come from the server aggregate: the player list in each frame is
  // only the leaderboard, capped so a large room stays cheap to broadcast.
  $('potline').textContent = s.player_count
    ? `${s.player_count} in · ${sats(s.pot_msat)} sats at stake` +
      (s.cashed_out_count ? ` · ${s.cashed_out_count} out` : '')
    : 'no bets yet';

  // Drive the 3D scene. Progress is log-scaled so the early climb is visible
  // and the tail does not saturate instantly.
  if (scene) {
    scene.setState(Math.log(Math.max(1, current)) / Math.log(25),
                   s.state === 'settled');
  }

  const action = $('action');
  const hint = $('hint');

  switch (s.state) {
    case 'betting_open':
      $('state').textContent =
        `betting closes in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
      action.textContent = myBet
        ? (autoTarget() ? `In — auto out at ${autoTarget().toFixed(2)}×` : 'In — good luck')
        : 'Place bet';
      action.className = 'primary big';
      action.disabled = !!myBet;
      break;

    case 'locked':
      $('state').textContent = 'launching';
      action.textContent = 'Launching…';
      action.disabled = true;
      break;

    case 'running': {
      $('state').textContent = 'in flight';
      if (!wasRunning) requestAnimationFrame(interpolate);
      if (myBet === 'in') {
        const payout = stake * current;
        action.textContent = `Cash out ${sats(payout)}`;
        action.className = 'primary big cashout';
        action.disabled = false;
      } else {
        action.textContent = myBet === 'out' ? 'Cashed out' : 'Watching';
        action.className = 'primary big';
        action.disabled = true;
      }
      break;
    }

    case 'settled': {
      const crash = s.crash_point ? parseFloat(s.crash_point) : current;
      mult.textContent = crash.toFixed(2) + '×';
      mult.className = myBet === 'out' ? 'multiplier won' : 'multiplier crashed';
      $('state').textContent =
        `crashed — next round in ${Math.max(0, s.next_phase_in_seconds).toFixed(0)}s`;
      action.textContent = 'Bet again';
      action.className = 'primary big';
      action.disabled = false;

      // Record the round once, on the transition into settled.
      if (!wasSettled && s.crash_point) {
        stats.crashes.push(crash);
        if (myBet === 'in') {
          stats.losses++;
          // Near-miss psychology: show how close they were
          const autoTarget = parseFloat($('auto-target').value || '0');
          if (autoTarget > 1 && crash > 1.0 && crash < autoTarget) {
            hint.innerHTML = `Almost! Crashed at ${crash.toFixed(2)}× — you were ${((autoTarget - crash) * 100).toFixed(0)}% away from ${autoTarget.toFixed(2)}×.`;
            hint.className = 'hint near-miss';
          } else {
            hint.textContent = crash < 1.5 ? 'Brutal — early crash.' : crash < 3 ? 'Rode it too far.' : 'So close to a monster.';
            hint.className = 'hint bad';
          }
          stats.streak = 0;
          buzz(120);
        } else if (myBet === 'out') {
          stats.wins++;
          stats.streak = (stats.streak || 0) + 1;
          const payout = Math.round(stake * crash / 1000);
          hint.textContent = stats.streak >= 5
            ? `🔥 ${stats.streak} IN A ROW! +${sats(payout * 1000)} sats`
            : stats.streak >= 3
            ? `On fire! ${stats.streak} wins straight. +${sats(payout * 1000)} sats`
            : `Won +${sats(payout * 1000)} sats at ${crash.toFixed(2)}×`;
          hint.className = 'hint good';
          if (stats.streak >= 3) buzz([20, 30, 20, 30, 40]);
          else buzz([30, 40, 30]);
          // Auto-increment stake on hot streak
          if (stats.streak >= 3 && stake < 25000) {
            const newStake = stake * 2;
            setStake(newStake);
            hint.textContent += ' • Stake doubled!';
          }
        }
        if (!stats.best || crash > stats.best) stats.best = crash;
        saveStats();
        renderStrip();
        refreshBalance();
      }
      // Pre-fill for instant re-bet: auto-bet on next round
      if (!wasSettled && myBet === 'out') {
        action.classList.add('pulse');
      }
      break;
    }
  }

  renderPlayers(s.players);
}

/* The recent-rounds strip under the stage. */
function renderStrip() {
  const wrap = $('strip');
  clear(wrap);
  for (const c of stats.crashes.slice(-24).reverse()) {
    const cls = c >= 10 ? 'pip high' : c >= 2 ? 'pip mid' : 'pip';
    wrap.appendChild(el('span', { class: cls, text: c.toFixed(2) + '×' }));
  }
}

function renderPlayers(players) {
  const wrap = $('players');
  clear(wrap);
  for (const p of players || []) {
    const amount = p.cashed_out
      ? (p.auto ? '⚡ ' : '↑ ') + parseFloat(p.cashed_out).toFixed(2) + '×'
      : sats(p.stake_msat) + ' sats';
    wrap.appendChild(el('div', { class: 'player' + (p.cashed_out ? ' out' : '') },
      el('span', { class: 'who', text: p.nickname || 'anon' }),
      el('span', { class: 'amt', text: amount })));
  }
}

async function refreshBalance() {
  try {
    const b = await api('GET', '/api/balance');
    setBalance(b.balance_msat);
  } catch { /* a failed refresh is cosmetic; the ledger is still correct */ }
}

async function onAction() {
  const hint = $('hint');
  hint.textContent = '';
  hint.className = 'hint';
  try {
    if (snapshot.state === 'betting_open' && !myBet) {
      const r = await api('POST', '/api/bet', {
        game: currentGame, stake_msat: stake, nickname,
        auto_cashout: autoTarget(),
      });
      setBalance(r.balance_msat);
      stats.wagered += stake;
      stats.plays++;
      saveStats();
      myBet = 'in';
      buzz(20);
    } else if (snapshot.state === 'running' && myBet === 'in') {
      const r = await api('POST', '/api/cashout', { game: currentGame });
      myBet = 'out';
      const at = parseFloat(r.cashed_out_at);
      const won = stake * at;
      if (won > stats.best) stats.best = won;
      saveStats();
      hint.textContent = `Out at ${at.toFixed(2)}× — paid at settlement.`;
      hint.className = 'hint good';
      buzz([25, 30, 25]);
      refreshBalance();
    }
  } catch (e) {
    hint.textContent = e.message;
    hint.className = 'hint bad';
  }
}

/* ---------------- ambient sound ---------------- */

let audio = null;

function toggleSound() {
  if (audio) {
    audio.close(); audio = null;
    $('sound-toggle').classList.remove('on');
    return;
  }
  audio = new (window.AudioContext || window.webkitAudioContext)();
  const gain = audio.createGain();
  gain.gain.value = 0.05;
  gain.connect(audio.destination);

  // Two detuned oscillators a fifth apart: calm, wide, and never resolving.
  [55, 82.5].forEach((f) => {
    const o = audio.createOscillator();
    o.type = 'sine';
    o.frequency.value = f;
    const lfo = audio.createOscillator();
    lfo.frequency.value = 0.05 + Math.random() * 0.06;
    const depth = audio.createGain();
    depth.gain.value = 1.5;
    lfo.connect(depth).connect(o.frequency);
    o.connect(gain);
    o.start(); lfo.start();
  });
  $('sound-toggle').classList.add('on');
}

/* ---------------- scratch tickets ---------------- */

const SYMBOLS = ['✦', '◈', '⬡', '✧', '◉', '⟡'];

async function loadScratch() {
  const { tickets } = await api('GET', '/api/scratch/catalog');
  const wrap = $('tickets');
  clear(wrap);

  for (const t of tickets) {
    const grid = el('div', { class: 'grid c' + t.cells });
    for (let i = 0; i < t.cells; i++) {
      grid.appendChild(el('div', { class: 'cell', text: '?' }));
    }

    const result = el('div', { class: 'result' });
    const button = el('button', { class: 'primary', text: `Scratch for ${sats(stake)} sats` });
    button.dataset.play = t.id;
    button.onclick = () => playScratch(t, grid, result, button);

    const table = el('table', { class: 'odds' },
      el('tr', {},
        el('th', { text: 'prize' }),
        el('th', { text: 'pays' }),
        el('th', { text: 'chance' })));
    for (const o of t.odds) {
      table.appendChild(el('tr', {},
        el('td', { text: o.tier }),
        el('td', { text: (o.payout_bp / 10000).toFixed(o.payout_bp % 10000 ? 2 : 0) + '×' }),
        el('td', { text: o.one_in ? '1 in ' + o.one_in.toLocaleString() : '—' })));
    }

    wrap.appendChild(el('div', { class: 'card' },
      el('h3', { text: t.name }),
      el('p', { class: 'muted small', text: t.blurb }),
      grid,
      result,
      button,
      table,
      el('p', { class: 'small' },
        'Return to player: ',
        el('span', { class: 'rtp', text: (t.rtp_bp / 100).toFixed(2) + '%' }),
        '. These odds are read from the same table that generates results.')));
  }
}

async function playScratch(t, grid, result, button) {
  result.textContent = '';
  result.className = 'result';
  button.disabled = true;
  [...grid.children].forEach((c) => { c.className = 'cell'; c.textContent = '?'; });

  let out;
  try {
    out = await api('POST', '/api/scratch/play', { ticket_id: t.id, stake_msat: stake });
  } catch (e) {
    result.textContent = e.message;
    button.disabled = false;
    return;
  }
  setBalance(out.balance_msat);
  stats.wagered += stake;
  stats.plays++;
  if (out.outcome.payout_msat > stats.best) stats.best = out.outcome.payout_msat;
  if (out.outcome.payout_msat > 0) stats.wins++; else stats.losses++;
  saveStats();

  const cells = out.outcome.cells;
  const counts = {};
  cells.forEach((c) => (counts[c] = (counts[c] || 0) + 1));
  const winner = Object.keys(counts).find((k) => counts[k] >= 3);

  cells.forEach((sym, i) => {
    setTimeout(() => {
      const cell = grid.children[i];
      cell.textContent = SYMBOLS[sym];
      cell.className = 'cell revealed' + (String(sym) === winner ? ' hit' : '');
      buzz(8);
      if (i === cells.length - 1) {
        const won = out.outcome.payout_msat > 0;
        result.className = 'result' + (won ? ' win' : '');
        result.textContent = won
          ? `${out.outcome.tier_name} — ${sats(out.outcome.payout_msat)} sats`
          : 'No win this time';
        if (won) buzz([40, 50, 40]);
        button.disabled = false;
      }
    }, i * 120);
  });
}

/* ---------------- portfolio ---------------- */

function renderPortfolio() {
  $('t-balance').textContent = sats(balanceMsat);
  charts.sparkline($('spark-balance'), stats.balances.slice(-40));

  const delta = stats.sessionStart == null ? 0 : balanceMsat - stats.sessionStart;
  const sess = $('t-session');
  sess.textContent = (delta >= 0 ? '+' : '') + sats(delta);
  sess.className = 'tile-value ' + (delta > 0 ? 'up' : delta < 0 ? 'down' : '');

  $('t-wagered').textContent = sats(stats.wagered);
  $('t-plays').textContent = `${stats.plays} play${stats.plays === 1 ? '' : 's'}`;
  $('t-best').textContent = sats(stats.best);

  charts.balanceChart($('chart-balance'),
    stats.balances.map((b) => ({ BalanceAfter: b })));
  charts.winLossDonut($('chart-donut'), stats.wins, stats.losses);
  charts.distributionChart($('chart-dist'), stats.crashes);
  charts.crashHistoryChart($('chart-history'), stats.crashes);

  const total = stats.wins + stats.losses;
  $('donut-note').textContent = total
    ? `${stats.wins} cashed out, ${stats.losses} rode into the crash, across ${total} rounds.`
    : '';
}

/* ---------------- lightning ----------------
 *
 * The deposit and withdraw cards stay hidden unless the server reports a node,
 * so a play-money deployment does not advertise a deposit it cannot honour. */

let depositHash = null;
let depositPoll = null;

async function detectLightning() {
  try {
    // A zero-amount request is rejected either way; what distinguishes the two
    // cases is whether the server says "not configured" or "bad amount".
    const res = await fetch('/api/deposit', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json',
                 Authorization: 'Bearer ' + token },
      body: JSON.stringify({ amount_sats: 0 }),
    });
    const data = await res.json().catch(() => ({}));
    const configured = !(data.error || '').includes('lightning not configured');
    $('card-deposit').hidden = !configured;
    $('card-withdraw').hidden = !configured;
  } catch {
    $('card-deposit').hidden = true;
    $('card-withdraw').hidden = true;
  }
}

async function createDeposit() {
  const hint = $('dep-hint');
  hint.textContent = '';
  hint.className = 'hint';

  const amount = Math.floor(Number($('dep-amt').value));
  if (!Number.isFinite(amount) || amount < 1) {
    hint.textContent = 'Enter an amount in sats.';
    hint.className = 'hint bad';
    return;
  }

  let res;
  try {
    res = await api('POST', '/api/deposit', { amount_sats: amount });
  } catch (e) {
    hint.textContent = e.message;
    hint.className = 'hint bad';
    return;
  }

  depositHash = res.payment_hash;
  $('dep-bolt11').textContent = res.invoice;
  $('dep-invoice').hidden = false;

  const wrap = $('dep-qr');
  clear(wrap);
  // Uppercase for the QR: bech32 is case-insensitive and uppercase encodes far
  // more densely, which keeps the symbol scannable on a phone screen.
  const matrix = qr.encode(res.invoice.toUpperCase());
  if (matrix) {
    wrap.appendChild(qr.render(matrix));
  } else {
    wrap.textContent = 'Invoice too long to show as a code — copy it instead.';
  }

  $('dep-status').textContent = 'Waiting for payment…';
  startDepositPolling();
}

/* Poll for settlement. The server confirms with the node before crediting, so
 * this cannot be used to claim a payment that never arrived. */
function startDepositPolling() {
  if (depositPoll) clearInterval(depositPoll);
  let attempts = 0;
  depositPoll = setInterval(async () => {
    if (!depositHash || ++attempts > 120) { // give up after ~10 minutes
      clearInterval(depositPoll);
      depositPoll = null;
      return;
    }
    try {
      const res = await api('POST', '/api/deposit/check',
        { payment_hash: depositHash });
      if (res.credited_msat > 0 || res.settled) {
        clearInterval(depositPoll);
        depositPoll = null;
        depositHash = null;
        $('dep-status').textContent = 'Paid. Balance updated.';
        $('dep-invoice').hidden = true;
        buzz([40, 50, 40]);
        await refreshBalance();
        loadHistory();
      }
    } catch {
      // Not settled yet is the normal case and arrives as an error; keep
      // waiting rather than treating it as a failure.
    }
  }, 5000);
}

async function withdraw() {
  const hint = $('wd-hint');
  hint.textContent = '';
  hint.className = 'hint';

  const bolt11 = $('wd-bolt11').value.trim();
  const amount = Math.floor(Number($('wd-amt').value));
  if (!bolt11.toLowerCase().startsWith('ln')) {
    hint.textContent = 'That does not look like a Lightning invoice.';
    hint.className = 'hint bad';
    return;
  }
  if (!Number.isFinite(amount) || amount < 1) {
    hint.textContent = 'Enter an amount in sats.';
    hint.className = 'hint bad';
    return;
  }

  try {
    const res = await api('POST', '/api/withdraw',
      { bolt11, amount_sats: amount });
    hint.className = 'hint good';
    hint.textContent = res.status === 'needs_approval'
      ? 'Queued for operator approval — large withdrawals are reviewed.'
      : 'Queued. It will be paid within about fifteen seconds.';
    $('wd-bolt11').value = '';
    $('wd-amt').value = '';
    await refreshBalance();
    loadHistory();
  } catch (e) {
    hint.textContent = e.message;
    hint.className = 'hint bad';
  }
}

/* ---------------- wallet ---------------- */

async function loadHistory() {
  const { entries } = await api('GET', '/api/history');
  const wrap = $('history');
  clear(wrap);
  for (const e of entries || []) {
    wrap.appendChild(el('div', { class: 'entry' },
      el('span', { class: 'kind', text: e.Kind }),
      el('span', {
        class: 'delta ' + (e.AmountMsat > 0 ? 'pos' : 'neg'),
        text: (e.AmountMsat > 0 ? '+' : '') + sats(e.AmountMsat),
      }),
      el('span', { class: 'after', text: sats(e.BalanceAfter) })));
  }
}

async function sendSats() {
  const hint = $('send-hint');
  try {
    const r = await api('POST', '/api/transfer', {
      to_pubkey: $('to-key').value.trim(),
      amount_msat: Math.round(Number($('send-amt').value) * 1000),
    });
    setBalance(r.balance_msat);
    hint.textContent = 'Sent.';
    hint.className = 'hint good';
    loadHistory();
  } catch (e) {
    hint.textContent = e.message;
    hint.className = 'hint bad';
  }
}

/* ---------------- verifier ---------------- */

async function sha256(bytes) {
  return new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
}

async function hmacSha256(keyBytes, msg) {
  const key = await crypto.subtle.importKey(
    'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
  return new Uint8Array(await crypto.subtle.sign('HMAC', key, msg));
}

function concat(...arrays) {
  const total = arrays.reduce((n, a) => n + a.length, 0);
  const out = new Uint8Array(total);
  let off = 0;
  for (const a of arrays) { out.set(a, off); off += a.length; }
  return out;
}

function kvRow(label, value) {
  return el('div', { class: 'kv' },
    el('span', { text: label }),
    el('code', { text: value }));
}

async function verifyRound() {
  const out = $('verify-out');
  clear(out);
  out.appendChild(el('p', { text: 'checking…' }));

  let r;
  try {
    r = await api('GET', '/api/verify/' + Number($('verify-id').value));
  } catch (e) {
    clear(out);
    out.appendChild(el('p', { class: 'bad', text: e.message }));
    return;
  }

  // 1. The revealed seed must hash to the commitment published beforehand.
  const seedBytes = unhex(r.server_seed);
  const seedHash = hex(await sha256(seedBytes));
  const commitOK = seedHash === r.commitment;

  // 2. The client seed must be the hash over participants, length-prefixed.
  const parts = [];
  for (const p of r.participants || []) {
    const pk = unhex(p);
    const len = new Uint8Array(4);
    new DataView(len.buffer).setUint32(0, pk.length, false);
    parts.push(len, pk);
  }
  const clientSeed = await sha256(concat(...parts));
  const clientOK = hex(clientSeed) === r.client_seed;

  // 3. The round seed follows from both, and determines the crash point.
  const nonceBytes = new Uint8Array(8);
  new DataView(nonceBytes.buffer).setBigUint64(0, BigInt(r.nonce), false);
  const roundSeed = await hmacSha256(seedBytes, concat(clientSeed, nonceBytes));

  const allOK = commitOK && clientOK;
  clear(out);
  out.appendChild(el('p', {
    class: allOK ? 'ok' : 'bad',
    text: allOK ? '✓ This round checks out.' : '✗ Verification failed.',
  }));
  out.appendChild(kvRow('commitment', r.commitment));
  out.appendChild(kvRow('sha256(seed)', seedHash));
  out.appendChild(kvRow('client seed', r.client_seed || ''));
  out.appendChild(kvRow('recomputed', hex(clientSeed)));
  out.appendChild(kvRow('round seed', hex(roundSeed)));
  out.appendChild(kvRow('crash point',
    r.crash_point ? (r.crash_point / 4294967296).toFixed(2) + '×' : '—'));
  out.appendChild(el('p', {
    class: 'muted small',
    text: 'Computed on this device. The server was asked only for the published '
        + 'values, not for its opinion.',
  }));
}

/* ---------------- wiring ---------------- */

function selectTab(view) {
  document.querySelectorAll('.tab').forEach((t) =>
    t.classList.toggle('active', t.dataset.view === view));
  document.querySelectorAll('.view').forEach((v) =>
    (v.hidden = v.id !== 'view-' + view));

  // The 3D loop only runs while its view is visible.
  if (scene) { if (view === 'crash') scene.start(); else scene.stop(); }
  if (view === 'wallet') loadHistory();
  if (view === 'portfolio') renderPortfolio();
}

function setStake(v) {
  stake = v;
  document.querySelectorAll('.chip').forEach((c) =>
    c.classList.toggle('on', Number(c.dataset.stake) === v));
  document.querySelectorAll('[data-play]').forEach((b) => {
    b.textContent = `Scratch for ${sats(stake)} sats`;
  });
}

async function init() {
  keypair = await loadOrCreateKey();
  $('keynote').textContent = 'your key: ' + keypair.publicKeyHex.slice(0, 16) + '…';
  $('nickname').value = localStorage.getItem(NAME_STORAGE) || '';

  $('enter').onclick = () => signIn().catch((e) => {
    $('keynote').textContent = e.message;
  });
  $('action').onclick = onAction;
  $('sound-toggle').onclick = toggleSound;
  $('send').onclick = sendSats;
  $('do-verify').onclick = verifyRound;
  $('copykey').onclick = () => navigator.clipboard.writeText(keypair.publicKeyHex);

  document.querySelectorAll('.tab').forEach((t) =>
    (t.onclick = () => selectTab(t.dataset.view)));
  document.querySelectorAll('.chip').forEach((c) =>
    (c.onclick = () => setStake(Number(c.dataset.stake))));

  $('auto-target').oninput = refreshAutoRow;

  $('do-deposit').onclick = createDeposit;
  $('do-withdraw').onclick = withdraw;
  $('dep-copy').onclick = () =>
    navigator.clipboard.writeText($('dep-bolt11').textContent);
  $('dep-check').onclick = async () => {
    if (!depositHash) return;
    try {
      const res = await api('POST', '/api/deposit/check',
        { payment_hash: depositHash });
      if (res.credited_msat > 0 || res.settled) {
        $('dep-status').textContent = 'Paid. Balance updated.';
        $('dep-invoice').hidden = true;
        depositHash = null;
        await refreshBalance();
      } else {
        $('dep-status').textContent = 'Not settled yet — still waiting.';
      }
    } catch (e) {
      $('dep-status').textContent = e.message;
    }
  };
  document.querySelectorAll('[data-dep]').forEach((b) => {
    b.onclick = () => { $('dep-amt').value = b.dataset.dep; };
  });
  document.querySelectorAll('[data-auto]').forEach((b) => {
    b.onclick = () => {
      $('auto-target').value = b.dataset.auto;
      refreshAutoRow();
    };
  });

  const pick = $('gamepick');
  [['rocket', 'Rocket'], ['orbital', 'Orbital'], ['tower', 'Tower']].forEach(([id, label]) => {
    const b = el('button', { text: label, class: id === currentGame ? 'on' : '' });
    b.onclick = () => {
      document.querySelectorAll('.gamepick button').forEach((x) => x.classList.remove('on'));
      b.classList.add('on');
      connect(id);
    };
    pick.appendChild(b);
  });

  setStake(stake);
  renderStrip();
}

init();
