package payments

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/go-jet/jet/v2/postgres"
	"github.com/go-jet/jet/v2/qrm"
	"github.com/google/uuid"

	"scrabble/backend/internal/postgres/jet/payments/model"
	"scrabble/backend/internal/postgres/jet/payments/table"
)

// Domain errors surfaced by the payments store and service.
var (
	// ErrInsufficientChips means the spendable segments held fewer chips than the price.
	ErrInsufficientChips = errors.New("payments: insufficient chips")
	// ErrUntrusted means the platform context is untrusted, so the gate is fail-closed.
	ErrUntrusted = errors.New("payments: untrusted platform")
	// ErrProductNotFound means the product is absent or deactivated.
	ErrProductNotFound = errors.New("payments: product not found")
	// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
	// cannot be bought with chips.
	ErrNotAValue = errors.New("payments: product is not a chip-priced value")
	// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
	// admin never grants currency (a gifted balance would bypass the cash desk, D16).
	ErrCannotGrantChips = errors.New("payments: cannot grant chips")
	// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
	// which has no credit target until the tournament stage.
	ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
	// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
	ErrNothingToGrant = errors.New("payments: product has nothing to grant")
)

// withTx runs fn inside a transaction on db, rolling back on error or panic.
func withTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) (err error) {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("payments: begin tx: %w", err)
	}
	defer func() {
		if p := recover(); p != nil {
			_ = tx.Rollback()
			panic(p)
		}
	}()
	if err := fn(tx); err != nil {
		_ = tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("payments: commit tx: %w", err)
	}
	return nil
}

// sourceAmount is one chip draw from a single segment during a spend.
type sourceAmount struct {
	source Source
	amount int
}

// catalogProduct is a product resolved for a chip spend: its chip price, the benefit its atoms
// fold into, and the raw atom composition for the purchase snapshot.
type catalogProduct struct {
	id         uuid.UUID
	title      string
	priceChips int
	delta      benefitDelta
	atoms      map[string]int
}

// loadState reads an account's balances and benefits straight from the materialized tables.
func (s *Store) loadState(ctx context.Context, accountID uuid.UUID) (walletState, error) {
	st := walletState{chips: map[Source]int{}, benefits: map[Source]benefitState{}}
	var brows []model.Balances
	if err := postgres.SELECT(table.Balances.AllColumns).
		FROM(table.Balances).
		WHERE(table.Balances.AccountID.EQ(postgres.UUID(accountID))).
		QueryContext(ctx, s.db, &brows); err != nil {
		return walletState{}, fmt.Errorf("payments: load balances %s: %w", accountID, err)
	}
	for _, r := range brows {
		st.chips[Source(r.Source)] = int(r.Chips)
	}
	var frows []model.Benefits
	if err := postgres.SELECT(table.Benefits.AllColumns).
		FROM(table.Benefits).
		WHERE(table.Benefits.AccountID.EQ(postgres.UUID(accountID))).
		QueryContext(ctx, s.db, &frows); err != nil {
		return walletState{}, fmt.Errorf("payments: load benefits %s: %w", accountID, err)
	}
	for _, r := range frows {
		st.benefits[Source(r.Origin)] = benefitState{adsPaidUntil: r.AdsPaidUntil, adsForever: r.AdsForever, hints: int(r.Hints)}
	}
	return st, nil
}

// state returns an account's payments state, served from the read cache when warm, otherwise
// loaded from the materialized tables and cached. The returned maps are read-only.
func (s *Store) state(ctx context.Context, accountID uuid.UUID) (walletState, error) {
	if st, ok := s.cache.get(accountID); ok {
		return st, nil
	}
	st, err := s.loadState(ctx, accountID)
	if err != nil {
		return walletState{}, err
	}
	s.cache.put(accountID, st)
	return st, nil
}

// Invalidate drops the cached state of every listed account so the next read reloads it. It is
// called after a committed mutation whose transaction the payments package does not own — the
// account-merge flow (after its own commit) and, later, external fund/refund intake.
func (s *Store) Invalidate(ids ...uuid.UUID) {
	for _, id := range ids {
		s.cache.invalidate(id)
	}
}

