// Command arcade is the Quantum Arcade server: one binary serving the API, the
// WebSocket round feed, and the static client.
package main

import (
	"context"
	"embed"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/coder/websocket"
	"github.com/drjones/quantum-arcade/pkg/cluster"
	"github.com/drjones/quantum-arcade/pkg/fair"
	"github.com/drjones/quantum-arcade/pkg/fees"
	"github.com/drjones/quantum-arcade/pkg/fixed"
	"github.com/drjones/quantum-arcade/pkg/identity"
	"github.com/drjones/quantum-arcade/pkg/ledger"
	"github.com/drjones/quantum-arcade/pkg/lightning"
	"github.com/drjones/quantum-arcade/pkg/room"
	"github.com/drjones/quantum-arcade/pkg/scratch"
	"github.com/drjones/quantum-arcade/pkg/sim"
	"github.com/drjones/quantum-arcade/pkg/tournament"
	"github.com/jackc/pgx/v5/pgxpool"
	"github.com/redis/go-redis/v9"
)

//go:embed static
var staticFiles embed.FS

// maxAutoCashOut bounds the target a client may request, keeping the
// conversion to fixed-point well inside the representable range.
const maxAutoCashOut = 1_000_000

// Games offered as shared rounds. They share one engine and differ in how the
// client renders the climb.
var games = []string{"rocket", "orbital", "tower"}

type server struct {
	pool        *pgxpool.Pool
	ledger      *ledger.Ledger
	auth        *identity.Authenticator
	rooms       map[string]*room.Room
	tournaments *tournament.Service
	hubs        map[string]*gameHub
	ln          *lightning.Service // Lightning deposit/withdrawal

	// Sessions live in Redis rather than instance memory. With several cloned
	// instances behind one endpoint, a token issued by one must be accepted by
	// all of them — otherwise every request would have to return to the
	// instance that happened to handle the sign-in.
	rdb  *redis.Client
	node *cluster.Node

	// scratchNonce advances per play so each ticket has a distinct seed.
	//
	// It is drawn from Redis rather than a local counter: with several
	// instances serving, two clones would otherwise hand the same nonce to
	// different players, and identical nonces mean identical outcomes for the
	// same key. The counter is shared, so every ticket is distinct fleet-wide.
}

