package wallet_test

import (
	"context"
	"testing"
	"time"

	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
	resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
	walletservice "hyapp/services/wallet-service/internal/service/wallet"
	"hyapp/services/wallet-service/internal/testutil/mysqltest"
)

// TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。
func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "v9", 7, 3, 11)
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:    "cmd-gift-v2",
		RoomID:       "room-1",
		SenderUserID: 10001,
		TargetUserID: 10002,
		GiftID:       "rose",
		GiftCount:    2,
		PriceVersion: "v9",
	})
	if err != nil {
		t.Fatalf("DebitGift failed: %v", err)
	}

	if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 6 || receipt.HeatValue != 22 || receipt.BalanceAfter != 86 || receipt.PriceVersion != "v9" {
		t.Fatalf("server price settlement mismatch: %+v", receipt)
	}
	if receipt.TransactionID == "" || receipt.BillingReceiptID == "" {
		t.Fatalf("receipt identifiers missing: %+v", receipt)
	}

	balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances sender failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 86 {
		t.Fatalf("sender COIN balance mismatch: %+v", balances)
	}
	balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetGiftPoint})
	if err != nil {
		t.Fatalf("GetBalances target failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetGiftPoint) != 6 {
		t.Fatalf("target GIFT_POINT balance mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
		t.Fatalf("gift debit should write two entries, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 3 {
		t.Fatalf("gift debit should write balance and gift outbox events, got %d", got)
	}
}

// TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。
func TestDebitGiftIsIdempotent(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	svc := walletservice.New(repository)
	command := ledger.DebitGiftCommand{
		CommandID:    "cmd-1",
		RoomID:       "room-1",
		SenderUserID: 10001,
		TargetUserID: 10002,
		GiftID:       "rose",
		GiftCount:    2,
	}

	first, err := svc.DebitGift(context.Background(), command)
	if err != nil {
		t.Fatalf("first debit failed: %v", err)
	}
	second, err := svc.DebitGift(context.Background(), command)
	if err != nil {
		t.Fatalf("second debit failed: %v", err)
	}

	if first.BillingReceiptID != second.BillingReceiptID || second.BalanceAfter != 80 || second.CoinSpent != 20 {
		t.Fatalf("idempotency mismatch: first=%+v second=%+v", first, second)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
		t.Fatalf("idempotent command should write one transaction, got %d", got)
	}
}

// TestDebitGiftRejectsCommandPayloadConflict 锁定同 command_id 不同业务 payload 的冲突语义。
func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	svc := walletservice.New(repository)
	command := ledger.DebitGiftCommand{CommandID: "cmd-conflict", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 1}
	if _, err := svc.DebitGift(context.Background(), command); err != nil {
		t.Fatalf("first debit failed: %v", err)
	}
	command.GiftCount = 2
	_, err := svc.DebitGift(context.Background(), command)
	if !xerr.IsCode(err, xerr.LedgerConflict) {
		t.Fatalf("expected LEDGER_CONFLICT, got %v", err)
	}
}

// TestDebitGiftInsufficientBalance 锁定余额不足时不写成功交易、分录或 outbox。
func TestDebitGiftInsufficientBalance(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 1)
	svc := walletservice.New(repository)

	_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:    "cmd-2",
		RoomID:       "room-1",
		SenderUserID: 10001,
		TargetUserID: 10002,
		GiftID:       "rose",
		GiftCount:    1,
	})
	if !xerr.IsCode(err, xerr.InsufficientBalance) {
		t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-2"); got != 0 {
		t.Fatalf("insufficient balance must not write transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id LIKE ?", "wtx_%"); got != 0 {
		t.Fatalf("insufficient balance must not write entries, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "command_id = ?", "cmd-2"); got != 0 {
		t.Fatalf("insufficient balance must not write outbox, got %d", got)
	}
}

// TestAdminCreditAssetIsIdempotent 验证后台入账有审计字段、幂等和余额查询闭环。
func TestAdminCreditAssetIsIdempotent(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-admin-credit",
		TargetUserID:   20001,
		AssetType:      "coin",
		Amount:         500,
		OperatorUserID: 90001,
		Reason:         "manual recharge",
		EvidenceRef:    "ticket-1",
	}

	first, txID, err := svc.AdminCreditAsset(context.Background(), command)
	if err != nil {
		t.Fatalf("AdminCreditAsset failed: %v", err)
	}
	second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command)
	if err != nil {
		t.Fatalf("AdminCreditAsset retry failed: %v", err)
	}
	if txID == "" || txID != secondTxID || first.AvailableAmount != 500 || second.AvailableAmount != 500 || first.AssetType != ledger.AssetCoin {
		t.Fatalf("admin credit idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID)
	}

	balances, err := svc.GetBalances(context.Background(), 20001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
	if err != nil {
		t.Fatalf("GetBalances failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 500 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
		t.Fatalf("balances mismatch: %+v", balances)
	}
}

// TestCreditTaskRewardIsIdempotent 验证任务奖励走独立账务类型且重复领奖命令不会重复发金币。
func TestCreditTaskRewardIsIdempotent(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.TaskRewardCommand{
		CommandID:    "cmd-task-reward",
		TargetUserID: 21001,
		Amount:       88,
		TaskType:     "daily",
		TaskID:       "task-1",
		CycleKey:     "2026-05-09",
		Reason:       "daily_task_reward",
	}

	first, err := svc.CreditTaskReward(context.Background(), command)
	if err != nil {
		t.Fatalf("CreditTaskReward failed: %v", err)
	}
	second, err := svc.CreditTaskReward(context.Background(), command)
	if err != nil {
		t.Fatalf("CreditTaskReward retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.Amount != 88 || second.Balance.AvailableAmount != 88 {
		t.Fatalf("task reward receipt mismatch: first=%+v second=%+v", first, second)
	}
	balances, err := svc.GetBalances(context.Background(), 21001, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 88 {
		t.Fatalf("task reward balance mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "task_reward"); got != 1 {
		t.Fatalf("task reward should write one transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
		t.Fatalf("task reward should write one entry, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
		t.Fatalf("task reward should write balance and task outbox events, got %d", got)
	}
}

// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。
func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetRechargePolicy(1001, "recharge-v1", 80000, 100)
	_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-credit-seller",
		TargetUserID:   30001,
		AssetType:      ledger.AssetCoinSellerCoin,
		Amount:         160000,
		OperatorUserID: 90001,
		Reason:         "seed seller balance",
		EvidenceRef:    "ticket-seller",
	})
	if err != nil {
		t.Fatalf("seed seller asset failed: %v", err)
	}

	command := ledger.CoinSellerTransferCommand{
		CommandID:      "cmd-seller-transfer",
		SellerUserID:   30001,
		TargetUserID:   30002,
		SellerRegionID: 1001,
		TargetRegionID: 1001,
		Amount:         80000,
		Reason:         "player recharge",
	}
	first, err := svc.TransferCoinFromSeller(context.Background(), command)
	if err != nil {
		t.Fatalf("TransferCoinFromSeller failed: %v", err)
	}
	second, err := svc.TransferCoinFromSeller(context.Background(), command)
	if err != nil {
		t.Fatalf("TransferCoinFromSeller retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SellerBalanceAfter != 80000 || first.TargetBalanceAfter != 80000 || first.RechargeUSDMinor != 100 {
		t.Fatalf("transfer receipt mismatch: first=%+v second=%+v", first, second)
	}

	sellerBalances, err := svc.GetBalances(context.Background(), 30001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances seller failed: %v", err)
	}
	if balanceAmount(sellerBalances, ledger.AssetCoinSellerCoin) != 80000 || balanceAmount(sellerBalances, ledger.AssetCoin) != 0 {
		t.Fatalf("seller balances mismatch: %+v", sellerBalances)
	}
	targetBalances, err := svc.GetBalances(context.Background(), 30002, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances target failed: %v", err)
	}
	if balanceAmount(targetBalances, ledger.AssetCoin) != 80000 {
		t.Fatalf("target COIN balance mismatch: %+v", targetBalances)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
		t.Fatalf("idempotent seller transfer should write one transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
		t.Fatalf("seller transfer should write two entries, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 1 {
		t.Fatalf("seller transfer should write one recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 4 {
		t.Fatalf("seller transfer should write two balance events, one transfer event and one recharge event, got %d", got)
	}
}

// TestTransferCoinFromSellerInsufficientBalance 验证币商余额不足时不生成转账交易。
func TestTransferCoinFromSellerInsufficientBalance(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)

	_, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
		CommandID:      "cmd-seller-low",
		SellerUserID:   31001,
		TargetUserID:   31002,
		SellerRegionID: 1001,
		TargetRegionID: 1001,
		Amount:         1,
		Reason:         "player recharge",
	})
	if !xerr.IsCode(err, xerr.InsufficientBalance) {
		t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-low"); got != 0 {
		t.Fatalf("insufficient seller balance must not write transaction, got %d", got)
	}
}

// TestTransferCoinFromSellerRequiresRechargePolicy 验证缺少区域充值政策时不会产生半成品充值事实。
func TestTransferCoinFromSellerRequiresRechargePolicy(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-credit-no-policy",
		TargetUserID:   32001,
		AssetType:      ledger.AssetCoinSellerCoin,
		Amount:         80000,
		OperatorUserID: 90001,
		Reason:         "seed seller balance",
		EvidenceRef:    "ticket-no-policy",
	})
	if err != nil {
		t.Fatalf("seed seller asset failed: %v", err)
	}

	_, err = svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
		CommandID:      "cmd-seller-no-policy",
		SellerUserID:   32001,
		TargetUserID:   32002,
		SellerRegionID: 2001,
		TargetRegionID: 2001,
		Amount:         80000,
		Reason:         "player recharge",
	})
	if !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("expected NOT_FOUND when recharge policy is missing, got %v", err)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-no-policy"); got != 0 {
		t.Fatalf("missing recharge policy must not write transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id LIKE ?", "wtx_%"); got != 0 {
		t.Fatalf("missing recharge policy must not write recharge record, got %d", got)
	}
}

// TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。
func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.CoinSellerStockCreditCommand{
		CommandID:       "cmd-stock-usdt",
		SellerUserID:    33001,
		StockType:       ledger.StockTypeUSDTPurchase,
		CoinAmount:      8000000,
		PaidAmountMicro: 100000000,
		OperatorUserID:  90001,
	}

	first, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
	if err != nil {
		t.Fatalf("AdminCreditCoinSellerStock purchase failed: %v", err)
	}
	second, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
	if err != nil {
		t.Fatalf("AdminCreditCoinSellerStock retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 8000000 || !first.CountsAsSellerRecharge || first.PaidCurrencyCode != ledger.PaidCurrencyUSDT {
		t.Fatalf("stock purchase receipt mismatch: first=%+v second=%+v", first, second)
	}

	balances, err := svc.GetBalances(context.Background(), 33001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances seller failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 8000000 || balanceAmount(balances, ledger.AssetCoin) != 0 {
		t.Fatalf("seller stock balances mismatch: %+v", balances)
	}
	if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE", first.TransactionID); got != 1 {
		t.Fatalf("stock purchase should write one seller recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
		t.Fatalf("stock purchase should write one wallet entry, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
		t.Fatalf("seller stock purchase must not write player recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
		t.Fatalf("stock purchase should write balance and stock outbox events, got %d", got)
	}
}

// TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount 锁定补偿不能携带充值金额的边界。
func TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	_, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
		CommandID:        "cmd-stock-comp-invalid",
		SellerUserID:     34001,
		StockType:        ledger.StockTypeCoinCompensation,
		CoinAmount:       50000,
		PaidCurrencyCode: ledger.PaidCurrencyUSDT,
		PaidAmountMicro:  1,
		OperatorUserID:   90001,
	})
	if !xerr.IsCode(err, xerr.CoinSellerStockAmountInvalid) {
		t.Fatalf("expected COIN_SELLER_STOCK_AMOUNT_INVALID, got %v", err)
	}
	if got := repository.CountRows("coin_seller_stock_records", "command_id = ?", "cmd-stock-comp-invalid"); got != 0 {
		t.Fatalf("invalid compensation must not write stock record, got %d", got)
	}
}

// TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount 验证金币补偿不计入币商进货金额。
func TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	receipt, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
		CommandID:      "cmd-stock-comp",
		SellerUserID:   35001,
		StockType:      ledger.StockTypeCoinCompensation,
		CoinAmount:     50000,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("AdminCreditCoinSellerStock compensation failed: %v", err)
	}
	if receipt.CountsAsSellerRecharge || receipt.PaidAmountMicro != 0 || receipt.PaidCurrencyCode != "" || receipt.BalanceAfter != 50000 {
		t.Fatalf("stock compensation receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = FALSE AND paid_amount_micro = 0", receipt.TransactionID); got != 1 {
		t.Fatalf("compensation should write one non-recharge stock record, got %d", got)
	}
}

// TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef 防止同一 USDT 凭证重复给库存入账。
func TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.CoinSellerStockCreditCommand{
		CommandID:       "cmd-stock-ref-1",
		SellerUserID:    36001,
		StockType:       ledger.StockTypeUSDTPurchase,
		CoinAmount:      100,
		PaidAmountMicro: 1000000,
		PaymentRef:      "usdt-ref-dup",
		EvidenceRef:     "oss://receipt-dup-1",
		OperatorUserID:  90001,
		Reason:          "first purchase",
	}
	if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
		t.Fatalf("first stock purchase failed: %v", err)
	}
	command.CommandID = "cmd-stock-ref-2"
	command.SellerUserID = 36002
	command.EvidenceRef = "oss://receipt-dup-2"
	_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
	if !xerr.IsCode(err, xerr.CoinSellerPaymentRefDuplicated) {
		t.Fatalf("expected COIN_SELLER_PAYMENT_REF_DUPLICATED, got %v", err)
	}
	if got := repository.CountRows("coin_seller_stock_records", "payment_ref = ?", "usdt-ref-dup"); got != 1 {
		t.Fatalf("duplicate payment_ref should keep one stock record, got %d", got)
	}
}

// TestAdminCreditCoinSellerStockRejectsIdempotencyConflict 锁定 command_id 相同但 payload 不同必须 fail-closed。
func TestAdminCreditCoinSellerStockRejectsIdempotencyConflict(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.CoinSellerStockCreditCommand{
		CommandID:       "cmd-stock-conflict",
		SellerUserID:    37001,
		StockType:       ledger.StockTypeUSDTPurchase,
		CoinAmount:      100,
		PaidAmountMicro: 1000000,
		PaymentRef:      "usdt-ref-conflict",
		EvidenceRef:     "oss://receipt-conflict",
		OperatorUserID:  90001,
		Reason:          "first purchase",
	}
	if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
		t.Fatalf("first stock purchase failed: %v", err)
	}
	command.CoinAmount = 200
	_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
	if !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
	}
}

