// Package tournament runs scheduled competitive events.
//
// A tournament collects entry fees into a prize pool and pays them out to the
// best performers over a window of ordinary rounds. Players keep playing the
// same games; the tournament simply scores what they do.
//
// The prize pool is a real ledger account rather than a number in a row. Entry
// fees move into it and prizes move out of it, so tournament money obeys the
// same double-entry invariants as everything else: it cannot be created,
// cannot be lost, and every movement is explained by a posting.
//
// Two properties the tests pin down, because they are where this kind of code
// usually goes wrong:
//
//   - Every millisatoshi collected is paid out. Integer division of a pool
//     across percentage shares leaves a remainder, and a remainder that is
//     silently dropped is money that vanishes.
//   - A tournament settles exactly once, even if two instances try at the
//     same moment.
package tournament

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/drjones/quantum-arcade/pkg/ledger"
	"github.com/jackc/pgx/v5/pgxpool"
)

var (
	ErrNotRegistering = errors.New("tournament: registration is not open")
	ErrAlreadyEntered = errors.New("tournament: already entered")
	ErrFull           = errors.New("tournament: entrant limit reached")
	ErrNotFinished    = errors.New("tournament: has not finished yet")
	ErrAlreadySettled = errors.New("tournament: already settled")
	ErrBadPayoutSplit = errors.New("tournament: payout shares must sum to 10000 basis points")
)

type Status string

const (
	StatusScheduled   Status = "scheduled"
	StatusRegistering Status = "registering"
	StatusRunning     Status = "running"
	StatusSettled     Status = "settled"
	StatusCancelled   Status = "cancelled"
)

// Tournament is a scheduled event.
type Tournament struct {
	ID            int64     `json:"id"`
	Name          string    `json:"name"`
	Game          string    `json:"game"`
	Status        Status    `json:"status"`
	EntryFeeMsat  int64     `json:"entry_fee_msat"`
	PoolAccountID int64     `json:"-"`
	PayoutBP      []int32   `json:"payout_bp"`
	MaxEntrants   *int32    `json:"max_entrants"`
	RegistersAt   time.Time `json:"registers_at"`
	StartsAt      time.Time `json:"starts_at"`
	EndsAt        time.Time `json:"ends_at"`
	PoolMsat      int64     `json:"pool_msat"`
	Entrants      int       `json:"entrants"`
}

// Standing is one player's place on the board.
type Standing struct {
	Position     int    `json:"position"`
	AccountID    int64  `json:"account_id"`
	Nickname     string `json:"nickname"`
	ScoreMsat    int64  `json:"score_msat"`
	RoundsPlayed int    `json:"rounds_played"`
	PrizeMsat    int64  `json:"prize_msat"`
}

type Service struct {
	pool   *pgxpool.Pool
	ledger *ledger.Ledger
}

func New(pool *pgxpool.Pool, l *ledger.Ledger) *Service {
	return &Service{pool: pool, ledger: l}
}

