package room

import (
	"context"
	"fmt"
	"time"

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

// Reconciler refunds rounds that were abandoned mid-flight.
//
// Stakes are debited when a bet is placed, so if the instance driving a game
// dies between the bet and settlement, those stakes sit with the house and the
// round never resolves. The books stay balanced — nothing is created or lost —
// but the players are quietly short, which is the same thing as being robbed
// by accident.
//
// This finds those rounds and refunds every unsettled stake. It is safe to run
// repeatedly and from any instance: refunds are recorded against the round, and
// a round already marked settled is skipped.
type Reconciler struct {
	pool   *pgxpool.Pool
	ledger *ledger.Ledger

	// Stale is how long a round may remain unsettled before it is considered
	// abandoned. It must exceed the longest possible round plus the time it
	// takes another instance to take over, or a live round would be refunded
	// out from under the players still in it.
	Stale time.Duration
}

func NewReconciler(pool *pgxpool.Pool, l *ledger.Ledger) *Reconciler {
	return &Reconciler{
		pool:   pool,
		ledger: l,
		// A round is capped at 60s of flight plus its betting and settle
		// phases; leadership moves within LeaseTTL. Two minutes is far past
		// any legitimate round and still prompt enough to matter at a party.
		Stale: 2 * time.Minute,
	}
}

// Result describes what a reconciliation pass did.
type Result struct {
	RoundsRefunded int
	BetsRefunded   int
	MsatRefunded   int64
	// EmptyRoundsClosed counts abandoned rounds that nobody had joined.
	EmptyRoundsClosed int
}

// Run refunds every abandoned round it finds.
func (rc *Reconciler) Run(ctx context.Context) (Result, error) {
	var res Result

	rows, err := rc.pool.Query(ctx, `
		SELECT DISTINCT r.id
		  FROM rounds r
		  JOIN bets b ON b.round_id = r.id
		 WHERE r.settled_at IS NULL
		   AND r.voided_at IS NULL
		   AND b.settled_at IS NULL
		   AND r.opened_at < now() - make_interval(secs => $1)
		 ORDER BY r.id`,
		rc.Stale.Seconds())
	if err != nil {
		return res, fmt.Errorf("finding abandoned rounds: %w", err)
	}

	var roundIDs []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return res, err
		}
		roundIDs = append(roundIDs, id)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return res, err
	}

	// Rounds that nobody joined also need closing. They hold no money, so
	// there is nothing to refund, but leaving them open forever makes the
	// operator's "unresolved rounds" signal useless — it climbs steadily with
	// noise and stops meaning anything when a real one appears.
	empty, err := rc.pool.Exec(ctx, `
		UPDATE rounds SET voided_at = now()
		 WHERE settled_at IS NULL
		   AND voided_at IS NULL
		   AND opened_at < now() - make_interval(secs => $1)
		   AND NOT EXISTS (SELECT 1 FROM bets WHERE bets.round_id = rounds.id)`,
		rc.Stale.Seconds())
	if err != nil {
		return res, fmt.Errorf("closing empty rounds: %w", err)
	}
	res.EmptyRoundsClosed = int(empty.RowsAffected())

	for _, roundID := range roundIDs {
		refunded, msat, err := rc.refundRound(ctx, roundID)
		if err != nil {
			// One bad round must not stop the rest from being made whole.
			fmt.Printf("reconcile: round %d: %v\n", roundID, err)
			continue
		}
		if refunded > 0 {
			res.RoundsRefunded++
			res.BetsRefunded += refunded
			res.MsatRefunded += msat
		}
	}
	return res, nil
}

// refundRound returns every unsettled stake in one round.
func (rc *Reconciler) refundRound(ctx context.Context, roundID int64) (int, int64, error) {
	// Claim the round by voiding it. Doing this before moving money means a
	// second pass — or another instance running concurrently — finds nothing
	// to do, so a refund cannot be issued twice.
	//
	// Void, not settled: an abandoned round produced no outcome, so it has no
	// seed to reveal and must not masquerade as a resolved round.
	tag, err := rc.pool.Exec(ctx,
		`UPDATE rounds SET voided_at = now()
		  WHERE id = $1 AND settled_at IS NULL AND voided_at IS NULL`, roundID)
	if err != nil {
		return 0, 0, fmt.Errorf("claiming round: %w", err)
	}
	if tag.RowsAffected() == 0 {
		return 0, 0, nil // another pass got there first
	}

	rows, err := rc.pool.Query(ctx,
		`SELECT account_id, stake_msat FROM bets
		  WHERE round_id = $1 AND settled_at IS NULL`, roundID)
	if err != nil {
		return 0, 0, err
	}
	type refund struct {
		account int64
		msat    int64
	}
	var refunds []refund
	for rows.Next() {
		var r refund
		if err := rows.Scan(&r.account, &r.msat); err != nil {
			rows.Close()
			return 0, 0, err
		}
		refunds = append(refunds, r)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return 0, 0, err
	}
	if len(refunds) == 0 {
		return 0, 0, nil
	}

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

	postings := make([]ledger.Posting, 0, len(refunds)+1)
	var total int64
	for _, r := range refunds {
		postings = append(postings, ledger.Posting{AccountID: r.account, AmountMsat: r.msat})
		total += r.msat
	}
	postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -total})

	rid := roundID
	if _, err := rc.ledger.Post(ctx, "refund_abandoned", &rid, postings); err != nil {
		return 0, 0, fmt.Errorf("posting refunds: %w", err)
	}

	if _, err := rc.pool.Exec(ctx,
		`UPDATE bets SET settled_at = now(), payout_msat = stake_msat
		  WHERE round_id = $1 AND settled_at IS NULL`, roundID); err != nil {
		return 0, 0, fmt.Errorf("marking bets refunded: %w", err)
	}

	return len(refunds), total, nil
}

// RunPeriodically sweeps for abandoned rounds until the context ends.
func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			res, err := rc.Run(ctx)
			if err != nil {
				fmt.Printf("reconcile: %v\n", err)
				continue
			}
			if res.RoundsRefunded > 0 || res.EmptyRoundsClosed > 0 {
				fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds "+
					"(%d msat); closed %d empty rounds\n",
					res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded,
					res.EmptyRoundsClosed)
			}
		}
	}
}
