// Package room runs the shared crash rounds.
//
// A round moves through four states: betting_open, locked, running, settled.
// The server seed is committed before betting opens and revealed only at
// settlement, so no one — including the operator — can know the crash point
// while bets are still being placed.
//
// All money movement goes through the ledger in a single transaction per
// settlement, which is what keeps the books balanced under load.
package room

import (
	"context"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"sort"
	"sync"
	"time"

	"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/ledger"
	"github.com/drjones/quantum-arcade/pkg/sim"
	"github.com/jackc/pgx/v5/pgxpool"
)

// State is the phase of a round.
type State string

const (
	StateBetting State = "betting_open"
	StateLocked  State = "locked"
	StateRunning State = "running"
	StateSettled State = "settled"
)

// Timings. The betting window is deliberately generous: at a party, people are
// walking up to their phones mid-round.
const (
	BettingWindow = 20 * time.Second
	LockedPause   = 3 * time.Second
	SettledPause  = 7 * time.Second
	TickInterval  = time.Second / sim.TickHz
)

// Bet is one player's position in the current round.
type Bet struct {
	AccountID   int64
	Pubkey      []byte
	Nickname    string
	StakeMsat   int64
	CashedOutAt fixed.F // zero until they cash out
	PayoutMsat  int64

	// AutoCashOutAt is an optional target set before the round starts. When
	// the multiplier reaches it the position closes automatically at exactly
	// that value — not at whatever the next tick happens to show — so the
	// player gets the number they chose. Zero means no target.
	AutoCashOutAt fixed.F
}

// Snapshot is what clients render. It carries the seed inputs so a client can
// verify the round the moment it settles.
type Snapshot struct {
	RoundID    int64  `json:"round_id"`
	Game       string `json:"game"`
	State      State  `json:"state"`
	Tick       int    `json:"tick"`
	Multiplier string `json:"multiplier"`
	Commitment string `json:"commitment"`
	ServerSeed string `json:"server_seed,omitempty"` // only once settled
	CrashPoint string `json:"crash_point,omitempty"` // only once settled
	// Players is capped at MaxListedPlayers. Sending every player to every
	// subscriber is O(n^2) in bandwidth and makes a large room impossible:
	// 50k players broadcast to 50k phones is gigabytes per second. The full
	// list is available on request; the feed carries the leaderboard.
	Players      []Player `json:"players"`
	PlayerCount  int      `json:"player_count"`
	PotMsat      int64    `json:"pot_msat"`
	CashedOut    int      `json:"cashed_out_count"`
	HousePotMsat int64    `json:"house_pot_msat"`
	NextPhaseIn  float64  `json:"next_phase_in_seconds"`

	// StartedUnixMilli is when the running phase began. Because the multiplier
	// curve is deterministic, a client can compute the current value locally
	// from this instead of being told it sixty times a second.
	StartedUnixMilli int64 `json:"started_unix_milli,omitempty"`
}

// MaxListedPlayers bounds the per-frame player list.
const MaxListedPlayers = 24

// BroadcastHz is how often a running round pushes a frame. Clients compute the
// multiplier locally between frames, so this only has to be often enough to
// correct drift and deliver cash-out news.
const BroadcastHz = 5

// Player is the public view of a participant.
type Player struct {
	Nickname string `json:"nickname"`
	// Pubkey is omitted from the live feed: it is 64 hex characters, it is
	// most of the frame, and nothing in the interface displays it. The full
	// participant list, with keys, is served by the verification endpoint
	// after settlement — which is where it actually matters.
	PubkeyHex  string `json:"pubkey,omitempty"`
	StakeMsat  int64  `json:"stake_msat"`
	CashedOut  string `json:"cashed_out,omitempty"`
	PayoutMsat int64  `json:"payout_msat"`
	// Auto is true when the position closed on its own target rather than a tap.
	Auto bool `json:"auto,omitempty"`
}

// Room runs one game's round loop.
type Room struct {
	Game string

	pool   *pgxpool.Pool
	ledger *ledger.Ledger

	// Fees is the operator's schedule. Deductions are posted as their own
	// ledger transaction rather than folded into the payout, so a player's
	// history shows the win and the fee as separate, itemised lines.
	Fees fees.Schedule

	mu         sync.RWMutex
	roundID    int64
	state      State
	tick       int
	nonce      uint64
	serverSeed fair.ServerSeed
	commitment [32]byte
	crashPoint fixed.F
	bets       map[int64]*Bet
	order      [][]byte // participant pubkeys in join order
	phaseEnds  time.Time

	subscribers map[chan []byte]struct{}
	subMu       sync.Mutex

	// runStarted is when the current running phase began.
	runStarted time.Time
}