// TestGiftConfigMustSelectGiftResource 验证后台新增礼物只能绑定 resource_type=gift 的资源。
func TestGiftConfigMustSelectGiftResource(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	frame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "frame_gold",
		ResourceType:   resourcedomain.TypeAvatarFrame,
		Name:           "Gold Frame",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyExtendExpiry,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create frame resource failed: %v", err)
	}
	_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:          "bad-gift",
		ResourceID:      frame.ResourceID,
		Status:          resourcedomain.StatusActive,
		Name:            "Bad Gift",
		PriceVersion:    "v1",
		CoinPrice:       1,
		GiftPointAmount: 1,
		HeatValue:       1,
		OperatorUserID:  90001,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("expected INVALID_ARGUMENT for non-gift resource, got %v", err)
	}

	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_spark",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Spark",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create gift resource failed: %v", err)
	}
	gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:          "spark",
		ResourceID:      giftResource.ResourceID,
		Status:          resourcedomain.StatusActive,
		Name:            "Spark",
		PriceVersion:    "v1",
		CoinPrice:       5,
		GiftPointAmount: 2,
		HeatValue:       9,
		OperatorUserID:  90001,
	})
	if err != nil {
		t.Fatalf("create gift config failed: %v", err)
	}
	if gift.Resource.ResourceType != resourcedomain.TypeGift || gift.CoinPrice != 5 {
		t.Fatalf("gift config mismatch: %+v", gift)
	}

	repository.SetBalance(41001, 100)
	receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-spark-gift",
		RoomID:       "room-1",
		SenderUserID: 41001,
		TargetUserID: 41002,
		GiftID:       "spark",
		GiftCount:    2,
	})
	if err != nil {
		t.Fatalf("DebitGift with resource-backed gift failed: %v", err)
	}
	if receipt.CoinSpent != 10 || receipt.GiftPointAdded != 4 || receipt.HeatValue != 18 {
		t.Fatalf("resource-backed gift settlement mismatch: %+v", receipt)
	}
}

