//go:build integration

package inttest

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

	"github.com/google/uuid"

	"scrabble/backend/internal/payments"
)

// seedBalance writes a chip balance for an account+source directly (payments has no FK to
// accounts, so a bare uuid is a valid account id here).
func seedBalance(t *testing.T, id uuid.UUID, source string, chips int) {
	t.Helper()
	if _, err := testDB.ExecContext(context.Background(),
		`INSERT INTO payments.balances (account_id, source, chips) VALUES ($1,$2,$3)
		 ON CONFLICT (account_id, source) DO UPDATE SET chips = EXCLUDED.chips`,
		id, source, chips); err != nil {
		t.Fatalf("seed balance: %v", err)
	}
}

// readBalance reads an account's chip balance for a source (0 when the row is absent).
func readBalance(t *testing.T, id uuid.UUID, source string) int {
	t.Helper()
	var chips int
	err := testDB.QueryRowContext(context.Background(),
		`SELECT chips FROM payments.balances WHERE account_id=$1 AND source=$2`, id, source).Scan(&chips)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("read balance: %v", err)
	}
	return chips
}

// benefitUntil reads an account's no-ads term end for an origin (nil when none).
func benefitUntil(t *testing.T, id uuid.UUID, origin string) *time.Time {
	t.Helper()
	var until *time.Time
	err := testDB.QueryRowContext(context.Background(),
		`SELECT ads_paid_until FROM payments.benefits WHERE account_id=$1 AND origin=$2`, id, origin).Scan(&until)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		t.Fatalf("read benefit until: %v", err)
	}
	return until
}

// ledgerRows counts an account's ledger rows of a given kind.
func ledgerRows(t *testing.T, id uuid.UUID, kind string) int {
	t.Helper()
	var n int
	if err := testDB.QueryRowContext(context.Background(),
		`SELECT count(*) FROM payments.ledger WHERE account_id=$1 AND kind=$2`, id, kind).Scan(&n); err != nil {
		t.Fatalf("ledger count: %v", err)
	}
	return n
}

// seedValueProduct creates an active chip-priced value: a product with a CHIP price (method
// NULL) and the given hint / no-ads-day atoms. It returns the product id.
func seedValueProduct(t *testing.T, priceChips, hints, noAdsDays int) uuid.UUID {
	t.Helper()
	ctx := context.Background()
	prod := uuid.New()
	if _, err := testDB.ExecContext(ctx,
		`INSERT INTO payments.product (product_id, title, active) VALUES ($1,'test value',true)`, prod); err != nil {
		t.Fatalf("seed product: %v", err)
	}
	if _, err := testDB.ExecContext(ctx,
		`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, NULL, 'CHIP', $2)`,
		prod, priceChips); err != nil {
		t.Fatalf("seed price: %v", err)
	}
	if hints > 0 {
		if _, err := testDB.ExecContext(ctx,
			`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'hints',$2)`, prod, hints); err != nil {
			t.Fatalf("seed hints item: %v", err)
		}
	}
	if noAdsDays > 0 {
		if _, err := testDB.ExecContext(ctx,
			`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,'noads_days',$2)`, prod, noAdsDays); err != nil {
			t.Fatalf("seed noads item: %v", err)
		}
	}
	return prod
}

// TestPaymentsSpendAppliesAtomically verifies a chip spend writes the ledger row, decrements the
// balance and applies the benefit together over Postgres.
func TestPaymentsSpendAppliesAtomically(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	seedBalance(t, acc, "direct", 100)
	prod := seedValueProduct(t, 60, 3, 30)

	if err := svc.Spend(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod); err != nil {
		t.Fatalf("spend: %v", err)
	}
	if got := readBalance(t, acc, "direct"); got != 40 {
		t.Errorf("balance after spend = %d, want 40", got)
	}
	if hints, _ := readBenefit(t, acc, "direct"); hints != 3 {
		t.Errorf("hints after spend = %d, want 3", hints)
	}
	if benefitUntil(t, acc, "direct") == nil {
		t.Error("no-ads term not applied by the spend")
	}
	if n := ledgerRows(t, acc, "spend"); n != 1 {
		t.Errorf("spend ledger rows = %d, want 1", n)
	}
}

// TestPaymentsSpendInsufficientNoWrite verifies an under-funded spend is refused and writes
// nothing (the balance, benefit and ledger are untouched).
func TestPaymentsSpendInsufficientNoWrite(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	seedBalance(t, acc, "direct", 10)
	prod := seedValueProduct(t, 60, 3, 0)

	if err := svc.Spend(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod); !errors.Is(err, payments.ErrInsufficientChips) {
		t.Fatalf("spend = %v, want ErrInsufficientChips", err)
	}
	if got := readBalance(t, acc, "direct"); got != 10 {
		t.Errorf("balance changed to %d, want 10 (no write)", got)
	}
	if h, _ := readBenefit(t, acc, "direct"); h != 0 {
		t.Errorf("benefit applied on a refused spend: hints %d", h)
	}
	if ledgerRows(t, acc, "spend") != 0 {
		t.Error("spend ledger row written on a refused spend")
	}
}

