package lightning_test

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"os"
	"sync"
	"testing"
	"time"

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

var runID = fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int63())

func testPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	dsn := os.Getenv("ARCADE_TEST_DSN")
	if dsn == "" {
		dsn = "postgres://arcade:arcade_dev@localhost:5432/arcade"
	}
	pool, err := pgxpool.New(context.Background(), dsn)
	if err != nil {
		t.Skipf("no database available: %v", err)
	}
	if err := pool.Ping(context.Background()); err != nil {
		t.Skipf("no database available: %v", err)
	}
	return pool
}

type fixture struct {
	t      *testing.T
	svc    *lightning.Service
	node   *lightning.FakeNode
	ledger *ledger.Ledger
	ctx    context.Context
}

func newFixture(t *testing.T) *fixture {
	t.Helper()
	pool := testPool(t)
	l := ledger.New(pool)
	node := lightning.NewFakeNode(1_000_000_000)

	// Withdrawals are processed queue-wide, so leftovers from an earlier run
	// would be picked up here and counted against this test. Park them.
	if _, err := pool.Exec(context.Background(),
		`UPDATE lightning_withdrawals SET status = 'rejected',
		        failure = 'cleared by test fixture', resolved_at = now()
		  WHERE status IN ('queued', 'sending')`); err != nil {
		t.Fatal(err)
	}
	return &fixture{
		t: t, node: node, ledger: l, ctx: context.Background(),
		svc: lightning.New(node, l, pool, lightning.DefaultLimits()),
	}
}

func (f *fixture) player(label string, fundMsat int64) int64 {
	f.t.Helper()
	pk := []byte(fmt.Sprintf("%s-%s-%s", runID, f.t.Name(), label))
	id, err := f.ledger.EnsurePlayer(f.ctx, pk)
	if err != nil {
		f.t.Fatal(err)
	}
	if fundMsat > 0 {
		if _, err := f.ledger.Deposit(f.ctx, id, fundMsat); err != nil {
			f.t.Fatal(err)
		}
	}
	return id
}

/* ---------------- deposits ---------------- */

func TestDepositCreditsOnlyAfterPayment(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 0)

	inv, err := f.svc.RequestDeposit(f.ctx, id, 50_000)
	if err != nil {
		t.Fatal(err)
	}

	// Nobody has paid yet: settling must refuse.
	if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err == nil {
		t.Fatal("an unpaid invoice was credited")
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
		t.Fatalf("balance = %d before payment, want 0", bal)
	}

	f.node.MarkPaid(inv.PaymentHash)
	credited, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
	if err != nil {
		t.Fatal(err)
	}
	if credited != 50_000 {
		t.Fatalf("credited %d, want 50000", credited)
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 50_000 {
		t.Fatalf("balance = %d after payment, want 50000", bal)
	}
}

// A node reporting the same settlement twice must not mint money.
func TestDepositIsIdempotent(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 0)

	inv, _ := f.svc.RequestDeposit(f.ctx, id, 25_000)
	f.node.MarkPaid(inv.PaymentHash)

	if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 5; i++ {
		if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrAlreadyCredited) {
			t.Fatalf("repeat settle %d gave %v, want ErrAlreadyCredited", i, err)
		}
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 25_000 {
		t.Fatalf("balance = %d after repeated settlement, want 25000", bal)
	}
}

// Concurrent settlements of one invoice — a webhook and a poll racing — must
// credit exactly once.
func TestConcurrentSettlementCreditsOnce(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 0)

	inv, _ := f.svc.RequestDeposit(f.ctx, id, 30_000)
	f.node.MarkPaid(inv.PaymentHash)

	var wg sync.WaitGroup
	succeeded := make([]bool, 8)
	for i := 0; i < 8; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			_, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash)
			succeeded[i] = err == nil
		}(i)
	}
	wg.Wait()

	wins := 0
	for _, ok := range succeeded {
		if ok {
			wins++
		}
	}
	if wins != 1 {
		t.Fatalf("%d concurrent settlements succeeded, want 1", wins)
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 30_000 {
		t.Fatalf("balance = %d, want 30000", bal)
	}
}

// A caller cannot conjure a credit by naming an invoice the node knows nothing
// about.
func TestUnknownInvoiceCannotBeCredited(t *testing.T) {
	f := newFixture(t)
	if _, err := f.svc.SettleDeposit(f.ctx, "deadbeef"); err == nil {
		t.Fatal("an unknown invoice was credited")
	}
}