func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
	return &Room{
		Game:        game,
		pool:        pool,
		ledger:      l,
		Fees:        fees.DefaultSchedule(),
		state:       StateSettled,
		bets:        make(map[int64]*Bet),
		subscribers: make(map[chan []byte]struct{}),
		phaseEnds:   time.Now(),
	}
}

// Subscribe returns a channel of snapshots. The channel is buffered and drops
// updates rather than blocking the round loop: a slow phone must never stall
// the game for everyone else.
func (r *Room) Subscribe() (<-chan []byte, func()) {
	ch := make(chan []byte, 4)
	r.subMu.Lock()
	r.subscribers[ch] = struct{}{}
	r.subMu.Unlock()

	return ch, func() {
		r.subMu.Lock()
		delete(r.subscribers, ch)
		close(ch)
		r.subMu.Unlock()
	}
}

// broadcast serialises the snapshot once and hands the same bytes to every
// subscriber.
//
// Letting each connection marshal its own copy costs ~355us per subscriber per
// frame, which at any real crowd size exceeds the tick interval by orders of
// magnitude. One marshal per frame turns fan-out into a pointer copy.
func (r *Room) broadcast() {
	payload, err := json.Marshal(r.Snapshot())
	if err != nil {
		fmt.Printf("room %s: marshalling snapshot: %v\n", r.Game, err)
		return
	}
	r.subMu.Lock()
	defer r.subMu.Unlock()
	for ch := range r.subscribers {
		select {
		case ch <- payload:
		default: // subscriber is behind; drop this frame rather than stall
		}
	}
}

// Run drives the round loop until the context is cancelled.
func (r *Room) Run(ctx context.Context) error {
	ticker := time.NewTicker(TickInterval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
			if err := r.step(ctx); err != nil {
				// A failed step must not kill the room; log-and-continue keeps
				// the arcade running even if the database blips.
				fmt.Printf("room %s: step error: %v\n", r.Game, err)
			}
		}
	}
}

func (r *Room) step(ctx context.Context) error {
	r.mu.Lock()
	state, phaseEnds := r.state, r.phaseEnds
	r.mu.Unlock()

	now := time.Now()

	switch state {
	case StateSettled:
		if now.After(phaseEnds) {
			return r.openRound(ctx)
		}
	case StateBetting:
		if now.After(phaseEnds) {
			r.mu.Lock()
			r.state = StateLocked
			r.phaseEnds = now.Add(LockedPause)
			r.mu.Unlock()
			r.broadcast()
		}
	case StateLocked:
		if now.After(phaseEnds) {
			return r.startRunning(ctx)
		}
	case StateRunning:
		r.mu.Lock()
		r.tick++
		reached := sim.MultiplierAt(r.tick)
		// Close any positions whose target has been met. This happens before
		// the crash check so a target at or below the crash point always pays,
		// regardless of where tick boundaries happen to fall.
		r.triggerAutoCashOutsLocked(reached)
		// A crash point beyond what the curve expresses would otherwise never
		// be reached, so the tick ceiling also ends the round.
		crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks
		r.mu.Unlock()

		if crashed {
			return r.settle(ctx)
		}
		// The multiplier is a pure function of the tick, and the client has
		// the same curve. So the feed does not need to carry it sixty times a
		// second: clients interpolate locally from StartedUnixMilli and the
		// server sends a correcting frame a few times a second.
		//
		// At 60Hz this fan-out was the single largest cost in the system. At
		// BroadcastHz it is a rounding error, and the animation is smoother
		// because it is no longer gated on network jitter.
		if r.tick%(sim.TickHz/BroadcastHz) == 0 {
			r.broadcast()
		}
	}
	return nil
}

// openRound commits to a fresh server seed and opens betting.
func (r *Room) openRound(ctx context.Context) error {
	seed := fair.NewServerSeed()
	commitment := seed.Commitment()

	r.mu.Lock()
	r.nonce++
	nonce := r.nonce
	r.mu.Unlock()

	var roundID int64
	err := r.pool.QueryRow(ctx,
		`INSERT INTO rounds (game, nonce, commitment) VALUES ($1, $2, $3) RETURNING id`,
		r.Game, int64(nonce), commitment[:]).Scan(&roundID)
	if err != nil {
		return fmt.Errorf("opening round: %w", err)
	}

	r.mu.Lock()
	r.roundID = roundID
	r.serverSeed = seed
	r.commitment = commitment
	r.crashPoint = 0
	r.tick = 0
	r.bets = make(map[int64]*Bet)
	r.order = nil
	r.state = StateBetting
	r.phaseEnds = time.Now().Add(BettingWindow)
	r.mu.Unlock()

	r.broadcast()
	return nil
}