// Create schedules a tournament and opens its prize pool account.
func (s *Service) Create(ctx context.Context, name, game string,
	entryFeeMsat int64, payoutBP []int32, maxEntrants *int32,
	registersAt, startsAt, endsAt time.Time) (*Tournament, error) {

	var total int32
	for _, bp := range payoutBP {
		if bp <= 0 {
			return nil, fmt.Errorf("%w: share %d is not positive", ErrBadPayoutSplit, bp)
		}
		total += bp
	}
	if total != 10000 {
		return nil, fmt.Errorf("%w: shares sum to %d", ErrBadPayoutSplit, total)
	}

	// The pool is a named ledger account, so it appears in the books and in
	// any audit alongside every other account.
	poolName := fmt.Sprintf("tournament_pool_%d_%s", time.Now().UnixNano(), game)
	var poolID int64
	if err := s.pool.QueryRow(ctx,
		`INSERT INTO accounts (kind, name) VALUES ('house', $1) RETURNING id`,
		poolName).Scan(&poolID); err != nil {
		return nil, fmt.Errorf("creating prize pool account: %w", err)
	}

	t := &Tournament{
		Name: name, Game: game, Status: StatusScheduled,
		EntryFeeMsat: entryFeeMsat, PoolAccountID: poolID, PayoutBP: payoutBP,
		MaxEntrants: maxEntrants,
		RegistersAt: registersAt, StartsAt: startsAt, EndsAt: endsAt,
	}
	if err := s.pool.QueryRow(ctx,
		`INSERT INTO tournaments
		   (name, game, entry_fee_msat, pool_account_id, payout_bp,
		    max_entrants, registers_at, starts_at, ends_at)
		 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
		name, game, entryFeeMsat, poolID, payoutBP, maxEntrants,
		registersAt, startsAt, endsAt).Scan(&t.ID); err != nil {
		return nil, fmt.Errorf("creating tournament: %w", err)
	}
	return t, nil
}

// Enter registers a player, moving their entry fee into the prize pool.
func (s *Service) Enter(ctx context.Context, tournamentID, accountID int64) error {
	t, err := s.Get(ctx, tournamentID)
	if err != nil {
		return err
	}
	if t.Status != StatusRegistering {
		return fmt.Errorf("%w: status is %s", ErrNotRegistering, t.Status)
	}
	if t.MaxEntrants != nil && t.Entrants >= int(*t.MaxEntrants) {
		return ErrFull
	}

	// Claim the seat before taking the money. The unique constraint makes a
	// double entry impossible, and failing here means nothing was charged.
	if _, err := s.pool.Exec(ctx,
		`INSERT INTO tournament_entries (tournament_id, account_id) VALUES ($1, $2)`,
		tournamentID, accountID); err != nil {
		return ErrAlreadyEntered
	}

	if t.EntryFeeMsat > 0 {
		if _, err := s.ledger.Post(ctx, "tournament_entry", nil, []ledger.Posting{
			{AccountID: accountID, AmountMsat: -t.EntryFeeMsat},
			{AccountID: t.PoolAccountID, AmountMsat: t.EntryFeeMsat},
		}); err != nil {
			// Could not pay: release the seat so the player can retry once
			// funded, rather than holding a place they never paid for.
			if _, derr := s.pool.Exec(ctx,
				`DELETE FROM tournament_entries
				  WHERE tournament_id = $1 AND account_id = $2`,
				tournamentID, accountID); derr != nil {
				fmt.Printf("tournament: could not release unpaid seat: %v\n", derr)
			}
			return err
		}
	}
	return nil
}

// RecordResult adds a round's net result to a player's tournament score.
//
// Called by settlement for every entrant playing the tournament's game inside
// its window. A losing round lowers the score; the board is net profit, so
// grinding many small wins and taking one large loss is not a way to climb.
func (s *Service) RecordResult(ctx context.Context, tournamentID, accountID, netMsat int64) error {
	_, err := s.pool.Exec(ctx,
		`UPDATE tournament_entries
		    SET score_msat = score_msat + $3,
		        rounds_played = rounds_played + 1
		  WHERE tournament_id = $1 AND account_id = $2`,
		tournamentID, accountID, netMsat)
	return err
}

// Leaderboard returns the current standings, best first.
func (s *Service) Leaderboard(ctx context.Context, tournamentID int64, limit int) ([]Standing, error) {
	rows, err := s.pool.Query(ctx,
		`SELECT e.account_id, COALESCE(a.nickname, ''), e.score_msat,
		        e.rounds_played, e.prize_msat
		   FROM tournament_entries e
		   JOIN accounts a ON a.id = e.account_id
		  WHERE e.tournament_id = $1
		  ORDER BY e.score_msat DESC, e.rounds_played ASC, e.id ASC
		  LIMIT $2`, tournamentID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var out []Standing
	pos := 0
	for rows.Next() {
		pos++
		st := Standing{Position: pos}
		if err := rows.Scan(&st.AccountID, &st.Nickname, &st.ScoreMsat,
			&st.RoundsPlayed, &st.PrizeMsat); err != nil {
			return nil, err
		}
		out = append(out, st)
	}
	return out, rows.Err()
}

// Settle pays the prize pool out to the leaders and closes the tournament.
//
// The entire pool is distributed. Integer division of a pool across percentage
// shares leaves a remainder, and dropping it would quietly destroy money and
// break the ledger's conservation check, so the remainder goes to first place.
func (s *Service) Settle(ctx context.Context, tournamentID int64) ([]Standing, error) {
	// Claim the tournament first: an UPDATE that only matches an unsettled row
	// means two instances cannot both pay out.
	tag, err := s.pool.Exec(ctx,
		`UPDATE tournaments SET status = 'settled', settled_at = now()
		  WHERE id = $1 AND status IN ('running', 'registering')
		    AND ends_at <= now()`, tournamentID)
	if err != nil {
		return nil, err
	}
	if tag.RowsAffected() == 0 {
		var status Status
		var endsAt time.Time
		if err := s.pool.QueryRow(ctx,
			`SELECT status, ends_at FROM tournaments WHERE id = $1`,
			tournamentID).Scan(&status, &endsAt); err != nil {
			return nil, err
		}
		if status == StatusSettled {
			return nil, ErrAlreadySettled
		}
		return nil, fmt.Errorf("%w: ends at %s", ErrNotFinished, endsAt.Format(time.RFC3339))
	}

	t, err := s.Get(ctx, tournamentID)
	if err != nil {
		return nil, err
	}
	poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
	if err != nil {
		return nil, err
	}

	board, err := s.Leaderboard(ctx, tournamentID, len(t.PayoutBP))
	if err != nil {
		return nil, err
	}
	if poolMsat == 0 || len(board) == 0 {
		return board, nil
	}

	// Compute each share, then hand the rounding remainder to first place so
	// the pool empties exactly.
	postings := make([]ledger.Posting, 0, len(board)+1)
	var distributed int64
	prizes := make([]int64, len(board))
	for i := range board {
		if i >= len(t.PayoutBP) {
			break
		}
		prize := poolMsat * int64(t.PayoutBP[i]) / 10000
		prizes[i] = prize
		distributed += prize
	}
	if remainder := poolMsat - distributed; remainder > 0 {
		prizes[0] += remainder
		distributed = poolMsat
	}

	for i, st := range board {
		if prizes[i] <= 0 {
			continue
		}
		board[i].PrizeMsat = prizes[i]
		postings = append(postings, ledger.Posting{
			AccountID: st.AccountID, AmountMsat: prizes[i]})
		if _, err := s.pool.Exec(ctx,
			`UPDATE tournament_entries SET prize_msat = $3
			  WHERE tournament_id = $1 AND account_id = $2`,
			tournamentID, st.AccountID, prizes[i]); err != nil {
			return nil, fmt.Errorf("recording prize: %w", err)
		}
	}

	if distributed > 0 {
		postings = append(postings, ledger.Posting{
			AccountID: t.PoolAccountID, AmountMsat: -distributed})
		if _, err := s.ledger.Post(ctx, "tournament_prize", nil, postings); err != nil {
			return nil, fmt.Errorf("paying prizes: %w", err)
		}
	}

	// The pool must be empty. Anything left would be money stranded in an
	// account nobody can reach.
	left, err := s.ledger.Balance(ctx, t.PoolAccountID)
	if err != nil {
		return nil, err
	}
	if left != 0 {
		return nil, fmt.Errorf("tournament %d settled with %d msat stranded in its pool",
			tournamentID, left)
	}
	return board, nil
}

// Cancel refunds every entry fee and closes the tournament.
func (s *Service) Cancel(ctx context.Context, tournamentID int64) error {
	tag, err := s.pool.Exec(ctx,
		`UPDATE tournaments SET status = 'cancelled', settled_at = now()
		  WHERE id = $1 AND status NOT IN ('settled', 'cancelled')`, tournamentID)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return ErrAlreadySettled
	}

	t, err := s.Get(ctx, tournamentID)
	if err != nil {
		return err
	}
	poolMsat, err := s.ledger.Balance(ctx, t.PoolAccountID)
	if err != nil {
		return err
	}
	if poolMsat == 0 {
		return nil
	}

	rows, err := s.pool.Query(ctx,
		`SELECT account_id FROM tournament_entries WHERE tournament_id = $1`,
		tournamentID)
	if err != nil {
		return err
	}
	var entrants []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return err
		}
		entrants = append(entrants, id)
	}
	rows.Close()
	if len(entrants) == 0 {
		return nil
	}

	// Refund the fee each player paid. The pool holds exactly the sum of
	// those fees, so refunding the entry fee to each empties it precisely.
	postings := make([]ledger.Posting, 0, len(entrants)+1)
	var total int64
	for _, id := range entrants {
		postings = append(postings, ledger.Posting{AccountID: id, AmountMsat: t.EntryFeeMsat})
		total += t.EntryFeeMsat
	}
	if total != poolMsat {
		return fmt.Errorf("tournament %d: pool holds %d but refunds total %d",
			tournamentID, poolMsat, total)
	}
	postings = append(postings, ledger.Posting{AccountID: t.PoolAccountID, AmountMsat: -total})

	_, err = s.ledger.Post(ctx, "tournament_refund", nil, postings)
	return err
}

// Get loads a tournament with its live pool balance and entrant count.
func (s *Service) Get(ctx context.Context, id int64) (*Tournament, error) {
	var t Tournament
	if err := s.pool.QueryRow(ctx,
		`SELECT id, name, game, status, entry_fee_msat, pool_account_id,
		        payout_bp, max_entrants, registers_at, starts_at, ends_at
		   FROM tournaments WHERE id = $1`, id).
		Scan(&t.ID, &t.Name, &t.Game, &t.Status, &t.EntryFeeMsat, &t.PoolAccountID,
			&t.PayoutBP, &t.MaxEntrants, &t.RegistersAt, &t.StartsAt, &t.EndsAt); err != nil {
		return nil, fmt.Errorf("tournament %d not found: %w", id, err)
	}
	t.PoolMsat, _ = s.ledger.Balance(ctx, t.PoolAccountID)
	_ = s.pool.QueryRow(ctx,
		`SELECT count(*) FROM tournament_entries WHERE tournament_id = $1`,
		id).Scan(&t.Entrants)
	return &t, nil
}

// AdvanceSchedules moves tournaments through their lifecycle by wall clock.
// Any instance may run it; the updates are idempotent.
func (s *Service) AdvanceSchedules(ctx context.Context) error {
	if _, err := s.pool.Exec(ctx,
		`UPDATE tournaments SET status = 'registering'
		  WHERE status = 'scheduled' AND registers_at <= now()`); err != nil {
		return err
	}
	if _, err := s.pool.Exec(ctx,
		`UPDATE tournaments SET status = 'running'
		  WHERE status = 'registering' AND starts_at <= now()`); err != nil {
		return err
	}
	return nil
}

// Active lists tournaments a player can currently see or join.
func (s *Service) Active(ctx context.Context) ([]Tournament, error) {
	rows, err := s.pool.Query(ctx,
		`SELECT id FROM tournaments
		  WHERE status IN ('scheduled', 'registering', 'running')
		  ORDER BY starts_at`)
	if err != nil {
		return nil, err
	}
	var ids []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return nil, err
		}
		ids = append(ids, id)
	}
	rows.Close()

	out := make([]Tournament, 0, len(ids))
	for _, id := range ids {
		t, err := s.Get(ctx, id)
		if err != nil {
			continue
		}
		out = append(out, *t)
	}
	return out, nil
}
