// Package lightning moves real money in and out of the internal ledger.
//
// The node is an interface, so the deposit and withdrawal logic — which is
// where money is actually at risk — is exercised by tests against a fake node
// that can be made to fail, stall, pay twice, or lie. Swapping in a real node
// is configuration, not new code.
//
// Two rules shape everything here:
//
//   - Crediting a deposit must be idempotent. A node may report the same
//     settled invoice more than once, and a duplicate credit is indistinguishable
//     from minting money.
//   - A withdrawal must debit before it pays. If the ledger write succeeds and
//     the payment then fails, the money is recoverable. If the payment succeeds
//     and the ledger write fails, it is not.
package lightning

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

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

var (
	ErrNodeUnavailable  = errors.New("lightning: node unavailable")
	ErrPaymentFailed    = errors.New("lightning: payment failed")
	ErrAmountOutOfRange = errors.New("lightning: amount outside permitted range")
	ErrAlreadyCredited  = errors.New("lightning: invoice already credited")
	ErrNeedsApproval    = errors.New("lightning: withdrawal requires operator approval")
)

// Invoice is a request for an inbound payment.
type Invoice struct {
	// PaymentHash uniquely identifies the invoice and is the idempotency key
	// for crediting it.
	PaymentHash string
	Bolt11      string
	AmountMsat  int64
	ExpiresAt   time.Time
}

// Payment is the result of an outbound send.
type Payment struct {
	PaymentHash string
	Preimage    string
	AmountMsat  int64
	FeeMsat     int64
}

// Node is the Lightning wallet. Implemented by the Alby Hub client in
// production and by a fake in tests.
type Node interface {
	// CreateInvoice requests an inbound payment.
	CreateInvoice(ctx context.Context, amountMsat int64, memo string) (Invoice, error)

	// LookupInvoice reports whether an invoice has been paid.
	LookupInvoice(ctx context.Context, paymentHash string) (settled bool, amountMsat int64, err error)

	// PayInvoice sends an outbound payment. maxFeeMsat bounds the routing fee.
	PayInvoice(ctx context.Context, bolt11 string, maxFeeMsat int64) (Payment, error)

	// Balance reports spendable millisatoshis held by the node.
	Balance(ctx context.Context) (int64, error)
}

// Limits bound what the service will do without a human.
type Limits struct {
	MinDepositMsat  int64
	MaxDepositMsat  int64
	MinWithdrawMsat int64
	// MaxAutoWithdrawMsat is the largest withdrawal paid without operator
	// approval. Above it the request is queued. This is the blast radius of a
	// stolen session token.
	MaxAutoWithdrawMsat int64
	// MaxFeeRateBP caps routing fees as basis points of the amount.
	MaxFeeRateBP int64
}

// DefaultLimits are deliberately conservative.
func DefaultLimits() Limits {
	return Limits{
		MinDepositMsat:      1_000,       // 1 sat
		MaxDepositMsat:      100_000_000, // 100k sats
		MinWithdrawMsat:     1_000,
		MaxAutoWithdrawMsat: 50_000_000, // 50k sats
		MaxFeeRateBP:        100,        // 1%
	}
}

// Service ties the node to the ledger.
type Service struct {
	node   Node
	ledger *ledger.Ledger
	pool   *pgxpool.Pool
	limits Limits
}

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