func main() {
	dsn := os.Getenv("ARCADE_DSN")
	if dsn == "" {
		dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
	}
	addr := os.Getenv("ARCADE_ADDR")
	if addr == "" {
		addr = ":8080"
	}

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		log.Fatalf("connecting to database: %v", err)
	}
	defer pool.Close()
	if err := pool.Ping(ctx); err != nil {
		log.Fatalf("database unreachable: %v", err)
	}

	// Redis carries sessions and cluster coordination. Every cloned instance
	// points at the same one; that plus the same database is the entire
	// configuration a clone needs.
	redisAddr := os.Getenv("ARCADE_REDIS")
	if redisAddr == "" {
		redisAddr = "localhost:6379"
	}
	rdb := redis.NewClient(&redis.Options{Addr: redisAddr})
	defer rdb.Close()
	if err := rdb.Ping(ctx).Err(); err != nil {
		log.Fatalf("redis unreachable at %s: %v", redisAddr, err)
	}

	// Every ticket in the catalog must have coherent odds before we serve it.
	for _, t := range scratch.Catalog {
		if err := t.Validate(); err != nil {
			log.Fatalf("scratch catalog: %v", err)
		}
	}

	l := ledger.New(pool)
	s := &server{
		pool:        pool,
		ledger:      l,
		tournaments: tournament.New(pool, l),
		auth:        identity.NewAuthenticator(),
		rooms:       make(map[string]*room.Room),
		hubs:        make(map[string]*gameHub),
		rdb:         rdb,
	}

	// Identity is generated, not configured: a cloned VM boots with its own
	// id and joins the cluster without anyone editing a file.
	s.node = cluster.NewNode(rdb, advertiseAddr())
	if err := s.node.Start(ctx); err != nil {
		log.Fatalf("joining cluster: %v", err)
	}
	defer s.node.Stop(context.Background())
	log.Printf("instance %s (%s) advertising %s", s.node.ID, s.node.Hostname, s.node.Address)

	// ── Lightning (optional: dev faucet works without it) ──
	//
	// The faucet and a real node must never both be enabled. The faucet mints
	// balance backed by nothing; with a real node attached, a player can
	// withdraw that balance as actual satoshis and drain the node. Refusing to
	// start is the only safe response — a warning would be read once and
	// forgotten, and the failure is silent until the money is gone.
	if os.Getenv("ALBY_URL") != "" && os.Getenv("ARCADE_DEV_FAUCET") == "1" {
		log.Fatal("REFUSING TO START: ARCADE_DEV_FAUCET=1 with a real Lightning node " +
			"configured. The faucet mints unbacked balance, which could then be " +
			"withdrawn as real satoshis. Unset one of them.")
	}

	if url := os.Getenv("ALBY_URL"); url != "" {
		token := os.Getenv("ALBY_TOKEN")
		if token == "" {
			log.Printf("ALBY_URL set but ALBY_TOKEN empty — Lightning disabled")
		} else {
			albyNode := lightning.NewAlbyNode(url, token)
			limits := lightning.DefaultLimits()
			s.ln = lightning.New(albyNode, s.ledger, s.pool, limits)
			log.Printf("Lightning node connected: %s", url)
			// Process queued withdrawals every 15 seconds.
			go func() {
				ticker := time.NewTicker(15 * time.Second)
				defer ticker.Stop()
				for {
					select {
					case <-ctx.Done():
						return
					case <-ticker.C:
						// Check solvency before paying anything out. If the
						// node holds less than players are owed, paying the
						// front of the queue drains what is left and the
						// players behind them get nothing — the worst possible
						// order to discover a shortfall in.
						sol, err := s.ln.CheckSolvency(ctx)
						if err != nil {
							log.Printf("lightning: cannot verify solvency, holding withdrawals: %v", err)
							continue
						}
						if !sol.Solvent {
							log.Printf("lightning: HOLDING WITHDRAWALS — node holds %d msat "+
								"but players are owed %d msat (short by %d)",
								sol.NodeBalanceMsat, sol.OwedToPlayers, -sol.SurplusMsat)
							continue
						}
						if n, err := s.ln.ProcessWithdrawals(ctx, 10); err != nil {
							log.Printf("lightning: withdrawal processor: %v", err)
						} else if n > 0 {
							log.Printf("lightning: paid %d withdrawals", n)
						}
					}
				}
			}()
		}
	} else {
		log.Printf("ALBY_URL not set — Lightning disabled (dev faucet only)")
	}

	// One hub per game. Each hub campaigns for leadership: the winner drives
	// the rounds and publishes frames, the rest relay those frames to their
	// own clients. Roles are renegotiated continuously, so losing an instance
	// hands its rooms over without intervention.
	for _, g := range games {
		h := newGameHub(g, room.New(g, pool, s.ledger), s.node)
		s.rooms[g] = h.room
		s.hubs[g] = h
		go h.supervise(ctx)
	}

	// Sweep for rounds abandoned by an instance that died mid-flight and
	// refund their stakes. Every instance runs this; the claim is atomic, so
	// concurrent sweeps refund exactly once.
	go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)

	// Move tournaments through their lifecycle by wall clock. Every instance
	// runs this; the updates are idempotent, so it needs no leader.
	go func() {
		t := time.NewTicker(15 * time.Second)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				if err := s.tournaments.AdvanceSchedules(ctx); err != nil {
					log.Printf("tournaments: advancing schedules: %v", err)
				}
			}
		}
	}()

	srv := &http.Server{
		Addr:              addr,
		Handler:           s.routes(),
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		log.Printf("Quantum Arcade listening on %s", addr)
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("server: %v", err)
		}
	}()

	<-ctx.Done()
	log.Println("shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = srv.Shutdown(shutdownCtx)
}