// startRunning derives the crash point from the committed seed and the
// participant set, then begins the climb.
func (r *Room) startRunning(ctx context.Context) error {
	r.mu.Lock()
	clientSeed := fair.ClientSeed(r.order)
	roundSeed := fair.RoundSeed(r.serverSeed, clientSeed, r.nonce)
	r.crashPoint = sim.CrashPoint(roundSeed)
	r.state = StateRunning
	r.tick = 0
	r.runStarted = time.Now()
	roundID := r.roundID
	crash := r.crashPoint
	r.mu.Unlock()

	if _, err := r.pool.Exec(ctx,
		`UPDATE rounds SET locked_at = now(), client_seed = $2, crash_point = $3
		  WHERE id = $1`,
		roundID, clientSeed[:], int64(crash)); err != nil {
		return fmt.Errorf("locking round: %w", err)
	}

	r.broadcast()
	return nil
}

// settle pays out everyone who cashed out in time and reveals the seed.
// Payouts are written as one ledger transaction so the books cannot be left
// half-updated.
func (r *Room) settle(ctx context.Context) error {
	r.mu.Lock()
	roundID := r.roundID
	seed := r.serverSeed
	crash := r.crashPoint
	bets := make([]*Bet, 0, len(r.bets))
	for _, b := range r.bets {
		bets = append(bets, b)
	}
	r.state = StateSettled
	r.phaseEnds = time.Now().Add(SettledPause)
	r.mu.Unlock()

	house, err := r.ledger.AccountByName(ctx, "house_pot")
	if err != nil {
		return err
	}

	var payouts []ledger.Posting
	var feeLines []ledger.Posting
	var housePays, houseKeeps int64

	for _, b := range bets {
		if b.CashedOutAt == 0 {
			continue // rode it into the crash; the stake already sits with the house
		}
		gross := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
		split := r.Fees.Apply(gross)
		b.PayoutMsat = split.NetMsat

		if gross > 0 {
			payouts = append(payouts, ledger.Posting{AccountID: b.AccountID, AmountMsat: gross})
			housePays += gross
		}
		if split.HouseMsat() > 0 {
			feeLines = append(feeLines, ledger.Posting{
				AccountID: b.AccountID, AmountMsat: -split.HouseMsat()})
			houseKeeps += split.HouseMsat()
		}

		if _, err := r.pool.Exec(ctx,
			`UPDATE bets SET payout_msat = $2, rake_msat = $4, rounding_msat = $5,
			                 settled_at = now()
			  WHERE round_id = $1 AND account_id = $3`,
			roundID, split.NetMsat, b.AccountID,
			split.RakeMsat, split.RoundingMsat); err != nil {
			return fmt.Errorf("recording payout: %w", err)
		}
	}

	rid := roundID

	// Pay the full winnings first, then take the fee as its own transaction.
	// Netting them into one posting would be arithmetically identical but
	// would hide the deduction: the player would see a smaller win rather
	// than a win and a charge.
	if housePays > 0 {
		payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays})
		if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil {
			return fmt.Errorf("settling round %d: %w", roundID, err)
		}
	}
	if houseKeeps > 0 {
		feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps})
		if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil {
			return fmt.Errorf("collecting fees for round %d: %w", roundID, err)
		}
	}

	// pgx encodes byte slices, not fixed-size arrays, so the seed is sliced.
	seedBytes := seed.Bytes()
	if _, err := r.pool.Exec(ctx,
		`UPDATE rounds SET settled_at = now(), server_seed = $2 WHERE id = $1`,
		roundID, seedBytes[:]); err != nil {
		return fmt.Errorf("revealing seed: %w", err)
	}

	_ = crash
	r.broadcast()
	return nil
}

// triggerAutoCashOutsLocked closes positions whose target the multiplier has
// reached. Callers must hold the lock.
//
// A target above the crash point never fires: the round is already over at
// that value. A target at or below it always fires, at exactly the target.
func (r *Room) triggerAutoCashOutsLocked(reached fixed.F) {
	for _, b := range r.bets {
		if b.CashedOutAt != 0 || b.AutoCashOutAt == 0 {
			continue
		}
		if b.AutoCashOutAt > r.crashPoint {
			continue // the round ends before this target is reached
		}
		if reached >= b.AutoCashOutAt {
			b.CashedOutAt = b.AutoCashOutAt
		}
	}
}