// RequestDeposit creates an invoice for a player and records it as pending.
func (s *Service) RequestDeposit(ctx context.Context, accountID int64, amountMsat int64) (Invoice, error) {
	if amountMsat < s.limits.MinDepositMsat || amountMsat > s.limits.MaxDepositMsat {
		return Invoice{}, fmt.Errorf("%w: deposits are %d to %d msat",
			ErrAmountOutOfRange, s.limits.MinDepositMsat, s.limits.MaxDepositMsat)
	}

	inv, err := s.node.CreateInvoice(ctx, amountMsat,
		fmt.Sprintf("Quantum Arcade deposit for account %d", accountID))
	if err != nil {
		return Invoice{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
	}

	if _, err := s.pool.Exec(ctx,
		`INSERT INTO lightning_invoices
		   (payment_hash, account_id, amount_msat, bolt11, expires_at)
		 VALUES ($1, $2, $3, $4, $5)`,
		inv.PaymentHash, accountID, inv.AmountMsat, inv.Bolt11, inv.ExpiresAt); err != nil {
		return Invoice{}, fmt.Errorf("recording invoice: %w", err)
	}
	return inv, nil
}

// SettleDeposit credits a paid invoice to its player.
//
// It is idempotent by payment hash. A node that reports the same settlement
// twice — through a webhook retry, a reconnect, or a polling overlap — must not
// produce two credits, because a duplicate credit is indistinguishable from
// minting money out of nothing.
func (s *Service) SettleDeposit(ctx context.Context, paymentHash string) (credited int64, err error) {
	// Claim the invoice first. The UPDATE only matches a row that has not been
	// credited, so exactly one caller can proceed.
	var accountID, amountMsat int64
	err = s.pool.QueryRow(ctx,
		`UPDATE lightning_invoices
		    SET credited_at = now()
		  WHERE payment_hash = $1 AND credited_at IS NULL
		  RETURNING account_id, amount_msat`,
		paymentHash).Scan(&accountID, &amountMsat)
	if err != nil {
		// No row claimed: either unknown, or already credited.
		var exists bool
		if e := s.pool.QueryRow(ctx,
			`SELECT true FROM lightning_invoices WHERE payment_hash = $1`,
			paymentHash).Scan(&exists); e == nil && exists {
			return 0, ErrAlreadyCredited
		}
		return 0, fmt.Errorf("unknown invoice %s", paymentHash)
	}

	// Confirm with the node before crediting. Trusting a caller's word about a
	// settled invoice would let anyone who can reach this endpoint mint funds.
	settled, paidMsat, err := s.node.LookupInvoice(ctx, paymentHash)
	if err != nil {
		s.releaseClaim(ctx, paymentHash)
		return 0, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
	}
	if !settled {
		s.releaseClaim(ctx, paymentHash)
		return 0, fmt.Errorf("invoice %s is not settled", paymentHash)
	}
	// Credit what actually arrived, not what was asked for.
	if paidMsat > 0 && paidMsat != amountMsat {
		amountMsat = paidMsat
	}

	if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
		s.releaseClaim(ctx, paymentHash)
		return 0, fmt.Errorf("crediting ledger: %w", err)
	}
	return amountMsat, nil
}

// releaseClaim undoes a claim when the credit could not be completed, so a
// transient failure does not strand a real payment forever.
func (s *Service) releaseClaim(ctx context.Context, paymentHash string) {
	if _, err := s.pool.Exec(ctx,
		`UPDATE lightning_invoices SET credited_at = NULL WHERE payment_hash = $1`,
		paymentHash); err != nil {
		fmt.Printf("lightning: could not release claim on %s: %v\n", paymentHash, err)
	}
}

// RequestWithdrawal debits a player and queues an outbound payment.
//
// The debit happens first and in the same call. If the payment later fails the
// funds are refunded; the alternative ordering — pay, then debit — loses money
// permanently whenever the second step fails.
func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (int64, error) {
	if amountMsat < s.limits.MinWithdrawMsat {
		return 0, fmt.Errorf("%w: minimum withdrawal is %d msat",
			ErrAmountOutOfRange, s.limits.MinWithdrawMsat)
	}

	// Take the funds now, so the same balance cannot be withdrawn twice by
	// two concurrent requests.
	if _, err := s.ledger.Withdraw(ctx, accountID, amountMsat); err != nil {
		return 0, err
	}

	status := "queued"
	if amountMsat > s.limits.MaxAutoWithdrawMsat {
		// Large withdrawals wait for a human. This bounds what a stolen
		// session token can remove.
		status = "needs_approval"
	}

	var id int64
	if err := s.pool.QueryRow(ctx,
		`INSERT INTO lightning_withdrawals
		   (account_id, bolt11, amount_msat, status)
		 VALUES ($1, $2, $3, $4) RETURNING id`,
		accountID, bolt11, amountMsat, status).Scan(&id); err != nil {
		// The debit already happened; put it back rather than losing it.
		if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
			fmt.Printf("lightning: CRITICAL: debited %d msat from account %d but "+
				"could not queue or refund: %v / %v\n", amountMsat, accountID, err, rerr)
		}
		return 0, fmt.Errorf("queueing withdrawal: %w", err)
	}
	if status == "needs_approval" {
		return id, ErrNeedsApproval
	}
	return id, nil
}

// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
	rows, err := s.pool.Query(ctx,
		`SELECT id, account_id, bolt11, amount_msat
		   FROM lightning_withdrawals
		  WHERE status = 'queued'
		  ORDER BY id
		  LIMIT $1`, limit)
	if err != nil {
		return 0, err
	}
	type job struct {
		id, account, amount int64
		bolt11              string
	}
	var jobs []job
	for rows.Next() {
		var j job
		if err := rows.Scan(&j.id, &j.account, &j.bolt11, &j.amount); err != nil {
			rows.Close()
			return 0, err
		}
		jobs = append(jobs, j)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return 0, err
	}

	paid := 0
	for _, j := range jobs {
		// Claim before paying, so two instances cannot send the same payment.
		tag, err := s.pool.Exec(ctx,
			`UPDATE lightning_withdrawals SET status = 'sending'
			  WHERE id = $1 AND status = 'queued'`, j.id)
		if err != nil || tag.RowsAffected() == 0 {
			continue
		}

		maxFee := j.amount * s.limits.MaxFeeRateBP / 10000
		payment, err := s.node.PayInvoice(ctx, j.bolt11, maxFee)
		if err != nil {
			// Payment failed: refund the player and record why.
			if _, rerr := s.ledger.Deposit(ctx, j.account, j.amount); rerr != nil {
				fmt.Printf("lightning: CRITICAL: payment %d failed and refund failed: %v\n",
					j.id, rerr)
			}
			if _, uerr := s.pool.Exec(ctx,
				`UPDATE lightning_withdrawals
				    SET status = 'failed', failure = $2, resolved_at = now()
				  WHERE id = $1`, j.id, err.Error()); uerr != nil {
				fmt.Printf("lightning: recording failure for %d: %v\n", j.id, uerr)
			}
			continue
		}

		if _, err := s.pool.Exec(ctx,
			`UPDATE lightning_withdrawals
			    SET status = 'paid', payment_hash = $2, fee_msat = $3, resolved_at = now()
			  WHERE id = $1`, j.id, payment.PaymentHash, payment.FeeMsat); err != nil {
			fmt.Printf("lightning: payment %d sent but not recorded: %v\n", j.id, err)
		}
		paid++
	}
	return paid, nil
}

// Approve releases a withdrawal that was held for review.
func (s *Service) Approve(ctx context.Context, withdrawalID int64) error {
	tag, err := s.pool.Exec(ctx,
		`UPDATE lightning_withdrawals SET status = 'queued'
		  WHERE id = $1 AND status = 'needs_approval'`, withdrawalID)
	if err != nil {
		return err
	}
	if tag.RowsAffected() == 0 {
		return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
	}
	return nil
}

// Reject cancels a held withdrawal and refunds the player.
func (s *Service) Reject(ctx context.Context, withdrawalID int64, reason string) error {
	var accountID, amountMsat int64
	err := s.pool.QueryRow(ctx,
		`UPDATE lightning_withdrawals
		    SET status = 'rejected', failure = $2, resolved_at = now()
		  WHERE id = $1 AND status = 'needs_approval'
		  RETURNING account_id, amount_msat`,
		withdrawalID, reason).Scan(&accountID, &amountMsat)
	if err != nil {
		return fmt.Errorf("withdrawal %d is not awaiting approval", withdrawalID)
	}
	if _, err := s.ledger.Deposit(ctx, accountID, amountMsat); err != nil {
		return fmt.Errorf("refunding rejected withdrawal: %w", err)
	}
	return nil
}

// Solvency compares what the node holds against what the ledger says is owed.
//
// These must agree. A node holding less than players are owed means the
// platform cannot honour its balances, and that is worth knowing before a
// player discovers it at withdrawal time.
type Solvency struct {
	NodeBalanceMsat int64
	OwedToPlayers   int64
	SurplusMsat     int64
	Solvent         bool
}

func (s *Service) CheckSolvency(ctx context.Context) (Solvency, error) {
	nodeBal, err := s.node.Balance(ctx)
	if err != nil {
		return Solvency{}, fmt.Errorf("%w: %v", ErrNodeUnavailable, err)
	}
	owed, err := s.ledger.TotalIssued(ctx)
	if err != nil {
		return Solvency{}, err
	}
	return Solvency{
		NodeBalanceMsat: nodeBal,
		OwedToPlayers:   owed,
		SurplusMsat:     nodeBal - owed,
		Solvent:         nodeBal >= owed,
	}, nil
}