func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()
	nowMs := time.Now().UnixMilli()

	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_diamond_magic",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Diamond Magic",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create diamond gift resource failed: %v", err)
	}
	gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:          "diamond-magic",
		ResourceID:      giftResource.ResourceID,
		Status:          resourcedomain.StatusActive,
		Name:            "Diamond Magic",
		PriceVersion:    "v1",
		ChargeAssetType: ledger.AssetDiamond,
		CoinPrice:       7,
		GiftPointAmount: 3,
		HeatValue:       9,
		GiftTypeCode:    resourcedomain.GiftTypeMagic,
		EffectiveFromMS: nowMs - 1000,
		EffectiveToMS:   nowMs + 60_000,
		EffectTypes:     []string{resourcedomain.GiftEffectAnimation, resourcedomain.GiftEffectMusic},
		OperatorUserID:  90001,
		RegionIDs:       []int64{0},
	})
	if err != nil {
		t.Fatalf("create diamond gift config failed: %v", err)
	}
	if gift.ChargeAssetType != ledger.AssetDiamond || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
		t.Fatalf("diamond gift config mismatch: %+v", gift)
	}

	repository.SetAssetBalance(52001, ledger.AssetDiamond, 100)
	receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-diamond-gift",
		RoomID:       "room-diamond",
		SenderUserID: 52001,
		TargetUserID: 52002,
		GiftID:       "diamond-magic",
		GiftCount:    2,
	})
	if err != nil {
		t.Fatalf("DebitGift with diamond-backed gift failed: %v", err)
	}
	if receipt.ChargeAssetType != ledger.AssetDiamond || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
		t.Fatalf("diamond gift settlement mismatch: %+v", receipt)
	}
}

// TestGiftConfigRegionFilter 验证礼物列表和送礼扣费都按区域配置校验，region_id=0 作为 GLOBAL 兜底区域。
func TestGiftConfigRegionFilter(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	globalResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_regional_global",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Regional Global",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create global gift resource failed: %v", err)
	}
	saudiResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_regional_saudi",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Regional Saudi",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create saudi gift resource failed: %v", err)
	}
	egyptResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_regional_egypt",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Regional Egypt",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create egypt gift resource failed: %v", err)
	}
	commands := []resourcedomain.GiftConfigCommand{
		{
			GiftID:          "regional-global",
			ResourceID:      globalResource.ResourceID,
			Status:          resourcedomain.StatusActive,
			Name:            "Regional Global",
			PriceVersion:    "v1",
			CoinPrice:       1,
			GiftPointAmount: 1,
			HeatValue:       1,
			OperatorUserID:  90001,
			RegionIDs:       []int64{0},
		},
		{
			GiftID:          "regional-saudi",
			ResourceID:      saudiResource.ResourceID,
			Status:          resourcedomain.StatusActive,
			Name:            "Regional Saudi",
			PriceVersion:    "v1",
			CoinPrice:       2,
			GiftPointAmount: 2,
			HeatValue:       2,
			OperatorUserID:  90001,
			RegionIDs:       []int64{1001},
		},
		{
			GiftID:          "regional-egypt",
			ResourceID:      egyptResource.ResourceID,
			Status:          resourcedomain.StatusActive,
			Name:            "Regional Egypt",
			PriceVersion:    "v1",
			CoinPrice:       3,
			GiftPointAmount: 3,
			HeatValue:       3,
			OperatorUserID:  90001,
			RegionIDs:       []int64{2002},
		},
	}
	for _, command := range commands {
		if _, err := svc.CreateGiftConfig(ctx, command); err != nil {
			t.Fatalf("create gift config %s failed: %v", command.GiftID, err)
		}
	}

	items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
		Keyword:      "regional-",
		ActiveOnly:   true,
		FilterRegion: true,
		RegionID:     1001,
		Page:         1,
		PageSize:     10,
	})
	if err != nil {
		t.Fatalf("list region gifts failed: %v", err)
	}
	if total != 2 || !giftIDsContain(items, "regional-global") || !giftIDsContain(items, "regional-saudi") || giftIDsContain(items, "regional-egypt") {
		t.Fatalf("region gift list mismatch total=%d items=%+v", total, items)
	}

	repository.SetBalance(51001, 100)
	if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-regional-egypt-in-saudi",
		RoomID:       "room-region-1001",
		SenderUserID: 51001,
		TargetUserID: 51002,
		GiftID:       "regional-egypt",
		GiftCount:    1,
		RegionID:     1001,
	}); !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("expected NOT_FOUND when sending gift outside configured region, got %v", err)
	}
}

