package ledger

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

// Batcher raises write throughput by grouping transactions.
//
// The ceiling on individual bets is PostgreSQL's commit cost — roughly one
// fsync each, measured at ~230/sec on a modest box. Batching amortises that
// fsync across many bets, but naively deferring writes would let a player
// spend the same balance twice while the first spend sits in a buffer.
//
// So a bet takes two steps:
//
//  1. Reserve, synchronously and in memory. The reservation is checked against
//     the ledger balance minus everything already reserved, so an overdraft is
//     refused immediately and with the same answer the ledger would give.
//  2. Flush, in the background. Reservations are written as one transaction.
//
// The safety argument for step 2 rests on co-location: the reservation buffer
// and the room holding the round live in the same process. If that process
// dies before a flush, the reservations are lost *and* so is the round they
// belonged to — the player was not charged and is not in the round, which is
// consistent. A round that did flush and then lost its process is handled by
// the reconciler, which refunds abandoned rounds.
//
// A round must therefore never settle before its bets have flushed.
// Room.settle enforces that by calling Flush first.
type Batcher struct {
	ledger *Ledger

	// maxDelay and maxBatch are read by the flush loop while callers may be
	// tuning them, so they are atomic rather than plain fields. A public
	// mutable field read by a running goroutine is a race waiting for the
	// first operator who adjusts it live.
	maxDelay atomic.Int64 // nanoseconds
	maxBatch atomic.Int64

	mu       sync.Mutex
	pending  []pendingTx
	reserved map[int64]int64 // account -> millisatoshis reserved but unwritten
	waiters  []chan error

	flushing sync.Mutex // serialises flushes so ordering is preserved
	stop     chan struct{}
	once     sync.Once

	// negativeOK caches whether an account may go negative. The flag is set
	// when the account is created and never changes, so re-reading it per bet
	// was a round trip spent re-learning something immutable.
	negMu      sync.RWMutex
	negativeOK map[int64]bool
}

type pendingTx struct {
	kind     string
	roundID  *int64
	postings []Posting
}

var ErrBatcherClosed = errors.New("ledger: batcher is closed")

func NewBatcher(l *Ledger) *Batcher {
	b := &Batcher{
		ledger:     l,
		reserved:   make(map[int64]int64),
		negativeOK: make(map[int64]bool),
		stop:       make(chan struct{}),
	}
	b.SetMaxDelay(200 * time.Millisecond)
	b.SetMaxBatch(256)
	return b
}

// SetMaxDelay sets how long a reservation may wait before being written. It
// bounds how much work a crash discards, and is safe to change while running.
func (b *Batcher) SetMaxDelay(d time.Duration) {
	if d < time.Millisecond {
		d = time.Millisecond
	}
	b.maxDelay.Store(int64(d))
}

// MaxDelay reports the current flush interval.
func (b *Batcher) MaxDelay() time.Duration {
	return time.Duration(b.maxDelay.Load())
}

// SetMaxBatch sets how many transactions may queue before an early flush, so a
// burst does not build an unboundedly large database transaction.
func (b *Batcher) SetMaxBatch(n int) {
	if n < 1 {
		n = 1
	}
	b.maxBatch.Store(int64(n))
}

// Run flushes on a timer until the context ends.
func (b *Batcher) Run(ctx context.Context) {
	interval := b.MaxDelay()
	t := time.NewTicker(interval)
	defer t.Stop()
	for {
		// Pick up a changed interval without restarting the loop.
		if d := b.MaxDelay(); d != interval {
			interval = d
			t.Reset(interval)
		}
		select {
		case <-ctx.Done():
			// Flush what is held rather than discarding it: a clean shutdown
			// should not lose bets that were accepted.
			_ = b.Flush(context.WithoutCancel(ctx))
			return
		case <-b.stop:
			_ = b.Flush(context.WithoutCancel(ctx))
			return
		case <-t.C:
			if err := b.Flush(ctx); err != nil {
				fmt.Printf("ledger: batch flush failed: %v\n", err)
			}
		}
	}
}

// Close stops the batcher after a final flush.
func (b *Batcher) Close() {
	b.once.Do(func() { close(b.stop) })
}