// loadProduct resolves a chip-priced value: an active product with a CHIP price row
// (method NULL) whose atoms are benefits (hints / no-ads days). It rejects a missing or
// deactivated product (ErrProductNotFound), a product with no chip price (ErrNotAValue), and a
// product carrying the chips atom (a chip pack is funded, never bought with chips — ErrNotAValue).
func (s *Store) loadProduct(ctx context.Context, productID uuid.UUID) (catalogProduct, error) {
	var p model.Product
	err := postgres.SELECT(table.Product.AllColumns).
		FROM(table.Product).
		WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
		LIMIT(1).
		QueryContext(ctx, s.db, &p)
	if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
		return catalogProduct{}, ErrProductNotFound
	}
	if err != nil {
		return catalogProduct{}, fmt.Errorf("payments: load product %s: %w", productID, err)
	}

	var price model.ProductPrice
	err = postgres.SELECT(table.ProductPrice.AllColumns).
		FROM(table.ProductPrice).
		WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
			AND(table.ProductPrice.Method.IS_NULL()).
			AND(table.ProductPrice.Currency.EQ(postgres.String(string(CurrencyChip))))).
		LIMIT(1).
		QueryContext(ctx, s.db, &price)
	if errors.Is(err, qrm.ErrNoRows) {
		return catalogProduct{}, ErrNotAValue
	}
	if err != nil {
		return catalogProduct{}, fmt.Errorf("payments: load price %s: %w", productID, err)
	}

	var items []model.ProductItem
	if err := postgres.SELECT(table.ProductItem.AllColumns).
		FROM(table.ProductItem).
		WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID))).
		QueryContext(ctx, s.db, &items); err != nil {
		return catalogProduct{}, fmt.Errorf("payments: load items %s: %w", productID, err)
	}

	cp := catalogProduct{id: productID, title: p.Title, priceChips: int(price.Amount), atoms: map[string]int{}}
	for _, it := range items {
		cp.atoms[it.AtomType] = int(it.Quantity)
		switch it.AtomType {
		case "hints":
			cp.delta.hintsAdd += int(it.Quantity)
		case "noads_days":
			cp.delta.noAdsDays += int(it.Quantity)
		case "chips":
			return catalogProduct{}, ErrNotAValue // a value never grants chips
		}
	}
	return cp, nil
}

// stackNoAds returns the new no-ads term end after adding addDays whole days from
// max(now, current end) — terms add up, the remainder is never lost (§5/D33). A non-positive
// addDays leaves the term unchanged; a nil current means no term yet.
func stackNoAds(current *time.Time, addDays int, now time.Time) *time.Time {
	if addDays <= 0 {
		return current
	}
	base := now
	if current != nil && current.After(now) {
		base = *current
	}
	end := base.Add(time.Duration(addDays) * 24 * time.Hour)
	return &end
}

// combineNoAds folds secondary's remaining no-ads term onto primary's during a merge: the
// remaining duration of each is preserved (§6/D15 "terms extend per origin").
func combineNoAds(primary, secondary *time.Time, now time.Time) *time.Time {
	if secondary == nil || !secondary.After(now) {
		return primary
	}
	base := now
	if primary != nil && primary.After(now) {
		base = *primary
	}
	end := base.Add(secondary.Sub(now))
	return &end
}

// applyBenefitTx applies a benefit delta to one origin inside tx, stacking the no-ads term,
// OR-ing the forever flag and adding hints. It ensures the (account, origin) row exists, locks
// it, then writes the recomputed values — so concurrent applies serialise on the row lock.
func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin Source, d benefitDelta, now time.Time) error {
	if d.zero() {
		return nil
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO payments.benefits (account_id, origin) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
		accountID, string(origin)); err != nil {
		return fmt.Errorf("payments: ensure benefit row %s: %w", origin, err)
	}
	var untilCur *time.Time
	var foreverCur bool
	var hintsCur int32
	if err := tx.QueryRowContext(ctx,
		`SELECT ads_paid_until, ads_forever, hints FROM payments.benefits
		 WHERE account_id = $1 AND origin = $2 FOR UPDATE`, accountID, string(origin)).
		Scan(&untilCur, &foreverCur, &hintsCur); err != nil {
		return fmt.Errorf("payments: lock benefit %s: %w", origin, err)
	}
	if _, err := tx.ExecContext(ctx,
		`UPDATE payments.benefits SET ads_paid_until = $3, ads_forever = $4, hints = $5, updated_at = now()
		 WHERE account_id = $1 AND origin = $2`,
		accountID, string(origin), stackNoAds(untilCur, d.noAdsDays, now), foreverCur || d.forever, int(hintsCur)+d.hintsAdd); err != nil {
		return fmt.Errorf("payments: apply benefit %s: %w", origin, err)
	}
	return nil
}

// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
// providerPaymentID are set only on an intake credit (fund/refund) and are nil for a
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
// unique index, so a duplicate provider callback fails here (the idempotency key).
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
	id, err := uuid.NewV7()
	if err != nil {
		return fmt.Errorf("payments: ledger id: %w", err)
	}
	// The snapshot is a bare value, not a postgres.String literal: a jsonb column infers its
	// type from an untyped parameter (the game-moves payload idiom), whereas a text-typed
	// literal is rejected against jsonb.
	var snap any = postgres.NULL
	if snapshot != nil {
		snap = string(snapshot)
	}
	stmt := table.Ledger.INSERT(
		table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
		table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
		table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
		table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
	).VALUES(
		id, accountID, kind,
		sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
		uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
		stringOrNull(providerPaymentID), snap, now,
	)
	if _, err := stmt.ExecContext(ctx, tx); err != nil {
		return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
	}
	return nil
}

// spend draws the chips across the given segments, appends a spend ledger row per draw (carrying
// the purchase snapshot), and applies the benefit — all in one transaction. It fails closed on
// an insufficient balance (a guarded decrement), rolling everything back.
func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAmount, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
	err := withTx(ctx, s.db, func(tx *sql.Tx) error {
		for _, dr := range draws {
			res, err := table.Balances.
				UPDATE(table.Balances.Chips, table.Balances.UpdatedAt).
				SET(table.Balances.Chips.SUB(postgres.Int(int64(dr.amount))), postgres.TimestampzT(now)).
				WHERE(table.Balances.AccountID.EQ(postgres.UUID(accountID)).
					AND(table.Balances.Source.EQ(postgres.String(string(dr.source)))).
					AND(table.Balances.Chips.GT_EQ(postgres.Int(int64(dr.amount))))).
				ExecContext(ctx, tx)
			if err != nil {
				return fmt.Errorf("payments: decrement %s: %w", dr.source, err)
			}
			if n, _ := res.RowsAffected(); n == 0 {
				return ErrInsufficientChips
			}
			src := dr.source
			if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil {
				return err
			}
		}
		return applyBenefitTx(ctx, tx, accountID, origin, d, now)
	})
	if err != nil {
		return err
	}
	s.cache.invalidate(accountID)
	return nil
}

// grant records an admin_grant ledger row (price 0, no chips) and applies the granted benefit to
// the chosen origin, in one transaction — a zero-price sale of a value.
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
	err := withTx(ctx, s.db, func(tx *sql.Tx) error {
		if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil {
			return err
		}
		return applyBenefitTx(ctx, tx, accountID, origin, d, now)
	})
	if err != nil {
		return err
	}
	s.cache.invalidate(accountID)
	return nil
}

// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
	err := withTx(ctx, s.db, func(tx *sql.Tx) error {
		if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
			return err
		}
		return applyBenefitTx(ctx, tx, accountID, origin, d, now)
	})
	if err != nil {
		return err
	}
	s.cache.invalidate(accountID)
	return nil
}

// consumeHint decrements one hint from the first applicable origin (in the given priority order)
// that has one, with a guarded update. It returns whether a hint was spent.
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
	for _, o := range origins {
		res, err := table.Benefits.
			UPDATE(table.Benefits.Hints, table.Benefits.UpdatedAt).
			SET(table.Benefits.Hints.SUB(postgres.Int(1)), postgres.TimestampzT(now)).
			WHERE(table.Benefits.AccountID.EQ(postgres.UUID(accountID)).
				AND(table.Benefits.Origin.EQ(postgres.String(string(o)))).
				AND(table.Benefits.Hints.GT(postgres.Int(0)))).
			ExecContext(ctx, s.db)
		if err != nil {
			return false, fmt.Errorf("payments: consume hint %s: %w", o, err)
		}
		if n, _ := res.RowsAffected(); n > 0 {
			s.cache.invalidate(accountID)
			return true, nil
		}
	}
	return false, nil
}