// TestPaymentsSpendGuardRollsBack forces the in-transaction guard to bite: with a stale read
// cache the draw plan looks affordable, but the guarded decrement matches no row and the whole
// transaction rolls back — no ledger row, no benefit, balance unchanged.
func TestPaymentsSpendGuardRollsBack(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	seedBalance(t, acc, "direct", 100)
	present := []payments.Source{payments.SourceDirect}
	cxt := payments.NewContext("direct", "web")
	if _, err := svc.Wallet(ctx, acc, cxt, present); err != nil { // warm the cache at 100
		t.Fatalf("warm: %v", err)
	}
	// Drain the real balance behind the cache's back (no invalidation), so the plan over-commits.
	if _, err := testDB.ExecContext(ctx, `UPDATE payments.balances SET chips = 10 WHERE account_id=$1 AND source='direct'`, acc); err != nil {
		t.Fatalf("drain: %v", err)
	}
	prod := seedValueProduct(t, 60, 3, 0)

	if err := svc.Spend(ctx, acc, cxt, present, prod); !errors.Is(err, payments.ErrInsufficientChips) {
		t.Fatalf("spend = %v, want ErrInsufficientChips", err)
	}
	if got := readBalance(t, acc, "direct"); got != 10 {
		t.Errorf("balance = %d, want 10 (rolled back)", got)
	}
	if ledgerRows(t, acc, "spend") != 0 {
		t.Error("spend ledger row written despite the rollback")
	}
	if h, _ := readBenefit(t, acc, "direct"); h != 0 {
		t.Error("benefit applied despite the rollback")
	}
}

// TestPaymentsGrantCreditsValuesNotChips verifies admin_grant applies a benefit at price 0
// without ever crediting chips (D16).
func TestPaymentsGrantCreditsValuesNotChips(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()

	if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, true); err != nil {
		t.Fatalf("grant: %v", err)
	}
	if hints, forever := readBenefit(t, acc, "direct"); hints != 5 || !forever {
		t.Errorf("granted benefit = hints %d forever %v, want 5/true", hints, forever)
	}
	if readBalance(t, acc, "direct") != 0 {
		t.Error("a grant must not credit chips")
	}
	if ledgerRows(t, acc, "admin_grant") != 1 {
		t.Error("admin_grant ledger row missing")
	}
	if ledgerRows(t, acc, "spend") != 0 || ledgerRows(t, acc, "fund") != 0 {
		t.Error("a grant wrote a non-grant ledger row")
	}
}

// TestPaymentsSpendHintByContext verifies a hint is drawn from an applicable origin, and that a
// direct-origin hint is not usable inside a VK context (the compliance wall for hints).
func TestPaymentsSpendHintByContext(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	seedBenefit(t, acc, "direct", 2, false)
	present := []payments.Source{payments.SourceDirect, payments.SourceVK}

	if spent, err := svc.SpendHint(ctx, acc, payments.NewContext("direct", "web"), present); err != nil || !spent {
		t.Fatalf("web spend hint = %v (err %v), want true", spent, err)
	}
	if h, _ := readBenefit(t, acc, "direct"); h != 1 {
		t.Errorf("hints after spend = %d, want 1", h)
	}
	// Inside VK the applicable origins are {vk} only, so the direct-origin hint is untouched.
	if spent, _ := svc.SpendHint(ctx, acc, payments.NewContext("vk", "android"), present); spent {
		t.Error("direct-origin hint spent inside VK — compliance wall breached")
	}
	if h, _ := readBenefit(t, acc, "direct"); h != 1 {
		t.Errorf("direct hints changed inside VK = %d, want 1 (untouched)", h)
	}
}

// TestPaymentsComplianceGateOverPostgres is the named compliance regression at the integration
// layer: a direct-origin no-ads benefit applies on the web but never inside a store wrapper.
func TestPaymentsComplianceGateOverPostgres(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	acc := uuid.New()
	if err := svc.Grant(ctx, acc, payments.SourceDirect, 0, 30, false); err != nil {
		t.Fatalf("grant: %v", err)
	}
	present := []payments.Source{payments.SourceDirect, payments.SourceVK}

	if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("direct", "web"), present); !adFree {
		t.Error("direct no-ads should apply on web")
	}
	if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("vk", "android"), present); adFree {
		t.Error("direct no-ads must NOT apply inside VK (compliance wall)")
	}
	if adFree, _ := svc.AdFree(ctx, acc, payments.NewContext("telegram", "web"), present); adFree {
		t.Error("direct no-ads must NOT apply inside Telegram (compliance wall)")
	}
}

// TestPaymentsMergeFoldsSegmentsAndBenefits verifies the merge folds chip segments (sum) and
// benefits (hints sum, forever OR) by origin, and clears the secondary's rows, over Postgres.
func TestPaymentsMergeFoldsSegmentsAndBenefits(t *testing.T) {
	ctx := context.Background()
	svc := newPaymentsService()
	primary, secondary := uuid.New(), uuid.New()
	seedBalance(t, primary, "vk", 10)
	seedBalance(t, secondary, "vk", 25)
	seedBenefit(t, primary, "vk", 1, false)
	seedBenefit(t, secondary, "vk", 4, true)

	tx, err := testDB.BeginTx(ctx, nil)
	if err != nil {
		t.Fatalf("begin: %v", err)
	}
	if err := svc.MergeTx(ctx, tx, primary, secondary); err != nil {
		_ = tx.Rollback()
		t.Fatalf("merge: %v", err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatalf("commit: %v", err)
	}
	svc.Invalidate(primary, secondary)

	if got := readBalance(t, primary, "vk"); got != 35 {
		t.Errorf("merged chips = %d, want 35", got)
	}
	if h, forever := readBenefit(t, primary, "vk"); h != 5 || !forever {
		t.Errorf("merged benefit = hints %d forever %v, want 5/true", h, forever)
	}
	if readBalance(t, secondary, "vk") != 0 {
		t.Error("secondary balance not cleared after merge")
	}
}