// TestGrantResourceGroupExpandsWalletAssetAndEntitlement 验证资源组赠送会原子展开钱包资产入账和非钱包权益。
func TestGrantResourceGroupExpandsWalletAssetAndEntitlement(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "badge_vip",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "VIP Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create badge resource failed: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "starter_pack",
		Name:      "Starter Pack",
		Status:    resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{
			{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 2000, SortOrder: 1},
			{ResourceID: badgeResource.ResourceID, Quantity: 1, SortOrder: 2},
		},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create resource group failed: %v", err)
	}

	command := resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-grant-starter-pack",
		TargetUserID:   42001,
		GroupID:        group.GroupID,
		Reason:         "test grant",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	}
	first, err := svc.GrantResourceGroup(ctx, command)
	if err != nil {
		t.Fatalf("GrantResourceGroup failed: %v", err)
	}
	second, err := svc.GrantResourceGroup(ctx, command)
	if err != nil {
		t.Fatalf("GrantResourceGroup retry failed: %v", err)
	}
	if first.GrantID == "" || first.GrantID != second.GrantID || len(first.Items) != 2 {
		t.Fatalf("grant idempotency mismatch: first=%+v second=%+v", first, second)
	}

	balances, err := svc.GetBalances(ctx, 42001, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 2000 {
		t.Fatalf("coin grant balance mismatch: %+v", balances)
	}
	if got := repository.CountRows("resource_grants", "command_id = ?", command.CommandID); got != 1 {
		t.Fatalf("idempotent group grant should write one grant, got %d", got)
	}
	if got := repository.CountRows("resource_grant_items", "grant_id = ?", first.GrantID); got != 2 {
		t.Fatalf("group grant should write two grant items, got %d", got)
	}
	if got := repository.CountRows("user_resource_entitlements", "user_id = ?", int64(42001)); got != 1 {
		t.Fatalf("group grant should write one non-coin entitlement, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ?", int64(42001), ledger.AssetCoin); got != 1 {
		t.Fatalf("group grant should write one coin wallet entry, got %d", got)
	}
}

// TestManagerCenterResourceGrantFiltersAndRechecksSwitch 锁定经理中心资源开关必须同时约束列表和写事务。
func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	enabled, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:        "badge_manager_enabled",
		ResourceType:        resourcedomain.TypeBadge,
		Name:                "Manager Enabled Badge",
		Status:              resourcedomain.StatusActive,
		Grantable:           true,
		ManagerGrantEnabled: true,
		GrantStrategy:       resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:         []string{"profile"},
		OperatorUserID:      90001,
	})
	if err != nil {
		t.Fatalf("create manager enabled resource failed: %v", err)
	}
	disabledForManager, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:        "badge_manager_disabled",
		ResourceType:        resourcedomain.TypeBadge,
		Name:                "Manager Disabled Badge",
		Status:              resourcedomain.StatusActive,
		Grantable:           true,
		ManagerGrantEnabled: false,
		GrantStrategy:       resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:         []string{"profile"},
		OperatorUserID:      90001,
	})
	if err != nil {
		t.Fatalf("create manager disabled resource failed: %v", err)
	}
	if _, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:        "badge_disabled_status",
		ResourceType:        resourcedomain.TypeBadge,
		Name:                "Disabled Badge",
		Status:              resourcedomain.StatusDisabled,
		Grantable:           true,
		ManagerGrantEnabled: true,
		GrantStrategy:       resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:         []string{"profile"},
		OperatorUserID:      90001,
	}); err != nil {
		t.Fatalf("create disabled status resource failed: %v", err)
	}

	items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{
		ResourceType:     resourcedomain.TypeBadge,
		ManagerGrantOnly: true,
		Page:             1,
		PageSize:         20,
	})
	if err != nil {
		t.Fatalf("ListResources manager grant only failed: %v", err)
	}
	if total != 1 || len(items) != 1 || items[0].ResourceID != enabled.ResourceID {
		t.Fatalf("manager grant resource list mismatch: total=%d items=%+v", total, items)
	}

	grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-manager-grant-enabled",
		TargetUserID:   43001,
		ResourceID:     enabled.ResourceID,
		Quantity:       1,
		Reason:         "manager center",
		OperatorUserID: 91001,
		GrantSource:    resourcedomain.GrantSourceManagerCenter,
	})
	if err != nil {
		t.Fatalf("manager center grant enabled resource failed: %v", err)
	}
	if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 {
		t.Fatalf("manager grant response mismatch: %+v", grant)
	}

	_, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-manager-grant-disabled",
		TargetUserID:   43001,
		ResourceID:     disabledForManager.ResourceID,
		Quantity:       1,
		Reason:         "manager center",
		OperatorUserID: 91001,
		GrantSource:    resourcedomain.GrantSourceManagerCenter,
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("expected PERMISSION_DENIED for manager disabled resource, got %v", err)
	}
}

// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)

	_, err := svc.GrantResourceGroup(context.Background(), resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-manager-grant-group",
		TargetUserID:   43002,
		GroupID:        101,
		Reason:         "manager center",
		OperatorUserID: 91001,
		GrantSource:    resourcedomain.GrantSourceManagerCenter,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("expected INVALID_ARGUMENT for manager center group grant, got %v", err)
	}
}