// MergeTx folds the secondary account's segments and benefits into the primary, by source and by
// origin, inside the caller's transaction (the account-merge flow): chips sum, no-ads terms
// extend per origin, forever OR-s, hints add. The secondary's payments rows are removed. The
// caller invalidates the primary's cache after its own commit (Invalidate). It does not touch
// the account schema — the JET import boundary stays intact, only the shared connection is used.
func (s *Store) MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO payments.balances (account_id, source, chips, updated_at)
		 SELECT $1, source, chips, now() FROM payments.balances WHERE account_id = $2
		 ON CONFLICT (account_id, source) DO UPDATE
		   SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
		primary, secondary); err != nil {
		return fmt.Errorf("payments: merge balances: %w", err)
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM payments.balances WHERE account_id = $1`, secondary); err != nil {
		return fmt.Errorf("payments: clear secondary balances: %w", err)
	}

	rows, err := tx.QueryContext(ctx,
		`SELECT origin, ads_paid_until, ads_forever, hints FROM payments.benefits WHERE account_id = $1`, secondary)
	if err != nil {
		return fmt.Errorf("payments: read secondary benefits: %w", err)
	}
	type secBenefit struct {
		origin  Source
		until   *time.Time
		forever bool
		hints   int
	}
	var secs []secBenefit
	for rows.Next() {
		var b secBenefit
		var o string
		var h int32
		if err := rows.Scan(&o, &b.until, &b.forever, &h); err != nil {
			rows.Close()
			return fmt.Errorf("payments: scan secondary benefit: %w", err)
		}
		b.origin, b.hints = Source(o), int(h)
		secs = append(secs, b)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return fmt.Errorf("payments: iterate secondary benefits: %w", err)
	}
	rows.Close()

	for _, b := range secs {
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO payments.benefits (account_id, origin) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
			primary, string(b.origin)); err != nil {
			return fmt.Errorf("payments: ensure primary benefit %s: %w", b.origin, err)
		}
		var untilCur *time.Time
		var foreverCur bool
		var hintsCur int32
		if err := tx.QueryRowContext(ctx,
			`SELECT ads_paid_until, ads_forever, hints FROM payments.benefits
			 WHERE account_id = $1 AND origin = $2 FOR UPDATE`, primary, string(b.origin)).
			Scan(&untilCur, &foreverCur, &hintsCur); err != nil {
			return fmt.Errorf("payments: lock primary benefit %s: %w", b.origin, err)
		}
		if _, err := tx.ExecContext(ctx,
			`UPDATE payments.benefits SET ads_paid_until = $3, ads_forever = $4, hints = $5, updated_at = now()
			 WHERE account_id = $1 AND origin = $2`,
			primary, string(b.origin), combineNoAds(untilCur, b.until, now), foreverCur || b.forever, int(hintsCur)+b.hints); err != nil {
			return fmt.Errorf("payments: merge benefit %s: %w", b.origin, err)
		}
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM payments.benefits WHERE account_id = $1`, secondary); err != nil {
		return fmt.Errorf("payments: clear secondary benefits: %w", err)
	}
	return nil
}

// sourceOrNull renders an optional source as a SQL string or NULL.
func sourceOrNull(s *Source) postgres.Expression {
	if s == nil {
		return postgres.NULL
	}
	return postgres.String(string(*s))
}

// uuidOrNull renders an optional id as a SQL uuid or NULL.
func uuidOrNull(id *uuid.UUID) postgres.Expression {
	if id == nil {
		return postgres.NULL
	}
	return postgres.UUID(*id)
}

// stringOrNull renders an optional string as a SQL string or NULL.
func stringOrNull(s *string) postgres.Expression {
	if s == nil {
		return postgres.NULL
	}
	return postgres.String(*s)
}
