package lnurl

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"sync"
	"time"
)

// Withdraw implements the LNURL-withdraw exchange.
//
// The flow, from the player's side, is: tap Cash out, scan the code, done.
// Underneath:
//
//  1. The arcade mints a single-use token (k1) bound to one account and one
//     amount, and shows it as an LNURL.
//  2. The player's wallet fetches the URL and reads the terms.
//  3. The wallet generates an invoice for the amount and calls back with it.
//  4. The arcade pays that invoice.
//
// Step 4 is why the token must be strictly single-use. It is a bearer
// instrument: anyone holding it can direct a payment to an invoice of their
// choosing. So it is random, short-lived, consumed on first use, and bound to
// the amount it was issued for.
var (
	ErrUnknownToken = errors.New("lnurl: token not recognised or already used")
	ErrExpired      = errors.New("lnurl: token has expired")
	ErrAmountRange  = errors.New("lnurl: invoice amount outside the permitted range")
)

// TokenTTL is how long a withdraw code stays valid. Short, because it is a
// bearer instrument: a code left on screen at a party should stop working
// well before anyone wanders off with a photograph of it.
const TokenTTL = 5 * time.Minute

// Token is one outstanding withdraw authorisation.
type Token struct {
	K1         string
	AccountID  int64
	AmountMsat int64
	Expires    time.Time
}

// WithdrawRequest is the JSON a wallet reads after scanning.
// Field names are fixed by the LNURL specification.
type WithdrawRequest struct {
	Tag                string `json:"tag"`
	Callback           string `json:"callback"`
	K1                 string `json:"k1"`
	DefaultDescription string `json:"defaultDescription"`
	MinWithdrawable    int64  `json:"minWithdrawable"`
	MaxWithdrawable    int64  `json:"maxWithdrawable"`
}

// Response is the LNURL result envelope.
type Response struct {
	Status string `json:"status"`
	Reason string `json:"reason,omitempty"`
}

func OK() Response             { return Response{Status: "OK"} }
func Fail(why string) Response { return Response{Status: "ERROR", Reason: why} }

// Service issues and redeems withdraw tokens.
//
// Tokens live in memory rather than the database. They are short-lived and
// worthless once used, and keeping them out of durable storage means a lost
// instance cannot leave a valid bearer token lying around to be replayed
// later.
type Service struct {
	// BaseURL is how a wallet reaches this instance, e.g. http://10.0.0.5:8080.
	BaseURL string

	mu     sync.Mutex
	tokens map[string]Token
}

func NewService(baseURL string) *Service {
	return &Service{BaseURL: baseURL, tokens: make(map[string]Token)}
}

// Issue mints a withdraw code for an exact amount.
func (s *Service) Issue(accountID, amountMsat int64) (lnurl string, k1 string, err error) {
	if amountMsat <= 0 {
		return "", "", fmt.Errorf("%w: amount must be positive", ErrAmountRange)
	}

	var raw [32]byte
	if _, err := rand.Read(raw[:]); err != nil {
		panic("lnurl: system randomness unavailable: " + err.Error())
	}
	k1 = hex.EncodeToString(raw[:])

	s.mu.Lock()
	s.sweepLocked()
	s.tokens[k1] = Token{
		K1: k1, AccountID: accountID, AmountMsat: amountMsat,
		Expires: time.Now().Add(TokenTTL),
	}
	s.mu.Unlock()

	url := fmt.Sprintf("%s/lnurl/withdraw?k1=%s", s.BaseURL, k1)
	encoded, err := EncodeURL(url)
	if err != nil {
		return "", "", err
	}
	return encoded, k1, nil
}

// Describe returns the terms a wallet reads after scanning.
//
// The minimum and maximum are set to the same value, which is what tells the
// wallet to withdraw exactly this amount rather than prompting the player to
// choose one. Choosing an amount is the step this whole mechanism exists to
// remove.
func (s *Service) Describe(k1 string) (*WithdrawRequest, error) {
	t, err := s.lookup(k1)
	if err != nil {
		return nil, err
	}
	return &WithdrawRequest{
		Tag:                "withdrawRequest",
		Callback:           s.BaseURL + "/lnurl/withdraw/callback",
		K1:                 t.K1,
		DefaultDescription: "Quantum Arcade cash out",
		MinWithdrawable:    t.AmountMsat,
		MaxWithdrawable:    t.AmountMsat,
	}, nil
}

// Redeem consumes a token and returns what it authorises.
//
// The token is deleted before the payment is attempted. A token that is
// consumed and then fails to pay costs the player a retry; a token that is
// left valid after a payment succeeds costs the house the whole balance
// again. The asymmetry decides the ordering.
func (s *Service) Redeem(ctx context.Context, k1 string) (Token, error) {
	s.mu.Lock()
	t, ok := s.tokens[k1]
	if ok {
		delete(s.tokens, k1)
	}
	s.mu.Unlock()

	if !ok {
		return Token{}, ErrUnknownToken
	}
	if time.Now().After(t.Expires) {
		return Token{}, ErrExpired
	}
	return t, nil
}

// Restore puts a token back after a failed payment, so a routing failure does
// not silently swallow the player's cash-out.
func (s *Service) Restore(t Token) {
	if time.Now().After(t.Expires) {
		return // no point restoring something already expired
	}
	s.mu.Lock()
	s.tokens[t.K1] = t
	s.mu.Unlock()
}

// ForceStore inserts a token regardless of its expiry. It exists so tests can
// construct an aged token; production code uses Restore, which refuses to
// resurrect something already expired.
func (s *Service) ForceStore(t Token) {
	s.mu.Lock()
	s.tokens[t.K1] = t
	s.mu.Unlock()
}

func (s *Service) lookup(k1 string) (Token, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tokens[k1]
	if !ok {
		return Token{}, ErrUnknownToken
	}
	if time.Now().After(t.Expires) {
		delete(s.tokens, k1)
		return Token{}, ErrExpired
	}
	return t, nil
}

// sweepLocked drops expired tokens. Called under the mutex.
//
// It discards them rather than reporting them, because Issue does not know how
// to refund. Callers that must refund use Expired instead.
func (s *Service) sweepLocked() {
	now := time.Now()
	for k, t := range s.tokens {
		if now.After(t.Expires) {
			delete(s.tokens, k)
		}
	}
}

// Expired removes and returns every token past its lifetime.
//
// The funds behind a code are debited when it is issued, so a code that is
// never scanned leaves a player short. The caller refunds what this returns —
// which is why the tokens are handed back rather than quietly dropped.
func (s *Service) Expired() []Token {
	now := time.Now()
	var out []Token

	s.mu.Lock()
	for k, t := range s.tokens {
		if now.After(t.Expires) {
			out = append(out, t)
			delete(s.tokens, k)
		}
	}
	s.mu.Unlock()
	return out
}

// Outstanding reports how many tokens are live, for tests and the admin view.
func (s *Service) Outstanding() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.tokens)
}