// AvailableBalance is what an account can actually spend: its ledger balance
// less anything reserved but not yet written.
func (b *Batcher) AvailableBalance(ctx context.Context, accountID int64) (int64, error) {
	settled, err := b.ledger.Balance(ctx, accountID)
	if err != nil {
		return 0, err
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	return settled + b.reserved[accountID], nil
}

// mayGoNegative reports whether an account is permitted a negative balance,
// caching the answer. The flag is immutable once an account exists.
func (b *Batcher) mayGoNegative(ctx context.Context, accountID int64) (bool, error) {
	b.negMu.RLock()
	v, ok := b.negativeOK[accountID]
	b.negMu.RUnlock()
	if ok {
		return v, nil
	}

	var allow bool
	if err := b.ledger.pool.QueryRow(ctx,
		`SELECT allow_negative FROM accounts WHERE id = $1`, accountID).Scan(&allow); err != nil {
		return false, fmt.Errorf("checking account %d: %w", accountID, err)
	}

	b.negMu.Lock()
	b.negativeOK[accountID] = allow
	b.negMu.Unlock()
	return allow, nil
}

// Post reserves a transaction and returns once it is durably written.
//
// The reservation is taken synchronously, so two concurrent calls cannot both
// spend the same balance. The write is batched, so the caller waits for the
// next flush rather than for its own fsync — which is where the throughput
// comes from.
func (b *Batcher) Post(ctx context.Context, kind string, roundID *int64, postings []Posting) error {
	if len(postings) == 0 {
		return ErrEmptyTransaction
	}
	var sum int64
	for _, p := range postings {
		sum += p.AmountMsat
	}
	if sum != 0 {
		return fmt.Errorf("%w: sum is %d", ErrUnbalanced, sum)
	}

	// Check every debit against the balance that will actually be available,
	// which is the settled balance plus reservations already taken.
	for _, p := range postings {
		if p.AmountMsat >= 0 {
			continue
		}
		available, err := b.AvailableBalance(ctx, p.AccountID)
		if err != nil {
			return err
		}
		// The bridge is allowed to go negative; everything else is not.
		allowNegative, err := b.mayGoNegative(ctx, p.AccountID)
		if err != nil {
			return err
		}
		if !allowNegative && available+p.AmountMsat < 0 {
			return fmt.Errorf("%w: account %d has %d available, needs %d",
				ErrInsufficientFunds, p.AccountID, available, -p.AmountMsat)
		}
	}

	done := make(chan error, 1)

	b.mu.Lock()
	select {
	case <-b.stop:
		b.mu.Unlock()
		return ErrBatcherClosed
	default:
	}
	for _, p := range postings {
		b.reserved[p.AccountID] += p.AmountMsat
	}
	b.pending = append(b.pending, pendingTx{kind: kind, roundID: roundID, postings: postings})
	b.waiters = append(b.waiters, done)
	full := int64(len(b.pending)) >= b.maxBatch.Load()
	b.mu.Unlock()

	if full {
		go func() {
			if err := b.Flush(context.WithoutCancel(ctx)); err != nil {
				fmt.Printf("ledger: batch flush failed: %v\n", err)
			}
		}()
	}

	select {
	case err := <-done:
		return err
	case <-ctx.Done():
		return ctx.Err()
	}
}

// Flush writes every pending transaction.
//
// Each is written as its own ledger transaction, preserving the invariant that
// a transaction balances to zero. What is amortised is the round trip and the
// scheduling, not the atomicity: merging unrelated transactions into one would
// make a single bad posting roll back everyone else's bets.
func (b *Batcher) Flush(ctx context.Context) error {
	b.flushing.Lock()
	defer b.flushing.Unlock()

	b.mu.Lock()
	if len(b.pending) == 0 {
		b.mu.Unlock()
		return nil
	}
	batch := b.pending
	waiters := b.waiters
	b.pending = nil
	b.waiters = nil
	b.mu.Unlock()

	release := func(tx pendingTx) {
		// Release the reservation whether or not the write succeeded: it has
		// either become a real posting or it never will, and in both cases
		// holding it would understate what the account can spend.
		b.mu.Lock()
		for _, p := range tx.postings {
			b.reserved[p.AccountID] -= p.AmountMsat
			if b.reserved[p.AccountID] == 0 {
				delete(b.reserved, p.AccountID)
			}
		}
		b.mu.Unlock()
	}

	groups := make([]Group, len(batch))
	for i, tx := range batch {
		groups[i] = Group{Kind: tx.kind, RoundID: tx.roundID, Postings: tx.postings}
	}

	// The fast path: one database transaction for the whole batch, so the
	// commit cost is paid once instead of once per bet.
	if _, err := b.ledger.PostMany(ctx, groups); err == nil {
		for i, tx := range batch {
			release(tx)
			waiters[i] <- nil
		}
		return nil
	}

	// Something in the batch was rejected. Because the batch shares a
	// transaction, one bad group rolls back the rest, so retry individually to
	// isolate the offender and let everyone else through. This is rare:
	// balances are checked before a group is ever queued.
	var firstErr error
	for i, tx := range batch {
		_, err := b.ledger.Post(ctx, tx.kind, tx.roundID, tx.postings)
		release(tx)
		waiters[i] <- err
		if err != nil && firstErr == nil {
			firstErr = err
		}
	}
	return firstErr
}

// Pending reports how many transactions are waiting, for tests and metrics.
func (b *Batcher) Pending() int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return len(b.pending)
}