// TestActiveResourceGroupRejectsUngrantableResource 验证 active 资源组不能保存或启用当前不可发放的资源项。
func TestActiveResourceGroupRejectsUngrantableResource(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	disabledBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "badge_disabled",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Disabled Badge",
		Status:         resourcedomain.StatusDisabled,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create disabled badge failed: %v", err)
	}
	_, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "active_badge_disabled",
		Name:      "Active Disabled Badge",
		Status:    resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{
			{ResourceID: disabledBadge.ResourceID, Quantity: 1},
		},
		OperatorUserID: 90001,
	})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("expected CONFLICT for disabled group resource, got %v", err)
	}

	coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:      "coin_resource_forbidden_in_group",
		ResourceType:      resourcedomain.TypeCoin,
		Name:              "Coin Resource",
		Status:            resourcedomain.StatusActive,
		Grantable:         true,
		GrantStrategy:     resourcedomain.GrantStrategyWalletCredit,
		WalletAssetType:   ledger.AssetCoin,
		WalletAssetAmount: 1000,
		UsageScopes:       []string{"grant"},
		OperatorUserID:    90001,
	})
	if err != nil {
		t.Fatalf("create coin resource failed: %v", err)
	}
	_, err = svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "active_coin_resource",
		Name:      "Active Coin Resource",
		Status:    resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{
			{ResourceID: coinResource.ResourceID, Quantity: 1},
		},
		OperatorUserID: 90001,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("expected INVALID_ARGUMENT for coin resource group item, got %v", err)
	}

	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "disabled_badge_staging",
		Name:      "Disabled Badge Staging",
		Status:    resourcedomain.StatusDisabled,
		Items: []resourcedomain.ResourceGroupItemInput{
			{ResourceID: disabledBadge.ResourceID, Quantity: 1},
		},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("disabled resource group should be saveable before enable: %v", err)
	}
	_, err = svc.SetResourceGroupStatus(ctx, resourcedomain.StatusCommand{
		ID:             group.GroupID,
		Status:         resourcedomain.StatusActive,
		OperatorUserID: 90001,
	})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("expected CONFLICT when enabling group with disabled resource, got %v", err)
	}
}

// TestEquipUserResourceRequiresActiveEquipableEntitlement 验证用户只能佩戴自己有效的可佩戴权益。
func TestEquipUserResourceRequiresActiveEquipableEntitlement(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "badge_equippable",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Equippable Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create badge resource failed: %v", err)
	}
	grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-equip-badge",
		TargetUserID:   43001,
		ResourceID:     badgeResource.ResourceID,
		Quantity:       1,
		Reason:         "test equip",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant badge resource failed: %v", err)
	}
	equipped, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:     43001,
		ResourceID: badgeResource.ResourceID,
	})
	if err != nil {
		t.Fatalf("equip badge resource failed: %v", err)
	}
	if equipped.EntitlementID == "" || equipped.EntitlementID != grant.Items[0].EntitlementID {
		t.Fatalf("equipped entitlement mismatch: equipped=%+v grant=%+v", equipped, grant)
	}
	repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:        43001,
		ResourceID:    badgeResource.ResourceID,
		EntitlementID: equipped.EntitlementID,
	})
	if err != nil {
		t.Fatalf("repeat equip badge resource failed: %v", err)
	}
	if repeated.EntitlementID != equipped.EntitlementID {
		t.Fatalf("repeat equip should keep entitlement: repeated=%+v equipped=%+v", repeated, equipped)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 1 {
		t.Fatalf("equipment should keep one active badge, got %d", got)
	}

	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_not_equipable",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Not Equipable Gift",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create gift resource failed: %v", err)
	}
	if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-non-equipable-gift",
		TargetUserID:   43001,
		ResourceID:     giftResource.ResourceID,
		Quantity:       1,
		Reason:         "test non equip",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	}); err != nil {
		t.Fatalf("grant gift resource failed: %v", err)
	}
	_, err = svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:     43001,
		ResourceID: giftResource.ResourceID,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("expected INVALID_ARGUMENT for non-equipable resource, got %v", err)
	}
}

// TestApplyWithdrawalFreezesUSDBalance 验证提现申请只创建待审核事实，并把可提现美元余额转入冻结。
func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalance(50001, ledger.AssetUSDBalance, 3000)
	svc := walletservice.New(repository)

	withdrawal, balance, err := svc.ApplyWithdrawal(context.Background(), ledger.WithdrawalCommand{
		CommandID:     "cmd-withdraw-usd",
		UserID:        50001,
		Amount:        1200,
		PayoutAccount: "paypal:host@example.com",
		Reason:        "host payout",
	})
	if err != nil {
		t.Fatalf("ApplyWithdrawal failed: %v", err)
	}
	if withdrawal.WithdrawalID == "" || withdrawal.Status != ledger.WithdrawalStatusPending || withdrawal.Amount != 1200 {
		t.Fatalf("withdrawal response mismatch: %+v", withdrawal)
	}
	if balance.AvailableAmount != 1800 || balance.FrozenAmount != 1200 {
		t.Fatalf("withdrawal balance mismatch: %+v", balance)
	}
	if got := repository.CountRows("wallet_withdrawal_requests", "user_id = ? AND status = ?", int64(50001), ledger.WithdrawalStatusPending); got != 1 {
		t.Fatalf("withdrawal request should be persisted once, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-withdraw-usd", "withdrawal_apply"); got != 1 {
		t.Fatalf("withdrawal should write one transaction, got %d", got)
	}
}

// TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(51001, 20000)
	svc := walletservice.New(repository)

	receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip1",
		UserID:    51001,
		Level:     1,
	})
	if err != nil {
		t.Fatalf("PurchaseVip failed: %v", err)
	}
	if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.CoinSpent != 1000 || receipt.CoinBalanceAfter != 19000 {
		t.Fatalf("vip receipt mismatch: %+v", receipt)
	}
	if !receipt.Vip.Active || receipt.Vip.Level != 1 || receipt.Vip.ExpiresAtMS <= receipt.Vip.StartedAtMS {
		t.Fatalf("vip membership mismatch: %+v", receipt.Vip)
	}
	if len(receipt.RewardItems) != 1 || receipt.RewardItems[0].ResourceType != resourcedomain.TypeAvatarFrame {
		t.Fatalf("vip reward items mismatch: %+v", receipt.RewardItems)
	}
	if got := repository.CountRows("vip_purchase_orders", "user_id = ? AND target_level = ?", int64(51001), int32(1)); got != 1 {
		t.Fatalf("vip order should be persisted once, got %d", got)
	}
	if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND status = 'active'", int64(51001)); got != 1 {
		t.Fatalf("vip reward entitlement should be active, got %d", got)
	}

	again, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip1",
		UserID:    51001,
		Level:     1,
	})
	if err != nil {
		t.Fatalf("PurchaseVip retry failed: %v", err)
	}
	if again.OrderID != receipt.OrderID || again.CoinBalanceAfter != receipt.CoinBalanceAfter {
		t.Fatalf("vip idempotency mismatch: first=%+v again=%+v", receipt, again)
	}
	if _, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip-renew",
		UserID:    51001,
		Level:     1,
	}); err != nil {
		t.Fatalf("same level renewal should be allowed: %v", err)
	}
	upgrade, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip3",
		UserID:    51001,
		Level:     3,
	})
	if err != nil {
		t.Fatalf("vip upgrade failed: %v", err)
	}
	if upgrade.Vip.Level != 3 {
		t.Fatalf("vip upgrade level mismatch: %+v", upgrade.Vip)
	}
	_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip-down-to-1",
		UserID:    51001,
		Level:     1,
	})
	if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
		t.Fatalf("expected VIP_DOWNGRADE_NOT_ALLOWED, got %v", err)
	}
}

// TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService 验证我的页开关和钻石兑换配置不再由 gateway 硬编码。
func TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(52001, 700)
	repository.SetAssetBalance(52001, ledger.AssetDiamond, 300)
	svc := walletservice.New(repository)

	overview, err := svc.GetWalletOverview(context.Background(), 52001)
	if err != nil {
		t.Fatalf("GetWalletOverview failed: %v", err)
	}
	if !overview.FeatureFlags.RechargeEnabled || !overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled {
		t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
	}
	if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetDiamond) != 300 {
		t.Fatalf("overview balances mismatch: %+v", overview.Balances)
	}
	rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001)
	if err != nil {
		t.Fatalf("GetDiamondExchangeConfig failed: %v", err)
	}
	if len(rules) != 2 || rules[0].FromAssetType != ledger.AssetDiamond || !rules[0].Enabled {
		t.Fatalf("diamond exchange rules mismatch: %+v", rules)
	}
}

// TestRechargeProductsAreConfiguredByPlatformRegionAndStatus 锁定内购配置由 wallet-service 持久化并按平台、区域和上架态投放。
func TestRechargeProductsAreConfiguredByPlatformRegionAndStatus(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)

	product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    1500000,
		CoinAmount:     1500,
		ProductName:    "1500 Coins",
		Description:    "coin pack",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1002, 1001},
		Enabled:        true,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct failed: %v", err)
	}
	if product.ProductID <= 0 || product.ProductCode == "" || product.Channel != ledger.RechargeChannelGoogle {
		t.Fatalf("created product identifiers mismatch: %+v", product)
	}
	if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 {
		t.Fatalf("created product regions mismatch: %+v", product.RegionIDs)
	}

	products, channels, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid)
	if err != nil {
		t.Fatalf("ListRechargeProducts android failed: %v", err)
	}
	if len(products) != 1 || products[0].ProductName != "1500 Coins" || products[0].CoinAmount != 1500 || products[0].AmountMicro != 1500000 {
		t.Fatalf("android recharge product mismatch: products=%+v", products)
	}
	if len(channels) != 1 || channels[0] != ledger.RechargeChannelGoogle {
		t.Fatalf("android recharge channels mismatch: %+v", channels)
	}

	iosProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformIOS)
	if err != nil {
		t.Fatalf("ListRechargeProducts ios failed: %v", err)
	}
	if len(iosProducts) != 0 {
		t.Fatalf("ios list must not include android product: %+v", iosProducts)
	}

	_, err = svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		ProductID:      product.ProductID,
		AmountMicro:    2500000,
		CoinAmount:     2600,
		ProductName:    "2600 Coins",
		Description:    "updated pack",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1002},
		Enabled:        false,
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("UpdateRechargeProduct failed: %v", err)
	}

	inactiveProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid)
	if err != nil {
		t.Fatalf("ListRechargeProducts inactive failed: %v", err)
	}
	if len(inactiveProducts) != 0 {
		t.Fatalf("disabled product must not be exposed to app: %+v", inactiveProducts)
	}

	adminProducts, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
		AppCode:  "lalu",
		Status:   ledger.RechargeProductStatusDisabled,
		RegionID: 1002,
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("ListAdminRechargeProducts failed: %v", err)
	}
	if total != 1 || len(adminProducts) != 1 || adminProducts[0].Enabled || adminProducts[0].CoinAmount != 2600 {
		t.Fatalf("admin disabled product mismatch: total=%d products=%+v", total, adminProducts)
	}

	if err := svc.DeleteRechargeProduct(context.Background(), "lalu", product.ProductID); err != nil {
		t.Fatalf("DeleteRechargeProduct failed: %v", err)
	}
	remaining, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{AppCode: "lalu", Page: 1, PageSize: 10})
	if err != nil {
		t.Fatalf("ListAdminRechargeProducts after delete failed: %v", err)
	}
	if total != 0 || len(remaining) != 0 {
		t.Fatalf("deleted product must disappear: total=%d products=%+v", total, remaining)
	}
}