// PlaceBet takes a stake during the betting window. The stake moves to the
// house immediately, so a player can never bet money they do not have.
func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64, autoCashOutAt fixed.F) error {
	if stakeMsat <= 0 {
		return ledger.ErrNonPositiveAmount
	}
	// A target at or below 1.0 would close instantly for no gain.
	if autoCashOutAt != 0 && autoCashOutAt <= fixed.One {
		return fmt.Errorf("auto cash-out target must be above 1.00")
	}

	r.mu.Lock()
	if r.state != StateBetting {
		r.mu.Unlock()
		return fmt.Errorf("betting is closed")
	}
	if _, exists := r.bets[accountID]; exists {
		r.mu.Unlock()
		return fmt.Errorf("already in this round")
	}
	roundID := r.roundID
	r.mu.Unlock()

	house, err := r.ledger.AccountByName(ctx, "house_pot")
	if err != nil {
		return err
	}
	rid := roundID
	if _, err := r.ledger.Post(ctx, "bet", &rid, []ledger.Posting{
		{AccountID: accountID, AmountMsat: -stakeMsat},
		{AccountID: house, AmountMsat: stakeMsat},
	}); err != nil {
		return err
	}

	if _, err := r.pool.Exec(ctx,
		`INSERT INTO bets (round_id, account_id, stake_msat) VALUES ($1, $2, $3)`,
		roundID, accountID, stakeMsat); err != nil {
		return err
	}

	r.mu.Lock()
	// Re-check state: the window may have closed while we were in the database.
	if r.state != StateBetting || r.roundID != roundID {
		r.mu.Unlock()
		return fmt.Errorf("betting closed while placing bet")
	}
	r.bets[accountID] = &Bet{
		AccountID: accountID, Pubkey: pubkey,
		Nickname: nickname, StakeMsat: stakeMsat,
		AutoCashOutAt: autoCashOutAt,
	}
	r.order = append(r.order, pubkey)
	r.mu.Unlock()

	r.broadcast()
	return nil
}

// CashOut locks in the current multiplier. It is rejected once the round has
// passed the crash point, which the tick loop enforces by settling first.
func (r *Room) CashOut(accountID int64) (fixed.F, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.state != StateRunning {
		return 0, fmt.Errorf("round is not running")
	}
	b, ok := r.bets[accountID]
	if !ok {
		return 0, fmt.Errorf("no bet in this round")
	}
	if b.CashedOutAt != 0 {
		return 0, fmt.Errorf("already cashed out")
	}
	at := sim.MultiplierAt(r.tick)
	if at >= r.crashPoint {
		return 0, fmt.Errorf("too late")
	}
	b.CashedOutAt = at

	go func() {
		if _, err := r.pool.Exec(context.Background(),
			`UPDATE bets SET cashout_at = $3 WHERE round_id = $1 AND account_id = $2`,
			r.roundID, accountID, int64(at)); err != nil {
			fmt.Printf("room %s: recording cashout: %v\n", r.Game, err)
		}
	}()

	return at, nil
}

// Snapshot renders the current state for clients.
func (r *Room) Snapshot() Snapshot {
	r.mu.RLock()
	defer r.mu.RUnlock()

	// Aggregate over every player, but only serialise the largest few.
	var pot int64
	cashed := 0
	all := make([]*Bet, 0, len(r.bets))
	for _, b := range r.bets {
		pot += b.StakeMsat
		if b.CashedOutAt != 0 {
			cashed++
		}
		all = append(all, b)
	}
	// Partial ordering is enough: the list is a leaderboard, not a ledger.
	sort.Slice(all, func(i, j int) bool { return all[i].StakeMsat > all[j].StakeMsat })
	if len(all) > MaxListedPlayers {
		all = all[:MaxListedPlayers]
	}

	players := make([]Player, 0, len(all))
	for _, b := range all {
		p := Player{
			Nickname:   b.Nickname,
			StakeMsat:  b.StakeMsat,
			PayoutMsat: b.PayoutMsat,
		}
		if b.CashedOutAt != 0 {
			p.CashedOut = b.CashedOutAt.String()
			p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt
		}
		players = append(players, p)
	}

	s := Snapshot{
		RoundID:     r.roundID,
		Game:        r.Game,
		State:       r.state,
		Tick:        r.tick,
		Multiplier:  sim.MultiplierAt(r.tick).String(),
		Commitment:  hex.EncodeToString(r.commitment[:]),
		Players:     players,
		PlayerCount: len(r.bets),
		PotMsat:     pot,
		CashedOut:   cashed,
		NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
	}
	if r.state == StateRunning {
		s.StartedUnixMilli = r.runStarted.UnixMilli()
	}
	// The seed is revealed only once the round is over.
	if r.state == StateSettled && r.crashPoint != 0 {
		s.ServerSeed = r.serverSeed.Hex()
		s.CrashPoint = r.crashPoint.String()
	}
	return s
}