func (s *server) routes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/health", s.handleHealth)
	mux.HandleFunc("POST /api/auth/challenge", s.handleChallenge)
	mux.HandleFunc("POST /api/auth/verify", s.handleVerify)
	mux.HandleFunc("GET /api/balance", s.handleBalance)
	mux.HandleFunc("GET /api/history", s.handleHistory)
	mux.HandleFunc("POST /api/transfer", s.handleTransfer)
	mux.HandleFunc("POST /api/deposit", s.handleDeposit)
	mux.HandleFunc("POST /api/deposit/check", s.handleDepositCheck)
	mux.HandleFunc("POST /api/withdraw", s.handleWithdraw)
	mux.HandleFunc("GET /api/games", s.handleGames)
	mux.HandleFunc("POST /api/bet", s.handleBet)
	mux.HandleFunc("POST /api/cashout", s.handleCashout)
	mux.HandleFunc("GET /api/scratch/catalog", s.handleScratchCatalog)
	mux.HandleFunc("POST /api/scratch/play", s.handleScratchPlay)
	mux.HandleFunc("GET /api/verify/{roundID}", s.handleVerifyRound)

	// The faucet exists so the arcade is playable before Lightning is wired
	// up. It mints from the bridge account exactly as a real deposit would,
	// so the ledger path under test is the production one. Off by default.
	if os.Getenv("ARCADE_DEV_FAUCET") == "1" {
		log.Println("dev faucet ENABLED — funds are not backed by Lightning")
		mux.HandleFunc("POST /api/dev/faucet", s.handleFaucet)
	}
	mux.HandleFunc("GET /ws/{game}", s.handleWS)
	mux.HandleFunc("GET /api/cluster", s.handleCluster)
	mux.HandleFunc("GET /api/fees", s.handleFees)
	mux.HandleFunc("GET /api/tournaments", s.handleTournaments)
	mux.HandleFunc("GET /api/tournaments/{id}/leaderboard", s.handleLeaderboard)
	mux.HandleFunc("POST /api/tournaments/{id}/enter", s.handleEnterTournament)

	// Mounted only when ARCADE_ADMIN_TOKEN is set, so a default deployment
	// has no admin surface at all.
	s.routesAdmin(mux)

	sub, err := fs.Sub(staticFiles, "static")
	if err != nil {
		log.Fatalf("static assets: %v", err)
	}
	mux.Handle("/", http.FileServer(http.FS(sub)))

	return logRequests(mux)
}

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		if r.URL.Path != "/api/health" {
			log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
		}
	})
}

// --- helpers ---

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

func writeErr(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

// SessionTTL bounds how long a token stays valid without use.
const SessionTTL = 24 * time.Hour

// session resolves the caller's public key from the Authorization header.
func (s *server) session(r *http.Request) (string, bool) {
	token := bearer(r)
	if token == "" {
		return "", false
	}
	pk, err := s.rdb.Get(r.Context(), "qa:session:"+token).Result()
	if err != nil {
		return "", false
	}
	return pk, true
}

func bearer(r *http.Request) string {
	token := r.Header.Get("Authorization")
	if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
		return token[7:]
	}
	return ""
}

// account resolves the caller to a ledger account id.
func (s *server) account(r *http.Request) (int64, []byte, bool) {
	pkHex, ok := s.session(r)
	if !ok {
		return 0, nil, false
	}
	pk, err := hex.DecodeString(pkHex)
	if err != nil {
		return 0, nil, false
	}
	id, err := s.ledger.EnsurePlayer(r.Context(), pk)
	if err != nil {
		return 0, nil, false
	}
	return id, pk, true
}

// --- handlers ---

func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
	total, err := s.ledger.ConservationCheck(r.Context())
	if err != nil {
		writeErr(w, http.StatusServiceUnavailable, err.Error())
		return
	}
	// A non-zero total means the books do not balance, which is a hard fault.
	status := "ok"
	if total != 0 {
		status = "ledger_imbalance"
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"status":          status,
		"ledger_sum_msat": total,
	})
}

func (s *server) handleChallenge(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Pubkey string `json:"pubkey"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "malformed request")
		return
	}
	nonce, err := s.auth.Challenge(req.Pubkey)
	if err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"challenge": nonce})
}

func (s *server) handleVerify(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Pubkey    string `json:"pubkey"`
		Signature string `json:"signature"`
		Nickname  string `json:"nickname"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "malformed request")
		return
	}
	if err := s.auth.Verify(req.Pubkey, req.Signature); err != nil {
		writeErr(w, http.StatusUnauthorized, err.Error())
		return
	}

	pk, _ := hex.DecodeString(req.Pubkey)
	accountID, err := s.ledger.EnsurePlayer(r.Context(), pk)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	if req.Nickname != "" {
		if _, err := s.pool.Exec(r.Context(),
			`UPDATE accounts SET nickname = $2 WHERE id = $1`, accountID, req.Nickname); err != nil {
			log.Printf("setting nickname: %v", err)
		}
	}

	var tokenBytes [32]byte
	seed := fair.NewServerSeed() // reuse the CSPRNG wrapper for session tokens
	tokenBytes = seed.Bytes()
	token := hex.EncodeToString(tokenBytes[:])

	if err := s.rdb.Set(r.Context(), "qa:session:"+token, req.Pubkey, SessionTTL).Err(); err != nil {
		writeErr(w, http.StatusInternalServerError, "could not store session")
		return
	}

	bal, _ := s.ledger.Balance(r.Context(), accountID)
	writeJSON(w, http.StatusOK, map[string]any{
		"token":        token,
		"balance_msat": bal,
	})
}