func TestApplyGameCoinChangeDebitCreditAndIdempotency(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(88001, 500)
	svc := walletservice.New(repository)

	debit := ledger.GameCoinChangeCommand{
		AppCode:         "lalu",
		CommandID:       "game:demo:order-1",
		UserID:          88001,
		PlatformCode:    "demo",
		GameID:          "demo_rocket_001",
		ProviderOrderID: "order-1",
		ProviderRoundID: "round-1",
		OpType:          ledger.GameOpDebit,
		CoinAmount:      120,
		RoomID:          "room-game-1",
		RequestHash:     "hash-order-1",
	}
	first, err := svc.ApplyGameCoinChange(context.Background(), debit)
	if err != nil {
		t.Fatalf("game debit failed: %v", err)
	}
	if first.BalanceAfter != 380 || first.IdempotentReplay {
		t.Fatalf("game debit receipt mismatch: %+v", first)
	}
	second, err := svc.ApplyGameCoinChange(context.Background(), debit)
	if err != nil {
		t.Fatalf("game debit replay failed: %v", err)
	}
	if second.TransactionID != first.TransactionID || !second.IdempotentReplay || second.BalanceAfter != 380 {
		t.Fatalf("game debit replay mismatch: first=%+v second=%+v", first, second)
	}
	credit, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
		AppCode:         "lalu",
		CommandID:       "game:demo:order-2",
		UserID:          88001,
		PlatformCode:    "demo",
		GameID:          "demo_rocket_001",
		ProviderOrderID: "order-2",
		OpType:          ledger.GameOpCredit,
		CoinAmount:      70,
		RequestHash:     "hash-order-2",
	})
	if err != nil {
		t.Fatalf("game credit failed: %v", err)
	}
	if credit.BalanceAfter != 450 {
		t.Fatalf("game credit balance mismatch: %+v", credit)
	}
	balances, err := svc.GetBalances(context.Background(), 88001, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 450 {
		t.Fatalf("final balance mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_transactions", "biz_type IN ('game_debit','game_credit')"); got != 2 {
		t.Fatalf("game coin changes should write two transactions, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "event_type = ?", "WalletBalanceChanged"); got < 2 {
		t.Fatalf("game coin changes should publish balance outbox events, got %d", got)
	}
}

func TestApplyGameCoinChangeRejectsHashConflictAndInsufficientBalance(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(88002, 50)
	svc := walletservice.New(repository)
	command := ledger.GameCoinChangeCommand{
		AppCode:         "lalu",
		CommandID:       "game:demo:order-conflict",
		UserID:          88002,
		PlatformCode:    "demo",
		GameID:          "demo_rocket_001",
		ProviderOrderID: "order-conflict",
		OpType:          ledger.GameOpDebit,
		CoinAmount:      20,
		RequestHash:     "hash-a",
	}
	if _, err := svc.ApplyGameCoinChange(context.Background(), command); err != nil {
		t.Fatalf("first game debit failed: %v", err)
	}
	command.RequestHash = "hash-b"
	if _, err := svc.ApplyGameCoinChange(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
	}
	_, err := svc.ApplyGameCoinChange(context.Background(), ledger.GameCoinChangeCommand{
		AppCode:         "lalu",
		CommandID:       "game:demo:order-insufficient",
		UserID:          88002,
		PlatformCode:    "demo",
		GameID:          "demo_rocket_001",
		ProviderOrderID: "order-insufficient",
		OpType:          ledger.GameOpDebit,
		CoinAmount:      1000,
		RequestHash:     "hash-insufficient",
	})
	if !xerr.IsCode(err, xerr.InsufficientBalance) {
		t.Fatalf("expected INSUFFICIENT_BALANCE, got %v", err)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "game:demo:order-insufficient"); got != 0 {
		t.Fatalf("insufficient game debit must not write transaction, got %d", got)
	}
}

func balanceAmount(balances []ledger.AssetBalance, assetType string) int64 {
	for _, balance := range balances {
		if balance.AssetType == assetType {
			return balance.AvailableAmount
		}
	}
	return -1
}

func giftIDsContain(items []resourcedomain.GiftConfig, giftID string) bool {
	for _, item := range items {
		if item.GiftID == giftID {
			return true
		}
	}
	return false
}