// If the node is unreachable at settle time, the claim must be released so the
// real payment is not stranded forever.
func TestFailedLookupReleasesTheClaim(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 0)

	inv, _ := f.svc.RequestDeposit(f.ctx, id, 10_000)
	f.node.MarkPaid(inv.PaymentHash)

	f.node.FailLookup = true
	if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); !errors.Is(err, lightning.ErrNodeUnavailable) {
		t.Fatalf("got %v, want ErrNodeUnavailable", err)
	}

	// Once the node returns, the deposit must still be creditable.
	f.node.FailLookup = false
	if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
		t.Fatalf("deposit stranded after a transient failure: %v", err)
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
		t.Fatalf("balance = %d, want 10000", bal)
	}
}

func TestDepositLimitsEnforced(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 0)
	limits := lightning.DefaultLimits()

	for _, amt := range []int64{0, limits.MinDepositMsat - 1, limits.MaxDepositMsat + 1} {
		if _, err := f.svc.RequestDeposit(f.ctx, id, amt); !errors.Is(err, lightning.ErrAmountOutOfRange) {
			t.Errorf("amount %d gave %v, want ErrAmountOutOfRange", amt, err)
		}
	}
}

/* ---------------- withdrawals ---------------- */

func TestWithdrawalDebitsImmediately(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 100_000)

	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc-invoice", 40_000); err != nil {
		t.Fatal(err)
	}
	// Debited at request time, not at send time: otherwise the same balance
	// could be withdrawn twice while the first payment is in flight.
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 60_000 {
		t.Fatalf("balance = %d after requesting withdrawal, want 60000", bal)
	}
}

func TestCannotWithdrawMoreThanBalance(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 10_000)

	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000); !errors.Is(err, ledger.ErrInsufficientFunds) {
		t.Fatalf("got %v, want ErrInsufficientFunds", err)
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 10_000 {
		t.Fatalf("balance = %d after a refused withdrawal, want 10000", bal)
	}
}

// Two concurrent withdrawals of the same funds: exactly one may proceed.
func TestConcurrentWithdrawalsCannotDoubleSpend(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 50_000)

	var wg sync.WaitGroup
	results := make([]error, 6)
	for i := 0; i < 6; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			_, results[i] = f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 50_000)
		}(i)
	}
	wg.Wait()

	ok := 0
	for _, err := range results {
		if err == nil {
			ok++
		}
	}
	if ok != 1 {
		t.Fatalf("%d concurrent withdrawals of the same balance succeeded, want 1", ok)
	}
	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
		t.Fatalf("balance = %d, want 0", bal)
	}
}

func TestSuccessfulWithdrawalIsPaidOnce(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 100_000)

	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
		t.Fatal(err)
	}
	before := f.node.PaymentCount()
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}
	if got := f.node.PaymentCount() - before; got != 1 {
		t.Fatalf("node sent %d payments, want 1", got)
	}
	// Processing again must not re-send.
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}
	if got := f.node.PaymentCount() - before; got != 1 {
		t.Fatalf("reprocessing sent the payment again: %d total", got)
	}
}

// A failed payment must return the money.
func TestFailedPaymentRefundsThePlayer(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 100_000)

	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 30_000); err != nil {
		t.Fatal(err)
	}
	afterRequest, _ := f.ledger.Balance(f.ctx, id)

	f.node.FailPay = true
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}

	after, _ := f.ledger.Balance(f.ctx, id)
	if after != afterRequest+30_000 {
		t.Fatalf("balance = %d after a failed payment, want %d (refunded)",
			after, afterRequest+30_000)
	}
}

// Concurrent processors, as two instances would be, must not double-send.
func TestConcurrentProcessorsSendOnce(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 500_000)

	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 20_000); err != nil {
		t.Fatal(err)
	}
	f.node.PayLatency = 150 * time.Millisecond
	before := f.node.PaymentCount()

	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, _ = f.svc.ProcessWithdrawals(f.ctx, 10)
		}()
	}
	wg.Wait()

	if got := f.node.PaymentCount() - before; got != 1 {
		t.Fatalf("concurrent processors sent %d payments, want 1", got)
	}
}