func (s *server) handleBalance(w http.ResponseWriter, r *http.Request) {
	id, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	bal, err := s.ledger.Balance(r.Context(), id)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
}

func (s *server) handleHistory(w http.ResponseWriter, r *http.Request) {
	id, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	entries, err := s.ledger.History(r.Context(), id, 50)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
}

func (s *server) handleTransfer(w http.ResponseWriter, r *http.Request) {
	from, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	var req struct {
		ToPubkey   string `json:"to_pubkey"`
		AmountMsat int64  `json:"amount_msat"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "malformed request")
		return
	}
	to, err := hex.DecodeString(req.ToPubkey)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "bad recipient key")
		return
	}
	toID, err := s.ledger.EnsurePlayer(r.Context(), to)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	if _, err := s.ledger.Transfer(r.Context(), from, toID, req.AmountMsat); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	bal, _ := s.ledger.Balance(r.Context(), from)
	writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
}

// ───────── Lightning deposit/withdrawal ─────────

type depositRequest struct {
	AmountSat int64 `json:"amount_sats"`
}

func (s *server) handleDeposit(w http.ResponseWriter, r *http.Request) {
	if s.ln == nil {
		writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
		return
	}
	acctID, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "sign in first")
		return
	}
	var req depositRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountSat < 1 {
		writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)")
		return
	}
	inv, err := s.ln.RequestDeposit(r.Context(), acctID, req.AmountSat*1000)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"payment_hash": inv.PaymentHash,
		"invoice":      inv.Bolt11,
		"amount_sats":  inv.AmountMsat / 1000,
		"expires_at":   inv.ExpiresAt,
	})
}

type depositCheckRequest struct {
	PaymentHash string `json:"payment_hash"`
}

func (s *server) handleDepositCheck(w http.ResponseWriter, r *http.Request) {
	if s.ln == nil {
		writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
		return
	}
	var req depositCheckRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.PaymentHash == "" {
		writeErr(w, http.StatusBadRequest, "payment_hash required")
		return
	}
	credited, err := s.ln.SettleDeposit(r.Context(), req.PaymentHash)
	if err != nil {
		writeJSON(w, http.StatusOK, map[string]any{"settled": false, "error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"settled":       true,
		"credited_msat": credited,
	})
}

type withdrawRequest struct {
	Bolt11    string `json:"bolt11"`
	AmountSat int64  `json:"amount_sats"`
}

func (s *server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
	if s.ln == nil {
		writeErr(w, http.StatusServiceUnavailable, "lightning not configured")
		return
	}
	acctID, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "sign in first")
		return
	}
	var req withdrawRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Bolt11 == "" || req.AmountSat < 1 {
		writeErr(w, http.StatusBadRequest, "bolt11 and amount_sats required (>=1)")
		return
	}
	id, err := s.ln.RequestWithdrawal(r.Context(), acctID, req.Bolt11, req.AmountSat*1000)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"withdrawal_id": id,
		"status":        "queued",
	})
}

func (s *server) handleGames(w http.ResponseWriter, r *http.Request) {
	// Serve the last frame each hub saw rather than the local room object:
	// on an instance that does not lead a game, the local room is idle and
	// would report a game that never starts.
	out := make([]json.RawMessage, 0, len(games))
	for _, g := range games {
		hub := s.hubs[g]
		if frame, ok := hub.LastFrame(); ok {
			out = append(out, json.RawMessage(frame))
			continue
		}
		// Nothing seen yet — fall back to the local view, which is correct
		// during the moment before the first frame arrives.
		snap, err := json.Marshal(s.rooms[g].Snapshot())
		if err != nil {
			continue
		}
		out = append(out, json.RawMessage(snap))
	}
	writeJSON(w, http.StatusOK, map[string]any{"rooms": out})
}

func (s *server) handleBet(w http.ResponseWriter, r *http.Request) {
	id, pk, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	body, err := readBody(r)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "could not read request")
		return
	}
	var req struct {
		Game      string `json:"game"`
		StakeMsat int64  `json:"stake_msat"`
		Nickname  string `json:"nickname"`
		// AutoCashOut is an optional target multiplier, e.g. 2.5 for 2.50x.
		// Zero or absent means no target.
		AutoCashOut float64 `json:"auto_cashout"`
	}
	if err := json.Unmarshal(body, &req); err != nil {
		writeErr(w, http.StatusBadRequest, "malformed request")
		return
	}
	rm, ok := s.rooms[req.Game]
	if !ok {
		writeErr(w, http.StatusNotFound, "no such game")
		return
	}
	// Only the instance driving this game holds the authoritative round.
	if s.forwardToLeader(w, r, req.Game, body) {
		return
	}
	// Convert the target to fixed-point at the boundary; everything past this
	// point is integer arithmetic.
	var target fixed.F
	if req.AutoCashOut > 0 {
		if req.AutoCashOut > float64(maxAutoCashOut) {
			writeErr(w, http.StatusBadRequest, "auto cash-out target is too large")
			return
		}
		target = fixed.F(req.AutoCashOut * float64(fixed.One))
	}

	if err := rm.PlaceBet(r.Context(), id, pk, req.Nickname, req.StakeMsat, target); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	bal, _ := s.ledger.Balance(r.Context(), id)
	writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
}

func (s *server) handleCashout(w http.ResponseWriter, r *http.Request) {
	id, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	body, err := readBody(r)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "could not read request")
		return
	}
	var req struct {
		Game string `json:"game"`
	}
	if err := json.Unmarshal(body, &req); err != nil {
		writeErr(w, http.StatusBadRequest, "malformed request")
		return
	}
	rm, ok := s.rooms[req.Game]
	if !ok {
		writeErr(w, http.StatusNotFound, "no such game")
		return
	}
	if s.forwardToLeader(w, r, req.Game, body) {
		return
	}
	at, err := rm.CashOut(id)
	if err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"cashed_out_at": at.String()})
}

func (s *server) handleScratchCatalog(w http.ResponseWriter, r *http.Request) {
	type entry struct {
		ID    string            `json:"id"`
		Name  string            `json:"name"`
		Blurb string            `json:"blurb"`
		Cells int               `json:"cells"`
		RTPBP uint64            `json:"rtp_bp"`
		Odds  []scratch.OddsRow `json:"odds"`
	}
	out := make([]entry, 0, len(scratch.Catalog))
	for _, t := range scratch.Catalog {
		out = append(out, entry{
			ID: t.ID, Name: t.Name, Blurb: t.Blurb, Cells: t.Cells,
			RTPBP: t.RTPBasisPoints(), Odds: t.Odds(),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"tickets": out})
}

func (s *server) handleScratchPlay(w http.ResponseWriter, r *http.Request) {
	id, pk, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	var req struct {
		TicketID  string `json:"ticket_id"`
		StakeMsat int64  `json:"stake_msat"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeErr(w, http.StatusBadRequest, "malformed request")
		return
	}
	ticket, ok := scratch.ByID(req.TicketID)
	if !ok {
		writeErr(w, http.StatusNotFound, "no such ticket")
		return
	}
	if req.StakeMsat <= 0 {
		writeErr(w, http.StatusBadRequest, "stake must be positive")
		return
	}

	house, err := s.ledger.AccountByName(r.Context(), "house_pot")
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	// Take the stake first: if the player cannot cover it, nothing else happens.
	if _, err := s.ledger.Post(r.Context(), "scratch_stake", nil, []ledger.Posting{
		{AccountID: id, AmountMsat: -req.StakeMsat},
		{AccountID: house, AmountMsat: req.StakeMsat},
	}); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}

	n, err := s.rdb.Incr(r.Context(), "qa:scratch:nonce").Result()
	if err != nil {
		writeErr(w, http.StatusServiceUnavailable, "could not allocate a nonce")
		return
	}
	nonce := uint64(n)

	server := fair.NewServerSeed()
	outcome, proof := scratch.PlayFromRound(ticket, server, pk, nonce, req.StakeMsat)

	if outcome.PayoutMsat > 0 {
		if _, err := s.ledger.Post(r.Context(), "scratch_payout", nil, []ledger.Posting{
			{AccountID: house, AmountMsat: -outcome.PayoutMsat},
			{AccountID: id, AmountMsat: outcome.PayoutMsat},
		}); err != nil {
			// The house cannot cover the prize. Record it and surface it
			// rather than silently voiding a winning ticket.
			log.Printf("scratch payout failed for account %d: %v", id, err)
			writeErr(w, http.StatusInternalServerError, "house cannot cover this prize; stake refunded")
			_, _ = s.ledger.Post(r.Context(), "scratch_refund", nil, []ledger.Posting{
				{AccountID: house, AmountMsat: -req.StakeMsat},
				{AccountID: id, AmountMsat: req.StakeMsat},
			})
			return
		}
	}

	commitment := server.Commitment()
	seedBytes := server.Bytes()
	if _, err := s.pool.Exec(r.Context(),
		`INSERT INTO scratch_plays
		   (account_id, ticket_id, nonce, commitment, server_seed,
		    stake_msat, tier_name, payout_msat, cells)
		 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
		id, ticket.ID, int64(nonce), commitment[:], seedBytes[:],
		req.StakeMsat, outcome.TierName, outcome.PayoutMsat, outcome.Cells); err != nil {
		log.Printf("recording scratch play: %v", err)
	}

	bal, _ := s.ledger.Balance(r.Context(), id)
	writeJSON(w, http.StatusOK, map[string]any{
		"outcome":      outcome,
		"proof":        proof,
		"balance_msat": bal,
	})
}

// handleTournaments lists what a player can currently join or watch.
func (s *server) handleTournaments(w http.ResponseWriter, r *http.Request) {
	active, err := s.tournaments.Active(r.Context())
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"tournaments": active})
}

func (s *server) handleLeaderboard(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "bad tournament id")
		return
	}
	board, err := s.tournaments.Leaderboard(r.Context(), id, 100)
	if err != nil {
		writeErr(w, http.StatusNotFound, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"standings": board})
}

func (s *server) handleEnterTournament(w http.ResponseWriter, r *http.Request) {
	account, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "bad tournament id")
		return
	}
	if err := s.tournaments.Enter(r.Context(), id, account); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	bal, _ := s.ledger.Balance(r.Context(), account)
	writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
}

// handleFees publishes exactly what the operator takes. It is generated from
// the same schedule the code charges, so the published terms cannot drift from
// the behaviour.
func (s *server) handleFees(w http.ResponseWriter, r *http.Request) {
	sch := fees.DefaultSchedule()
	tickets := make([]map[string]any, 0, len(scratch.Catalog))
	for _, t := range scratch.Catalog {
		tickets = append(tickets, map[string]any{
			"ticket":           t.Name,
			"game_rtp_percent": fmt.Sprintf("%.2f%%", float64(t.RTPBasisPoints())/100),
			"effective_percent": fmt.Sprintf("%.2f%%",
				float64(sch.EffectiveRTPBasisPoints(int64(t.RTPBasisPoints())))/100),
		})
	}
	crashRTP := int64(10000 - sim.HouseEdgeBP)
	writeJSON(w, http.StatusOK, map[string]any{
		"schedule": sch.Describe(crashRTP),
		"crash_games": map[string]string{
			"game_rtp_percent": fmt.Sprintf("%.2f%%", float64(crashRTP)/100),
			"effective_percent": fmt.Sprintf("%.2f%%",
				float64(sch.EffectiveRTPBasisPoints(crashRTP))/100),
		},
		"scratch_tickets": tickets,
	})
}

// handleCluster reports the instances currently serving and which of them
// drives each game. This is the operator's view of a cloned fleet.
func (s *server) handleCluster(w http.ResponseWriter, r *http.Request) {
	members, err := s.node.Members(r.Context())
	if err != nil {
		writeErr(w, http.StatusServiceUnavailable, err.Error())
		return
	}
	leaders := make(map[string]any, len(games))
	for _, g := range games {
		m, err := s.node.LeaderOf(r.Context(), g)
		if err != nil {
			continue
		}
		leaders[g] = map[string]any{
			"instance": m.ID,
			"hostname": m.Hostname,
			"address":  m.Address,
			"is_me":    m.ID == s.node.ID,
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"this_instance": map[string]string{
			"id": s.node.ID, "hostname": s.node.Hostname, "address": s.node.Address,
		},
		"members": members,
		"leaders": leaders,
	})
}

// handleFaucet credits the caller from the bridge account. Development only.
func (s *server) handleFaucet(w http.ResponseWriter, r *http.Request) {
	id, _, ok := s.account(r)
	if !ok {
		writeErr(w, http.StatusUnauthorized, "not signed in")
		return
	}
	var req struct {
		AmountMsat int64 `json:"amount_msat"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.AmountMsat <= 0 {
		req.AmountMsat = 100_000_000 // 100k sats
	}
	if _, err := s.ledger.Deposit(r.Context(), id, req.AmountMsat); err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	bal, _ := s.ledger.Balance(r.Context(), id)
	writeJSON(w, http.StatusOK, map[string]any{"balance_msat": bal})
}

// handleVerifyRound returns everything needed to check a settled round.
func (s *server) handleVerifyRound(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("roundID")
	roundID, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeErr(w, http.StatusBadRequest, "bad round id")
		return
	}

	var game string
	var nonce int64
	var commitment, serverSeed, clientSeed []byte
	var crashPoint *int64
	err = s.pool.QueryRow(r.Context(),
		`SELECT game, nonce, commitment, server_seed, client_seed, crash_point
		   FROM rounds WHERE id = $1`, roundID).
		Scan(&game, &nonce, &commitment, &serverSeed, &clientSeed, &crashPoint)
	if err != nil {
		writeErr(w, http.StatusNotFound, "round not found")
		return
	}
	if serverSeed == nil {
		writeErr(w, http.StatusConflict, "round has not settled yet; seed is still sealed")
		return
	}

	rows, err := s.pool.Query(r.Context(),
		`SELECT a.pubkey FROM bets b JOIN accounts a ON a.id = b.account_id
		  WHERE b.round_id = $1 ORDER BY b.id`, roundID)
	if err != nil {
		writeErr(w, http.StatusInternalServerError, err.Error())
		return
	}
	defer rows.Close()
	var participants []string
	for rows.Next() {
		var pk []byte
		if err := rows.Scan(&pk); err != nil {
			writeErr(w, http.StatusInternalServerError, err.Error())
			return
		}
		participants = append(participants, hex.EncodeToString(pk))
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"round_id":     roundID,
		"game":         game,
		"nonce":        nonce,
		"commitment":   hex.EncodeToString(commitment),
		"server_seed":  hex.EncodeToString(serverSeed),
		"client_seed":  hex.EncodeToString(clientSeed),
		"crash_point":  crashPoint,
		"participants": participants,
		"how_to_verify": "sha256(server_seed) must equal commitment; " +
			"client_seed is sha256 over each participant pubkey length-prefixed in join order; " +
			"round seed is hmac-sha256(server_seed, client_seed || big-endian nonce)",
	})
}