// Large withdrawals wait for a human. This bounds what a stolen token removes.
func TestLargeWithdrawalNeedsApproval(t *testing.T) {
	f := newFixture(t)
	limits := lightning.DefaultLimits()
	id := f.player("a", limits.MaxAutoWithdrawMsat*3)

	amount := limits.MaxAutoWithdrawMsat + 1
	wid, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)
	if !errors.Is(err, lightning.ErrNeedsApproval) {
		t.Fatalf("got %v, want ErrNeedsApproval", err)
	}

	// It must not be paid while it waits.
	before := f.node.PaymentCount()
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}
	if f.node.PaymentCount() != before {
		t.Fatal("a withdrawal awaiting approval was paid")
	}

	if err := f.svc.Approve(f.ctx, wid); err != nil {
		t.Fatal(err)
	}
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}
	if f.node.PaymentCount() != before+1 {
		t.Fatal("an approved withdrawal was not paid")
	}
}

func TestRejectedWithdrawalIsRefunded(t *testing.T) {
	f := newFixture(t)
	limits := lightning.DefaultLimits()
	id := f.player("a", limits.MaxAutoWithdrawMsat*3)

	before, _ := f.ledger.Balance(f.ctx, id)
	amount := limits.MaxAutoWithdrawMsat + 1
	wid, _ := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", amount)

	if err := f.svc.Reject(f.ctx, wid, "suspicious"); err != nil {
		t.Fatal(err)
	}
	after, _ := f.ledger.Balance(f.ctx, id)
	if after != before {
		t.Fatalf("balance = %d after rejection, want %d (fully refunded)", after, before)
	}
	// A rejected withdrawal must never be paid afterwards.
	count := f.node.PaymentCount()
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}
	if f.node.PaymentCount() != count {
		t.Fatal("a rejected withdrawal was paid")
	}
}

// A routing fee above the cap must fail rather than quietly cost the house.
func TestExcessiveFeeIsRefused(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 1_000_000)

	f.node.FeeMsat = 500_000 // far above 1% of the amount
	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 100_000); err != nil {
		t.Fatal(err)
	}
	before, _ := f.ledger.Balance(f.ctx, id)

	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}
	after, _ := f.ledger.Balance(f.ctx, id)
	if after != before+100_000 {
		t.Fatalf("balance = %d, want %d — an over-priced payment should refund",
			after, before+100_000)
	}
}

/* ---------------- solvency ---------------- */

func TestSolvencyDetectsShortfall(t *testing.T) {
	f := newFixture(t)

	owed, err := f.ledger.TotalIssued(f.ctx)
	if err != nil {
		t.Fatal(err)
	}

	f.node.SetBalance(owed + 1_000_000)
	s, err := f.svc.CheckSolvency(f.ctx)
	if err != nil {
		t.Fatal(err)
	}
	if !s.Solvent {
		t.Fatalf("reported insolvent while holding a surplus: %+v", s)
	}

	// Now the node holds less than players are owed.
	f.node.SetBalance(owed - 1)
	s, err = f.svc.CheckSolvency(f.ctx)
	if err != nil {
		t.Fatal(err)
	}
	if s.Solvent {
		t.Fatalf("reported solvent while short: %+v", s)
	}
	if s.SurplusMsat >= 0 {
		t.Fatalf("surplus = %d, want negative", s.SurplusMsat)
	}
}

/* ---------------- round trip ---------------- */

// Money in, play, money out — and the books balance at the end.
func TestFullDepositWithdrawRoundTrip(t *testing.T) {
	f := newFixture(t)
	id := f.player("a", 0)

	inv, err := f.svc.RequestDeposit(f.ctx, id, 80_000)
	if err != nil {
		t.Fatal(err)
	}
	f.node.MarkPaid(inv.PaymentHash)
	if _, err := f.svc.SettleDeposit(f.ctx, inv.PaymentHash); err != nil {
		t.Fatal(err)
	}

	if _, err := f.svc.RequestWithdrawal(f.ctx, id, "lnbc", 80_000); err != nil {
		t.Fatal(err)
	}
	if _, err := f.svc.ProcessWithdrawals(f.ctx, 10); err != nil {
		t.Fatal(err)
	}

	if bal, _ := f.ledger.Balance(f.ctx, id); bal != 0 {
		t.Fatalf("balance = %d after a full round trip, want 0", bal)
	}
	total, err := f.ledger.ConservationCheck(f.ctx)
	if err != nil {
		t.Fatal(err)
	}
	if total != 0 {
		t.Fatalf("books do not balance after a round trip: %d", total)
	}
}