// handleWS streams round snapshots to a client.
func (s *server) handleWS(w http.ResponseWriter, r *http.Request) {
	game := r.PathValue("game")
	hub, ok := s.hubs[game]
	if !ok {
		writeErr(w, http.StatusNotFound, "no such game")
		return
	}
	rm := hub.room

	// The server is LAN-only, so any origin on the local network is acceptable.
	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		InsecureSkipVerify: true,
	})
	if err != nil {
		return
	}
	defer conn.CloseNow()

	ctx := r.Context()
	// Frames come from the hub, which produces them when this instance leads
	// the game and relays the leader's when it does not.
	updates, unsubscribe := hub.Subscribe()
	defer unsubscribe()

	// Send the current state immediately so a joining phone is never blank.
	first, err := json.Marshal(rm.Snapshot())
	if err != nil {
		return
	}
	if err := conn.Write(ctx, websocket.MessageText, first); err != nil {
		return
	}

	// Frames arrive pre-serialised: the room marshals once per broadcast and
	// every connection writes the same bytes. Marshalling per connection was
	// the dominant cost under load.
	for {
		select {
		case <-ctx.Done():
			return
		case payload, ok := <-updates:
			if !ok {
				return
			}
			if err := conn.Write(ctx, websocket.MessageText, payload); err != nil {
				return
			}
		}
	}
}
