package wallet_test

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"google.golang.org/grpc"
	activityv1 "hyapp.local/api/proto/activity/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/walletmq"
	"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/service/wallet/ports"
	mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
	"hyapp/services/wallet-service/internal/testutil/mysqltest"
)

func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walletmq.WalletOutboxMessage {
	return walletmq.WalletOutboxMessage{
		AppCode:        record.AppCode,
		EventID:        record.EventID,
		EventType:      record.EventType,
		TransactionID:  record.TransactionID,
		CommandID:      record.CommandID,
		UserID:         record.UserID,
		AssetType:      record.AssetType,
		AvailableDelta: record.AvailableDelta,
		FrozenDelta:    record.FrozenDelta,
		PayloadJSON:    record.PayloadJSON,
		OccurredAtMS:   record.CreatedAtMS,
	}
}

func waitForNextWalletVersionMillisecond(t *testing.T) {
	t.Helper()

	start := time.Now().UnixMilli()
	for time.Now().UnixMilli() <= start {
		time.Sleep(time.Millisecond)
	}
}

func requireWalletVersionAdvanced(t *testing.T, change string, before int64, after int64) {
	t.Helper()

	if after <= before {
		t.Fatalf("%s must advance gift catalog version: before=%d after=%d", change, before, after)
	}
}

// TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格，收礼收入进入 COIN，历史 GIFT_POINT 仍保持下线。
func TestDebitGiftUsesServerPriceWithoutGiftPointCredit(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 != 0 || receipt.HeatValue != 14 || 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) != 0 {
		t.Fatalf("target GIFT_POINT must stay zero after point removal: %+v", balances)
	}
	balances, err = svc.GetBalances(context.Background(), 10002, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances target COIN failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 4 {
		t.Fatalf("target COIN income mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
		t.Fatalf("gift debit should write sender debit and target income entries, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 4 {
		t.Fatalf("gift debit should write sender balance, target balance, debit and income outbox events, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
		t.Fatalf("gift debit should publish target income fact, got %d", got)
	}
	if receipt.HostPeriodDiamondAdded != 0 || receipt.HostPeriodCycleKey != "" {
		t.Fatalf("non-host gift must not credit host period diamonds: %+v", receipt)
	}
	if got := repository.CountRows("host_period_diamond_accounts", "user_id = ?", int64(10002)); got != 0 {
		t.Fatalf("non-host gift must not create host period diamond account, got %d", got)
	}
}

func TestHostGiftWithoutPublishedPolicyFailsBeforeAnyMutation(t *testing.T) {
	for index, app := range []string{"lalu", "fami", "huwaa"} {
		t.Run(app, func(t *testing.T) {
			repository := mysqltest.NewRepository(t)
			senderID := int64(31011 + index*10)
			targetID := senderID + 1
			giftID := app + "-policyless-rose"
			commandID := "cmd-" + app + "-policyless-host-gift"
			repository.SetBalanceForApp(app, senderID, 1000)
			repository.SetGiftPriceForApp(app, giftID, "v1", 100, 0, 100)
			repository.SetGiftDiamondRatioForApp(app, 20, resourcedomain.GiftTypeNormal, "100.00")
			svc := walletservice.New(repository)

			_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
				AppCode:            app,
				CommandID:          commandID,
				RoomID:             "room-" + app,
				SenderUserID:       senderID,
				TargetUserID:       targetID,
				TargetIsHost:       true,
				TargetHostRegionID: 20,
				GiftID:             giftID,
				GiftCount:          1,
				PriceVersion:       "v1",
				RegionID:           20,
				SenderRegionID:     20,
			})
			if !xerr.IsCode(err, xerr.PermissionDenied) {
				t.Fatalf("host gift without a published policy must fail closed, got %v", err)
			}
			// 政策解析发生在账户行锁、金币扣减和收礼返币之前；任何 App 都不能用隐式品牌兜底继续结算。
			assertBalanceForApp(t, svc, app, senderID, ledger.AssetCoin, 1000)
			assertBalanceForApp(t, svc, app, targetID, ledger.AssetCoin, 0)
			assertBalanceForApp(t, svc, app, targetID, ledger.AssetPointDiamond, 0)
			if got := repository.CountRows("wallet_transactions", "app_code = ? AND command_id = ?", app, commandID); got != 0 {
				t.Fatalf("policyless host gift must not write a transaction, got %d", got)
			}
			if got := repository.CountRows("host_period_diamond_accounts", "app_code = ? AND user_id = ?", app, targetID); got != 0 {
				t.Fatalf("policyless host gift must not create a cycle account, got %d", got)
			}
		})
	}
}

func TestDebitDirectGiftCreditsReceiverAndKeepsDirectBizType(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(12001, 1000)
	repository.SetGiftPrice("direct-rose", "v1", 100, 0, 100)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               901,
		Name:                   "direct gift host coefficient",
		RegionID:               7701,
		GiftCoinToDiamondRatio: "1",
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:          "cmd-direct-rose",
		SenderUserID:       12001,
		SenderRegionID:     1001,
		RegionID:           1001,
		TargetUserID:       12002,
		TargetIsHost:       true,
		TargetHostRegionID: 7701,
		GiftID:             "direct-rose",
		GiftCount:          1,
		PriceVersion:       "v1",
	})
	if err != nil {
		t.Fatalf("DebitDirectGift failed: %v", err)
	}

	if receipt.CoinSpent != 100 || receipt.BalanceAfter != 900 || receipt.GiftIncomeCoinAmount != 30 || receipt.GiftIncomeBalanceAfter != 30 || receipt.HostPeriodDiamondAdded != 100 {
		t.Fatalf("direct gift settlement mismatch: %+v", receipt)
	}
	assertBalance(t, svc, 12001, ledger.AssetCoin, 900)
	assertBalance(t, svc, 12002, ledger.AssetCoin, 30)
	if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ?", receipt.TransactionID, "direct_gift_debit"); got != 1 {
		t.Fatalf("direct gift transaction must use direct biz type, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND room_id = ''", receipt.TransactionID); got != 2 {
		t.Fatalf("direct gift entries must not pretend to belong to a room, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
		t.Fatalf("direct gift should publish receiver income fact, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPeriodDiamondCredited"); got != 1 {
		t.Fatalf("direct gift to active host should credit host period diamonds, got %d", got)
	}

	// 模拟稳定 intent hash 上线前的 Lalu direct gift：旧 hash 曾包含派生的 region/Host/Agency。
	// 正常重试仍应由 Wallet 根据首次 metadata 验证稳定输入，不需要 Gateway 为每次送礼额外预读回执。
	repository.SetTransactionRequestHashForApp(appcode.Default, "cmd-direct-rose", "legacy-direct-gift-request-hash")
	legacyFormalReplay, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:          "cmd-direct-rose",
		SenderUserID:       12001,
		SenderRegionID:     9999,
		RegionID:           9999,
		TargetUserID:       12002,
		TargetIsHost:       false,
		TargetHostRegionID: 9999,
		GiftID:             "direct-rose",
		GiftCount:          1,
		PriceVersion:       "v1",
	})
	if err != nil || legacyFormalReplay.TransactionID != receipt.TransactionID {
		t.Fatalf("legacy direct gift must replay through the formal rolling-deploy path: receipt=%+v err=%v", legacyFormalReplay, err)
	}
	replayed, err := svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:          "cmd-direct-rose",
		SenderUserID:       12001,
		SenderRegionID:     9999,
		RegionID:           9999,
		TargetUserID:       12002,
		TargetIsHost:       false,
		TargetHostRegionID: 9999,
		GiftID:             "direct-rose",
		GiftCount:          1,
		PriceVersion:       "v1",
	})
	if err != nil {
		t.Fatalf("DebitDirectGift replay failed: %v", err)
	}
	if replayed.TransactionID != receipt.TransactionID || replayed.GiftIncomeCoinAmount != receipt.GiftIncomeCoinAmount || replayed.HostPeriodDiamondAdded != receipt.HostPeriodDiamondAdded {
		t.Fatalf("direct gift replay receipt mismatch: got %+v want %+v", replayed, receipt)
	}
	_, err = svc.DebitDirectGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:    "cmd-direct-rose",
		SenderUserID: 12001,
		TargetUserID: 12003,
		GiftID:       "direct-rose",
		GiftCount:    1,
		PriceVersion: "v1",
	})
	if !xerr.IsCode(err, xerr.LedgerConflict) {
		t.Fatalf("direct gift replay must reject a changed stable target, got %v", err)
	}
}

// TestBatchDebitGiftSettlesAllTargetsAtomically 验证多目标送礼一次扣总额，并为每个接收方写独立收礼事实。
func TestBatchDebitGiftSettlesAllTargetsAtomically(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.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
		CommandID:      "cmd-gift-batch",
		RoomID:         "room-1",
		SenderUserID:   10001,
		GiftID:         "rose",
		GiftCount:      2,
		PriceVersion:   "v9",
		SenderRegionID: 1001,
		Targets: []ledger.DebitGiftTargetCommand{
			{CommandID: "cmd-gift-batch:target:10002", TargetUserID: 10002},
			{CommandID: "cmd-gift-batch:target:10003", TargetUserID: 10003},
		},
	})
	if err != nil {
		t.Fatalf("BatchDebitGift failed: %v", err)
	}

	if receipt.Aggregate.CoinSpent != 28 || receipt.Aggregate.GiftPointAdded != 0 || receipt.Aggregate.HeatValue != 28 || receipt.Aggregate.BalanceAfter != 72 || len(receipt.Targets) != 2 {
		t.Fatalf("batch aggregate mismatch: %+v", receipt)
	}
	for _, userID := range []int64{10002, 10003} {
		balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetGiftPoint})
		if err != nil {
			t.Fatalf("GetBalances target %d failed: %v", userID, err)
		}
		if balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
			t.Fatalf("target %d GIFT_POINT must stay zero after point removal: %+v", userID, balances)
		}
		balances, err = svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
		if err != nil {
			t.Fatalf("GetBalances target COIN %d failed: %v", userID, err)
		}
		if balanceAmount(balances, ledger.AssetCoin) != 4 {
			t.Fatalf("target %d COIN income mismatch: %+v", userID, balances)
		}
	}
	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) != 72 {
		t.Fatalf("sender COIN balance mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003"); got != 2 {
		t.Fatalf("batch gift should write one transaction per target, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id IN (?, ?)", receipt.Targets[0].Receipt.TransactionID, receipt.Targets[1].Receipt.TransactionID); got != 4 {
		t.Fatalf("batch gift should write sender debit and target income entries per target, got %d", got)
	}

	again, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
		CommandID:      "cmd-gift-batch",
		RoomID:         "room-1",
		SenderUserID:   10001,
		GiftID:         "rose",
		GiftCount:      2,
		PriceVersion:   "v9",
		SenderRegionID: 1001,
		Targets: []ledger.DebitGiftTargetCommand{
			{CommandID: "cmd-gift-batch:target:10002", TargetUserID: 10002},
			{CommandID: "cmd-gift-batch:target:10003", TargetUserID: 10003},
		},
	})
	if err != nil {
		t.Fatalf("BatchDebitGift replay failed: %v", err)
	}
	if again.Aggregate.CoinSpent != receipt.Aggregate.CoinSpent || again.Targets[0].Receipt.TransactionID != receipt.Targets[0].Receipt.TransactionID || repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-gift-batch:target:10002", "cmd-gift-batch:target:10003") != 2 {
		t.Fatalf("batch idempotency mismatch: first=%+v again=%+v", receipt, again)
	}
	if receipt.Aggregate.PaidAtMS <= 0 || again.Aggregate.PaidAtMS != receipt.Aggregate.PaidAtMS {
		t.Fatalf("batch aggregate paid_at_ms drifted: first=%d replay=%d", receipt.Aggregate.PaidAtMS, again.Aggregate.PaidAtMS)
	}
	for index, target := range receipt.Targets {
		walletCreatedAtMS := repository.TransactionCreatedAtMS(target.CommandID)
		if target.Receipt.PaidAtMS != walletCreatedAtMS || again.Targets[index].Receipt.PaidAtMS != walletCreatedAtMS || target.Receipt.PaidAtMS != receipt.Aggregate.PaidAtMS {
			t.Fatalf("batch target paid_at_ms is not stable wallet fact: target=%s created=%d first=%d replay=%d aggregate=%d", target.CommandID, walletCreatedAtMS, target.Receipt.PaidAtMS, again.Targets[index].Receipt.PaidAtMS, receipt.Aggregate.PaidAtMS)
		}
	}
	for _, userID := range []int64{10002, 10003} {
		balances, err := svc.GetBalances(context.Background(), userID, []string{ledger.AssetCoin})
		if err != nil {
			t.Fatalf("GetBalances target COIN %d after replay failed: %v", userID, err)
		}
		if balanceAmount(balances, ledger.AssetCoin) != 4 {
			t.Fatalf("batch replay must not duplicate target income for %d: %+v", userID, balances)
		}
	}
}

// TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。
func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "v9", 7, 3, 11)
	// 周期钻石必须来自已发布 Host 政策；系数 1 用于只验证主播身份与幂等累计。
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               906,
		Name:                   "host period identity coefficient",
		RegionID:               8801,
		GiftCoinToDiamondRatio: "1",
	})
	svc := walletservice.New(repository)
	cycleBefore := time.Now().UTC().Format("2006-01")

	command := ledger.DebitGiftCommand{
		CommandID:          "cmd-gift-host-period",
		RoomID:             "room-1",
		SenderUserID:       10001,
		TargetUserID:       10002,
		GiftID:             "rose",
		GiftCount:          2,
		PriceVersion:       "v9",
		TargetIsHost:       true,
		TargetHostRegionID: 8801,
	}
	first, err := svc.DebitGift(context.Background(), command)
	if err != nil {
		t.Fatalf("host DebitGift failed: %v", err)
	}
	cycleAfter := time.Now().UTC().Format("2006-01")

	if first.HostPeriodDiamondAdded != 14 || first.HostPeriodCycleKey == "" {
		t.Fatalf("host period diamond receipt mismatch: %+v", first)
	}
	if first.HostPeriodCycleKey != cycleBefore && first.HostPeriodCycleKey != cycleAfter {
		t.Fatalf("host period cycle must use current UTC month, got %q", first.HostPeriodCycleKey)
	}
	if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND region_id = ? AND total_diamonds = ? AND gift_diamond_total = ?", int64(10002), first.HostPeriodCycleKey, int64(8801), int64(14), int64(14)); got != 1 {
		t.Fatalf("host period diamond account mismatch, got %d", got)
	}
	if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ? AND diamond_delta = ? AND diamond_after = ?", int64(10002), first.HostPeriodCycleKey, int64(14), int64(14)); got != 1 {
		t.Fatalf("host period diamond entry mismatch, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "HostPeriodDiamondCredited"); got != 1 {
		t.Fatalf("host period diamond outbox event mismatch, got %d", got)
	}

	second, err := svc.DebitGift(context.Background(), command)
	if err != nil {
		t.Fatalf("host DebitGift replay failed: %v", err)
	}
	if second.TransactionID != first.TransactionID || second.HostPeriodDiamondAdded != 14 || second.HostPeriodCycleKey != first.HostPeriodCycleKey {
		t.Fatalf("host period idempotent receipt mismatch: first=%+v second=%+v", first, second)
	}
	if got := repository.CountRows("host_period_diamond_accounts", "user_id = ? AND cycle_key = ? AND total_diamonds = ?", int64(10002), first.HostPeriodCycleKey, int64(14)); got != 1 {
		t.Fatalf("host period replay must not add diamonds again, got %d", got)
	}
	if got := repository.CountRows("host_period_diamond_entries", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 1 {
		t.Fatalf("host period replay must not add duplicate entries, got %d", got)
	}
}

func TestDebitGiftWithoutGiftTypeMultiplierFailsBeforeAnyMutation(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalanceForApp("fami", 11001, 500)
	repository.SetGiftPriceForApp("fami", "missing-multiplier-gift", "v1", 100, 0, 100)
	repository.DeleteGiftDiamondRatiosForApp("fami")
	repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{
		PolicyID:               904,
		Name:                   "strict gift multiplier policy",
		RegionID:               8801,
		GiftCoinToDiamondRatio: "1",
	})
	svc := walletservice.New(repository)

	_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		AppCode:            "fami",
		CommandID:          "cmd-missing-gift-multiplier",
		RoomID:             "room-missing-gift-multiplier",
		SenderUserID:       11001,
		SenderRegionID:     1001,
		TargetUserID:       11002,
		GiftID:             "missing-multiplier-gift",
		GiftCount:          1,
		PriceVersion:       "v1",
		TargetIsHost:       true,
		TargetHostRegionID: 8801,
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("missing gift multiplier must fail closed, got %v", err)
	}
	assertBalanceForApp(t, svc, "fami", 11001, ledger.AssetCoin, 500)
	assertBalanceForApp(t, svc, "fami", 11002, ledger.AssetCoin, 0)
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-missing-gift-multiplier"); got != 0 {
		t.Fatalf("missing multiplier must fail before transaction or account mutation, got %d", got)
	}
}

func TestDebitGiftCombinesGiftTypeRatioAndHostPolicyCoefficient(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(12101, 1000)
	repository.SetGiftPrice("lucky-host-coefficient", "v1", 100, 100, 100)
	repository.SetGiftType("lucky-host-coefficient", resourcedomain.GiftTypeLucky)
	repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeLucky, "10.00")
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               902,
		Name:                   "host diamond coefficient",
		RegionID:               8801,
		GiftCoinToDiamondRatio: "1.25",
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:          "cmd-lucky-host-coefficient",
		RoomID:             "room-host-coefficient",
		SenderUserID:       12101,
		SenderRegionID:     1001,
		TargetUserID:       12102,
		GiftID:             "lucky-host-coefficient",
		GiftCount:          1,
		PriceVersion:       "v1",
		TargetIsHost:       true,
		TargetHostRegionID: 8801,
	})
	if err != nil {
		t.Fatalf("DebitGift with Host coefficient failed: %v", err)
	}
	// 100 金币 × lucky 10% × Host 系数 1.25 = 12.5，只在最终结果向下取整为 12。
	if receipt.HostPeriodDiamondAdded != 12 {
		t.Fatalf("combined period diamond formula mismatch: %+v", receipt)
	}
	if got := repository.CountRows("wallet_transactions", `transaction_id = ?
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 100000
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_salary_policy_diamond_coefficient_ppm')) AS SIGNED) = 1250000
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_salary_policy_id')) AS SIGNED) = 902
		AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_income_policy_type')) = 'SALARY_DIAMOND'
		AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.income_asset_type')) = 'HOST_PERIOD_DIAMOND'`, receipt.TransactionID); got != 1 {
		t.Fatalf("combined period diamond transaction snapshot mismatch, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", `transaction_id = ? AND event_type = 'HostPeriodDiamondCredited'
		AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_type_code')) = 'lucky'
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.gift_diamond_ratio_ppm')) AS SIGNED) = 100000
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.host_salary_policy_diamond_coefficient_ppm')) AS SIGNED) = 1250000
		AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.host_income_policy_type')) = 'SALARY_DIAMOND'
		AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.income_asset_type')) = 'HOST_PERIOD_DIAMOND'`, receipt.TransactionID); got != 1 {
		t.Fatalf("combined period diamond outbox snapshot mismatch, got %d", got)
	}
}

func TestDebitGiftAppliesGlobalDefaultGiftDiamondRatioToHeatValueByGiftType(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeNormal, "30.00")
	repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeLucky, "40.00")
	repository.SetGiftDiamondRatio(8801, resourcedomain.GiftTypeSuperLucky, "50.00")

	cases := []struct {
		giftID     string
		giftType   string
		command    string
		senderID   int64
		targetID   int64
		coinPrice  int64
		heatUnit   int64
		wantHeat   int64
		wantIncome int64
	}{
		{giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, targetID: 14001, coinPrice: 100, heatUnit: 999, wantHeat: 100, wantIncome: 30},
		{giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, targetID: 14002, coinPrice: 100, heatUnit: 999, wantHeat: 10, wantIncome: 10},
		{giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, targetID: 14003, coinPrice: 200, heatUnit: 500, wantHeat: 2, wantIncome: 2},
	}

	for _, tc := range cases {
		repository.SetBalance(tc.senderID, 500)
		repository.SetGiftPrice(tc.giftID, "v1", tc.coinPrice, 100, tc.heatUnit)
		repository.SetGiftType(tc.giftID, tc.giftType)
		receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
			CommandID:      tc.command,
			RoomID:         "room-ratio",
			SenderUserID:   tc.senderID,
			SenderRegionID: 1001,
			RegionID:       8801,
			TargetUserID:   tc.targetID,
			GiftID:         tc.giftID,
			GiftCount:      1,
			PriceVersion:   "v1",
		})
		if err != nil {
			t.Fatalf("DebitGift %s failed: %v", tc.giftType, err)
		}
		if receipt.HeatValue != tc.wantHeat || receipt.GiftPointAdded != 0 || receipt.CoinSpent != tc.coinPrice {
			t.Fatalf("%s room contribution ratio mismatch: %+v want heat %d", tc.giftType, receipt, tc.wantHeat)
		}
		balances, err := svc.GetBalances(context.Background(), tc.targetID, []string{ledger.AssetCoin})
		if err != nil {
			t.Fatalf("GetBalances target income %s failed: %v", tc.giftType, err)
		}
		if balanceAmount(balances, ledger.AssetCoin) != tc.wantIncome {
			t.Fatalf("%s target income mismatch: got %+v want %d", tc.giftType, balances, tc.wantIncome)
		}
	}
}

func TestDebitGiftUsesTargetHostRegionDiamondRatioAndRegionalReturnCoinRatio(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetBalance(14501, 500)
	repository.SetGiftPrice("regional-return-gift", "v1", 100, 100, 100)
	repository.SetGiftType("regional-return-gift", resourcedomain.GiftTypeNormal)
	repository.SetGiftDiamondRatio(7701, resourcedomain.GiftTypeNormal, "80.00")
	repository.SetGiftReturnCoinRatio(7701, resourcedomain.GiftTypeNormal, "45.00")
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               907,
		Name:                   "regional gift ratio coefficient",
		RegionID:               7701,
		GiftCoinToDiamondRatio: "1",
	})

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:          "cmd-regional-return-coin",
		RoomID:             "room-regional-return",
		SenderUserID:       14501,
		SenderRegionID:     1001,
		RegionID:           7701,
		TargetUserID:       14502,
		GiftID:             "regional-return-gift",
		GiftCount:          1,
		PriceVersion:       "v1",
		TargetIsHost:       true,
		TargetHostRegionID: 7701,
	})
	if err != nil {
		t.Fatalf("DebitGift regional return failed: %v", err)
	}
	if receipt.HeatValue != 100 || receipt.HostPeriodDiamondAdded != 80 {
		t.Fatalf("host region ratio must change host diamonds without changing global room contribution: %+v", receipt)
	}
	balances, err := svc.GetBalances(context.Background(), 14502, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances target income failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 45 {
		t.Fatalf("regional return coin income mismatch: %+v", balances)
	}
}

func TestDebitGiftAllowsZeroReturnCoinRatio(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	cases := []struct {
		name     string
		giftID   string
		giftType string
		regionID int64
		senderID int64
		targetID int64
		wantHeat int64
	}{
		{name: "lucky", giftID: "zero-return-lucky-gift", giftType: resourcedomain.GiftTypeLucky, regionID: 7702, senderID: 14601, targetID: 14602, wantHeat: 10},
		{name: "super-lucky", giftID: "zero-return-super-lucky-gift", giftType: resourcedomain.GiftTypeSuperLucky, regionID: 7703, senderID: 14603, targetID: 14604, wantHeat: 1},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			repository.SetBalance(tc.senderID, 500)
			repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100)
			repository.SetGiftType(tc.giftID, tc.giftType)
			// 0% 返币是后台可配置的合法业务结果；扣费和房间贡献仍要按礼物真实价格结算。
			repository.SetGiftReturnCoinRatio(tc.regionID, tc.giftType, "0.00")

			receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
				CommandID:      "cmd-zero-return-" + tc.name,
				RoomID:         "room-zero-return-" + tc.name,
				SenderUserID:   tc.senderID,
				SenderRegionID: 1001,
				RegionID:       tc.regionID,
				TargetUserID:   tc.targetID,
				GiftID:         tc.giftID,
				GiftCount:      1,
				PriceVersion:   "v1",
			})
			if err != nil {
				t.Fatalf("DebitGift with zero return ratio failed: %v", err)
			}
			if receipt.CoinSpent != 100 || receipt.HeatValue != tc.wantHeat || receipt.BalanceAfter != 400 {
				t.Fatalf("zero return ratio settlement mismatch: %+v want heat %d", receipt, tc.wantHeat)
			}
			assertBalance(t, svc, tc.senderID, ledger.AssetCoin, 400)
			assertBalance(t, svc, tc.targetID, ledger.AssetCoin, 0)
			if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 0 {
				t.Fatalf("zero return ratio must not publish gift income event, got %d", got)
			}
		})
	}
}

func TestBatchDebitGiftAppliesGlobalDefaultGiftDiamondRatioToEachTargetHeatValue(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(15001, 1000)
	repository.SetGiftPrice("batch-room-ratio-gift", "v1", 100, 100, 999)
	repository.SetGiftType("batch-room-ratio-gift", resourcedomain.GiftTypeLucky)
	repository.SetGiftDiamondRatio(9901, resourcedomain.GiftTypeLucky, "25.00")
	svc := walletservice.New(repository)

	receipt, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
		CommandID:      "cmd-batch-room-ratio",
		RoomID:         "room-ratio",
		SenderUserID:   15001,
		SenderRegionID: 1001,
		RegionID:       9901,
		GiftID:         "batch-room-ratio-gift",
		GiftCount:      1,
		PriceVersion:   "v1",
		Targets: []ledger.DebitGiftTargetCommand{
			{CommandID: "cmd-batch-room-ratio:target:15002", TargetUserID: 15002},
			{CommandID: "cmd-batch-room-ratio:target:15003", TargetUserID: 15003},
		},
	})
	if err != nil {
		t.Fatalf("BatchDebitGift failed: %v", err)
	}
	if receipt.Aggregate.HeatValue != 20 || len(receipt.Targets) != 2 {
		t.Fatalf("batch aggregate room contribution ratio mismatch: %+v", receipt)
	}
	for _, target := range receipt.Targets {
		if target.Receipt.HeatValue != 10 || target.Receipt.GiftPointAdded != 0 {
			t.Fatalf("target room contribution ratio mismatch: %+v", target)
		}
	}
}

func TestBatchDebitGiftMissingOneTargetPolicyFailsBeforeAnyMutation(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(15101, 1000)
	repository.SetGiftPrice("batch-host-region-ratio-gift", "v1", 100, 100, 100)
	repository.SetGiftType("batch-host-region-ratio-gift", resourcedomain.GiftTypeNormal)
	repository.SetGiftDiamondRatio(7701, resourcedomain.GiftTypeNormal, "80.00")
	repository.SetGiftDiamondRatio(7702, resourcedomain.GiftTypeNormal, "90.00")
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               903,
		Name:                   "regional host coefficient",
		RegionID:               7701,
		GiftCoinToDiamondRatio: "1.5",
	})
	svc := walletservice.New(repository)

	_, err := svc.BatchDebitGift(context.Background(), ledger.BatchDebitGiftCommand{
		CommandID:      "cmd-batch-host-region-ratio",
		RoomID:         "room-host-region-ratio",
		SenderUserID:   15101,
		SenderRegionID: 1001,
		RegionID:       9901,
		GiftID:         "batch-host-region-ratio-gift",
		GiftCount:      1,
		PriceVersion:   "v1",
		Targets: []ledger.DebitGiftTargetCommand{
			{CommandID: "cmd-batch-host-region-ratio:target:15102", TargetUserID: 15102, TargetIsHost: true, TargetHostRegionID: 7701},
			{CommandID: "cmd-batch-host-region-ratio:target:15103", TargetUserID: 15103, TargetIsHost: true, TargetHostRegionID: 7702},
		},
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("one target without a current or inherited policy must reject the whole batch, got %v", err)
	}
	// 所有目标政策和倍率都在批量账户锁及库存消耗前预解析；任一目标失败不能形成半笔批量账。
	assertBalance(t, svc, 15101, ledger.AssetCoin, 1000)
	assertBalance(t, svc, 15102, ledger.AssetCoin, 0)
	assertBalance(t, svc, 15103, ledger.AssetCoin, 0)
	if got := repository.CountRows("wallet_transactions", "command_id IN (?, ?)", "cmd-batch-host-region-ratio:target:15102", "cmd-batch-host-region-ratio:target:15103"); got != 0 {
		t.Fatalf("rejected batch must not write any target transaction, got %d", got)
	}
}

func TestPointDiamondGiftCombinesHostAndAgencyIncomeForSameUser(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalanceForApp("fami", 15111, 1000)
	repository.SetGiftPriceForApp("fami", "point-diamond-self-gift", "v1", 100, 0, 100)
	repository.SetGiftDiamondRatioForApp("fami", 0, resourcedomain.GiftTypeNormal, "100.00")
	repository.SetGiftDiamondRatioForApp("fami", 31, resourcedomain.GiftTypeNormal, "100.00")
	if got := repository.CountRows("gift_diamond_ratio_configs", "app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active'", "fami", int64(31), resourcedomain.GiftTypeNormal); got != 1 {
		t.Fatalf("POINT_DIAMOND gift multiplier seed mismatch, got %d", got)
	}
	repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{
		PolicyID:                905,
		PolicyVersion:           905,
		Name:                    "point diamond self agency policy",
		PolicyType:              ledger.HostPolicyTypePointDiamond,
		RegionID:                31,
		GiftCoinToDiamondRatio:  "1",
		PointDiamondsPerUSD:     200000,
		CoinsPerUSD:             80000,
		MinimumWithdrawUSDMinor: 100,
		AgencyPointShareBPS:     2000,
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		AppCode:                 "fami",
		CommandID:               "cmd-point-diamond-self-gift",
		RoomID:                  "room-point-diamond-self-gift",
		SenderUserID:            15111,
		SenderRegionID:          31,
		RegionID:                31,
		TargetUserID:            15111,
		TargetIsHost:            true,
		TargetHostRegionID:      31,
		TargetAgencyOwnerUserID: 15111,
		GiftID:                  "point-diamond-self-gift",
		GiftCount:               1,
		PriceVersion:            "v1",
	})
	if err != nil {
		t.Fatalf("POINT_DIAMOND self gift failed: %v", err)
	}
	if receipt.HostIncomePolicyType != ledger.HostPolicyTypePointDiamond || receipt.IncomeAssetType != ledger.AssetPointDiamond || receipt.HostBaseAmount != 100 || receipt.AgencyAmount != 20 {
		t.Fatalf("POINT_DIAMOND split receipt mismatch: %+v", receipt)
	}
	assertBalanceForApp(t, svc, "fami", 15111, ledger.AssetPointDiamond, 120)
	if got := repository.CountRows("wallet_entries", `transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?`, receipt.TransactionID, int64(15111), ledger.AssetPointDiamond, int64(120)); got != 1 {
		t.Fatalf("same Host and Agency must merge into one POINT_DIAMOND entry, got %d", got)
	}
	if got := repository.CountRows("point_diamond_gift_income_entries", `transaction_id = ? AND host_user_id = ? AND agency_user_id = ? AND host_base_amount = ? AND agency_amount = ?`, receipt.TransactionID, int64(15111), int64(15111), int64(100), int64(20)); got != 1 {
		t.Fatalf("gift income projection must preserve the 100 Host + 20 Agency split, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", `transaction_id = ?
		AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_income_policy_type')) = 'POINT_DIAMOND'
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.host_base_amount')) AS SIGNED) = 100
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.agency_amount')) AS SIGNED) = 20`, receipt.TransactionID); got != 1 {
		t.Fatalf("self gift metadata must preserve Host/Agency split after merged posting, got %d", got)
	}
	if got := repository.CountRows("host_period_diamond_accounts", "app_code = ? AND user_id = ?", "fami", int64(15111)); got != 0 {
		t.Fatalf("POINT_DIAMOND policy must never create salary-cycle diamonds, got %d", got)
	}
	statsContext := appcode.WithContext(context.Background(), "fami")
	startAtMS, endAtMS := time.Now().Add(-time.Hour).UnixMilli(), time.Now().Add(time.Hour).UnixMilli()
	hostStats, err := svc.GetHostRevenueStats(statsContext, "fami", ledger.HostRevenueStatsQuery{HostUserID: 15111, StartAtMS: startAtMS, EndAtMS: endAtMS})
	if err != nil || hostStats.DiamondEarnings != 100 || hostStats.GiftSenders != 1 {
		t.Fatalf("Host stats must count only the 100 base points: stats=%+v err=%v", hostStats, err)
	}
	agencyHostStats, err := svc.GetAgencyHostGiftStats(statsContext, "fami", ledger.AgencyHostGiftStatsQuery{HostUserIDs: []int64{15111}, AgencyUserID: 15111, StartAtMS: startAtMS, EndAtMS: endAtMS})
	if err != nil || agencyHostStats.GiftIncome != 100 || agencyHostStats.PointDiamondAgencyShareIncome != 20 || agencyHostStats.GiftedHostCount != 1 {
		t.Fatalf("Agency stats must preserve the self-host 100 Host + 20 Agency split: stats=%+v err=%v", agencyHostStats, err)
	}
	transactions, total, err := svc.ListWalletTransactions(statsContext, ledger.ListWalletTransactionsQuery{AppCode: "fami", UserID: 15111, AssetType: ledger.AssetPointDiamond, Page: 1, PageSize: 20})
	if err != nil || total != 1 || len(transactions) != 1 {
		t.Fatalf("POINT_DIAMOND history query mismatch: total=%d items=%+v err=%v", total, transactions, err)
	}
	if transactions[0].EntryRole != "host_gift_income_and_agency_share" || transactions[0].AvailableDelta != 120 || transactions[0].HostBaseAmount != 100 || transactions[0].AgencyAmount != 20 {
		t.Fatalf("POINT_DIAMOND merged history split mismatch: %+v", transactions[0])
	}
	exchangeCommand := ledger.PointToCoinCommand{
		AppCode: "fami", CommandID: "cmd-point-diamond-self-exchange", UserID: 15111,
		PointAmount: 120, SourceAssetType: ledger.AssetPointDiamond, RegionID: 31, NowMS: time.Now().UnixMilli(),
	}
	exchange, err := svc.ExchangePointToCoin(context.Background(), exchangeCommand)
	if err != nil {
		t.Fatalf("exchange POINT_DIAMOND to COIN: %v", err)
	}
	if exchange.PointAmount != 120 || exchange.CoinAmount != 48 || exchange.PointBalanceAfter != 0 || exchange.RatioPointAmount != 200000 || exchange.RatioCoinAmount != 80000 || exchange.SourceAssetType != ledger.AssetPointDiamond || exchange.PolicyID != 905 || exchange.PolicyVersion != 905 {
		t.Fatalf("120 POINT_DIAMOND must exchange to 48 COIN: %+v", exchange)
	}
	replayedExchange, err := svc.ExchangePointToCoin(context.Background(), exchangeCommand)
	if err != nil || replayedExchange.TransactionID != exchange.TransactionID || replayedExchange.CoinAmount != 48 || replayedExchange.PolicyID != 905 || replayedExchange.PolicyVersion != 905 {
		t.Fatalf("POINT_DIAMOND exchange replay mismatch: replay=%+v err=%v", replayedExchange, err)
	}
	requireWalletBalanceForApp(t, svc, "fami", 15111, ledger.AssetPointDiamond, 0, 0)
	requireWalletBalanceForApp(t, svc, "fami", 15111, ledger.AssetCoin, 978, 0)
}

// TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。
func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "default", 10, 10, 10)
	svc := walletservice.New(repository)

	_, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:    "cmd-gift-host-no-region",
		RoomID:       "room-1",
		SenderUserID: 10001,
		TargetUserID: 10002,
		GiftID:       "rose",
		GiftCount:    1,
		TargetIsHost: true,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("expected INVALID_ARGUMENT, got %v", err)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-gift-host-no-region"); got != 0 {
		t.Fatalf("invalid host period command must not write transaction, got %d", got)
	}
}

// TestGetActiveHostSalaryPolicyReadsRuntimePolicy 验证 H5 展示读取的是工资结算 runtime 表里的生效政策和档位。
func TestGetActiveHostSalaryPolicyReadsRuntimePolicy(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	nowMs := time.Now().UnixMilli()
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:                 901,
		Name:                     "current host policy",
		RegionID:                 8801,
		Status:                   "active",
		SettlementMode:           ledger.HostSalarySettlementModeHalfMonth,
		SettlementTriggerMode:    ledger.HostSalarySettlementTriggerManual,
		GiftCoinToDiamondRatio:   "1.0000",
		ResidualDiamondToUSDRate: "0.0100",
		EffectiveFromMs:          nowMs - 1000,
		EffectiveToMs:            0,
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUSDMinor: 1500, HostCoinReward: 200, AgencySalaryUSDMinor: 300, SortOrder: 1},
			{LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUSDMinor: 4500, HostCoinReward: 500, AgencySalaryUSDMinor: 900, SortOrder: 2},
		},
	})
	svc := walletservice.New(repository)

	cycleKey := time.UnixMilli(nowMs).UTC().Format("2006-01")
	policy, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", "", cycleKey, nowMs)
	if err != nil {
		t.Fatalf("GetActiveHostSalaryPolicy failed: %v", err)
	}
	if !found {
		t.Fatal("expected active host salary policy")
	}
	if policy.PolicyID != 901 || policy.PolicyVersion != 901 || policy.CycleKey != cycleKey || policy.Name != "current host policy" || policy.SettlementMode != ledger.HostSalarySettlementModeHalfMonth || policy.RegionID != 8801 {
		t.Fatalf("policy mismatch: %+v", policy)
	}
	if policy.SettlementTriggerMode != ledger.HostSalarySettlementTriggerManual {
		t.Fatalf("policy trigger mode mismatch: %+v", policy)
	}
	if len(policy.Levels) != 2 || policy.Levels[1].LevelNo != 2 || policy.Levels[1].HostSalaryUSDMinor != 4500 || policy.Levels[1].AgencySalaryUSDMinor != 900 {
		t.Fatalf("policy levels mismatch: %+v", policy.Levels)
	}
	if _, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", ledger.HostSalarySettlementTriggerAutomatic, cycleKey, nowMs); err != nil {
		t.Fatalf("GetActiveHostSalaryPolicy with trigger failed: %v", err)
	} else if found {
		t.Fatal("automatic trigger query should not match manual policy")
	}
}

// TestGetHostSalaryProgressReadsCurrentCycleDiamonds 验证 H5 等级进度读取工资周期钻石账户，查询不到时保持 0 累计。
func TestGetHostSalaryProgressReadsCurrentCycleDiamonds(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "salary-progress", 14, 10, 10)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               908,
		Name:                   "salary progress coefficient",
		RegionID:               8801,
		GiftCoinToDiamondRatio: "1",
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:               "cmd-salary-progress",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            13002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-progress",
		SenderRegionID:          8801,
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("salary gift failed: %v", err)
	}

	progress, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 13002, receipt.HostPeriodCycleKey, 0)
	if err != nil {
		t.Fatalf("GetHostSalaryProgress failed: %v", err)
	}
	if progress.HostUserID != 13002 || progress.CycleKey != receipt.HostPeriodCycleKey || progress.RegionID != 8801 || progress.AgencyOwnerUserID != 30001 || progress.TotalDiamonds != 14 || progress.GiftDiamondTotal != 14 {
		t.Fatalf("host salary progress mismatch: %+v", progress)
	}

	missing, err := svc.GetHostSalaryProgress(context.Background(), "lalu", 99999, "2026-06", 0)
	if err != nil {
		t.Fatalf("GetHostSalaryProgress missing account failed: %v", err)
	}
	if missing.HostUserID != 99999 || missing.CycleKey != "2026-06" || missing.TotalDiamonds != 0 {
		t.Fatalf("missing progress must return zero account: %+v", missing)
	}
}

// TestHostSalarySettlementCreditsIncrementalRewards 走真实送礼入钻石账户后，验证日结只发放等级累计差额。
func TestHostSalarySettlementCreditsIncrementalRewards(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 1000)
	repository.SetGiftPrice("rose", "salary", 200, 10, 10)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               101,
		Name:                   "daily salary",
		RegionID:               8801,
		Status:                 "active",
		SettlementMode:         ledger.HostSalarySettlementModeDaily,
		GiftCoinToDiamondRatio: "1",
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
			{LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2},
		},
	})
	svc := walletservice.New(repository)

	first, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:               "cmd-salary-gift-1",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("first salary gift failed: %v", err)
	}
	result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-run-1",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       first.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("first salary settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("first settlement result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
	// 主播 COIN 同时包含普通礼物 30% 返币 60 和等级金币奖励 90。
	assertBalance(t, svc, 10002, ledger.AssetCoin, 150)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)

	second, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:               "cmd-salary-gift-2",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("second salary gift failed: %v", err)
	}
	if second.HostPeriodCycleKey != first.HostPeriodCycleKey {
		t.Fatalf("salary gifts should be in same cycle: first=%q second=%q", first.HostPeriodCycleKey, second.HostPeriodCycleKey)
	}
	result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-run-2",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       first.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("second salary settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("second settlement result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300)
	// 两笔礼物累计返币 120；等级金币奖励按累计权益补到 198。
	assertBalance(t, svc, 10002, ledger.AssetCoin, 318)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80)
	if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ?", int64(10002), first.HostPeriodCycleKey); got != 2 {
		t.Fatalf("salary settlement records mismatch, got %d", got)
	}

	result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-run-3",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       first.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("noop salary settlement failed: %v", err)
	}
	if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
		t.Fatalf("settlement without new diamonds should not claim rows: %+v", result)
	}
}

// TestHostSalaryManualPolicyRequiresManualTrigger 验证手动政策不会被 cron 默认自动结算，必须由后台人工入口显式传 manual。
func TestHostSalaryManualPolicyRequiresManualTrigger(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 500)
	repository.SetGiftPrice("rose", "salary-manual", 200, 10, 10)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:              105,
		Name:                  "manual salary",
		RegionID:              8801,
		Status:                "active",
		SettlementMode:        ledger.HostSalarySettlementModeDaily,
		SettlementTriggerMode: ledger.HostSalarySettlementTriggerManual,
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
		},
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:               "cmd-salary-manual-gift",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-manual",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("manual salary gift failed: %v", err)
	}

	result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-manual-auto",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("automatic scan for manual policy failed: %v", err)
	}
	if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
		t.Fatalf("manual policy must not be claimed by automatic scan: %+v", result)
	}

	result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-manual-explicit",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		TriggerMode:    ledger.HostSalarySettlementTriggerManual,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("manual salary settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 {
		t.Fatalf("manual settlement result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
}

// TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets 验证后台人工结算按身份拆分钱包，Agency 行不会顺手发 Host 工资。
func TestHostSalaryManualSettlementRoleSplitsHostAndAgencyWallets(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 1000)
	repository.SetGiftPrice("rose", "salary-role-split", 120, 10, 10)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               303,
		Name:                   "manual role split salary",
		RegionID:               8801,
		Status:                 "active",
		SettlementMode:         ledger.HostSalarySettlementModeDaily,
		SettlementTriggerMode:  ledger.HostSalarySettlementTriggerManual,
		GiftCoinToDiamondRatio: "1",
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
		},
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:               "cmd-role-split-gift",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-role-split",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("role split gift failed: %v", err)
	}

	agencyResult, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "role-split-agency",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		TriggerMode:    ledger.HostSalarySettlementTriggerManual,
		SettlementRole: "agency",
		CycleKey:       receipt.HostPeriodCycleKey,
		UserIDs:        []int64{10002},
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("agency-only settlement failed: %v", err)
	}
	if agencyResult.ProcessedCount != 1 || agencyResult.SuccessCount != 1 || agencyResult.FailureCount != 0 {
		t.Fatalf("agency-only result mismatch: %+v", agencyResult)
	}
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 0)
	// Agency-only 结算不能发 Host 等级奖励，但送礼事务已独立发放普通礼物 30% 返币。
	assertBalance(t, svc, 10002, ledger.AssetCoin, 36)
	if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND settlement_role = ? AND agency_salary_usd_minor_delta = ? AND host_salary_usd_minor_delta = 0", int64(10002), receipt.HostPeriodCycleKey, "agency", int64(50)); got != 1 {
		t.Fatalf("agency-only record mismatch, got %d", got)
	}
	if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND settled_agency_salary_usd_minor = ? AND settled_host_salary_usd_minor = 0 AND last_settled_total_diamonds = 0", int64(10002), receipt.HostPeriodCycleKey, int64(50)); got != 1 {
		t.Fatalf("agency-only progress should not close host side, got %d", got)
	}

	hostResult, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "role-split-host",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		TriggerMode:    ledger.HostSalarySettlementTriggerManual,
		SettlementRole: "host",
		CycleKey:       receipt.HostPeriodCycleKey,
		UserIDs:        []int64{10002},
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("host-only settlement failed: %v", err)
	}
	if hostResult.ProcessedCount != 1 || hostResult.SuccessCount != 1 || hostResult.FailureCount != 0 {
		t.Fatalf("host-only result mismatch: %+v", hostResult)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
	assertBalance(t, svc, 10002, ledger.AssetCoin, 126)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
	if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND settlement_role = ? AND host_salary_usd_minor_delta = ? AND agency_salary_usd_minor_delta = 0", int64(10002), receipt.HostPeriodCycleKey, "host", int64(150)); got != 1 {
		t.Fatalf("host-only record mismatch, got %d", got)
	}
	if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND settled_host_salary_usd_minor = ? AND settled_host_coin_reward = ? AND settled_agency_salary_usd_minor = ? AND last_settled_total_diamonds = ?", int64(10002), receipt.HostPeriodCycleKey, int64(150), int64(90), int64(50), receipt.HostPeriodDiamondAdded); got != 1 {
		t.Fatalf("split settlement should close progress after both roles are paid, got %d", got)
	}
}

// TestHostSalaryMonthEndCreditsResidualAndClearsCycle 验证月底结算补发剩余钻石折美元，并逻辑关闭当月周期。
func TestHostSalaryMonthEndCreditsResidualAndClearsCycle(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 1000)
	repository.SetGiftPrice("rose", "salary-residual", 350, 10, 10)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:                 102,
		Name:                     "daily residual",
		RegionID:                 8801,
		Status:                   "active",
		SettlementMode:           ledger.HostSalarySettlementModeDaily,
		GiftCoinToDiamondRatio:   "1",
		ResidualDiamondToUSDRate: "0.01",
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
			{LevelNo: 2, RequiredDiamonds: 500, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2},
		},
	})
	svc := walletservice.New(repository)

	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:               "cmd-salary-residual-gift",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-residual",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("residual salary gift failed: %v", err)
	}
	if _, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-residual-daily",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	}); err != nil {
		t.Fatalf("daily residual salary settlement failed: %v", err)
	}
	result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-residual-month-end",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("month-end residual salary settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("month-end settlement result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 400)
	if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), receipt.HostPeriodCycleKey, int64(250)); got != 1 {
		t.Fatalf("month-end progress should store residual and cleared marker, got %d", got)
	}
	result, err = svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-residual-after-clear",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("after-clear salary settlement failed: %v", err)
	}
	if result.ClaimedCount != 0 {
		t.Fatalf("cleared cycle must not be claimed again: %+v", result)
	}
}

// TestHostSalaryMonthEndUsesBoundPreviousCyclePolicy 验证次月执行上月月结时只能读取上月绑定版本，不能使用执行月份的新政策。
func TestHostSalaryMonthEndUsesBoundPreviousCyclePolicy(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 500)
	repository.SetGiftPrice("rose", "salary-cycle-version", 200, 10, 10)
	previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01")
	currentCycle := time.Now().UTC().Format("2006-01")
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:       401,
		CycleKey:       previousCycle,
		PolicyVersion:  401,
		Name:           "previous cycle policy",
		RegionID:       8801,
		Status:         "active",
		SettlementMode: ledger.HostSalarySettlementModeDaily,
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, SortOrder: 1},
		},
	})
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:       402,
		CycleKey:       currentCycle,
		PolicyVersion:  402,
		Name:           "current cycle policy",
		RegionID:       8801,
		Status:         "active",
		SettlementMode: ledger.HostSalarySettlementModeDaily,
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 900, SortOrder: 1},
		},
	})
	svc := walletservice.New(repository)
	receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:          "cmd-salary-cycle-version",
		RoomID:             "room-1",
		SenderUserID:       10001,
		TargetUserID:       10002,
		GiftID:             "rose",
		GiftCount:          1,
		PriceVersion:       "salary-cycle-version",
		TargetIsHost:       true,
		TargetHostRegionID: 8801,
	})
	if err != nil {
		t.Fatalf("cycle version gift failed: %v", err)
	}
	repository.MoveHostPeriodCycle(10002, receipt.HostPeriodCycleKey, previousCycle)

	result, err := svc.ProcessHostSalarySettlementBatch(context.Background(), ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "salary-cycle-version-month-end",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
		CycleKey:       previousCycle,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("previous cycle month-end settlement failed: %v", err)
	}
	if result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("previous cycle month-end result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
	if got := repository.CountRows("host_salary_settlement_records", "user_id = ? AND cycle_key = ? AND policy_id = ? AND policy_version = ? AND host_salary_usd_minor_delta = ?", int64(10002), previousCycle, uint64(401), uint64(401), int64(150)); got != 1 {
		t.Fatalf("month-end must persist previous cycle policy version, got %d", got)
	}
}

// TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle 走通“已发布政策 -> 主播收礼 -> 多次增量结算 -> 月底清算 -> 下周期重新起算”的真实账务链路。
func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 2000)
	repository.SetGiftPrice("rose", "salary-full-lifecycle", 120, 10, 10)
	previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01")
	currentCycle := time.Now().UTC().Format("2006-01")
	levels := []ledger.HostSalaryPolicyLevel{
		{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
		{LevelNo: 2, RequiredDiamonds: 400, HostSalaryUSDMinor: 300, HostCoinReward: 198, AgencySalaryUSDMinor: 80, SortOrder: 2},
	}
	// 每个月分别发布不可变版本；相同金额也不能跨周期复用同一条 runtime 政策。
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:                 301,
		CycleKey:                 previousCycle,
		Name:                     "previous cycle daily salary",
		RegionID:                 8801,
		Status:                   "active",
		SettlementMode:           ledger.HostSalarySettlementModeDaily,
		GiftCoinToDiamondRatio:   "1",
		ResidualDiamondToUSDRate: "0.01",
		Levels:                   levels,
	})
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:                 304,
		CycleKey:                 currentCycle,
		Name:                     "current cycle daily salary",
		RegionID:                 8801,
		Status:                   "active",
		SettlementMode:           ledger.HostSalarySettlementModeDaily,
		GiftCoinToDiamondRatio:   "1",
		ResidualDiamondToUSDRate: "0.01",
		Levels:                   levels,
	})
	svc := walletservice.New(repository)
	ctx := context.Background()

	first, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:               "cmd-full-lifecycle-gift-1",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-full-lifecycle",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("first lifecycle gift failed: %v", err)
	}
	if first.HostPeriodDiamondAdded != 120 {
		t.Fatalf("first gift should add host diamonds: %+v", first)
	}
	// DebitGift 使用真实当前月份入账；测试把真实送礼产生的账户移动到上月，模拟月底清算前的完整历史周期。
	repository.MoveHostPeriodCycle(10002, first.HostPeriodCycleKey, previousCycle)

	result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "full-lifecycle-daily-level-1",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       previousCycle,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("level 1 daily settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("level 1 daily settlement result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 150)
	// 首笔礼物返币 36，达到 1 级后另发等级金币奖励 90。
	assertBalance(t, svc, 10002, ledger.AssetCoin, 126)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 50)
	previousIDs := repository.HostSalarySettlementIDs(10002, previousCycle)
	if len(previousIDs) != 1 || previousIDs[0] == "" {
		t.Fatalf("level 1 settlement id missing: %v", previousIDs)
	}
	t.Logf("settlement ids after level 1: %v", previousIDs)

	result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "full-lifecycle-daily-repeat",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       previousCycle,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("repeat daily settlement failed: %v", err)
	}
	if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
		t.Fatalf("repeat daily settlement should not claim unchanged account: %+v", result)
	}
	if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) {
		t.Fatalf("repeat daily settlement must not create record: before=%v after=%v", previousIDs, ids)
	}

	second, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:               "cmd-full-lifecycle-gift-2",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               3,
		PriceVersion:            "salary-full-lifecycle",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("second lifecycle gift failed: %v", err)
	}
	if second.HostPeriodDiamondAdded != 360 {
		t.Fatalf("second gift should add host diamonds: %+v", second)
	}
	repository.MoveHostPeriodCycle(10002, second.HostPeriodCycleKey, previousCycle)

	result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "full-lifecycle-daily-level-2",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       previousCycle,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("level 2 daily settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("level 2 daily settlement result mismatch: %+v", result)
	}
	// 第二次只补发累计 2 级与已发 1 级之间的差额，最终余额等于 2 级累计权益。
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 300)
	// 第二笔三连礼返币 108，等级奖励再增量补发 108，累计 342。
	assertBalance(t, svc, 10002, ledger.AssetCoin, 342)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 80)
	previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle)
	if len(previousIDs) != 2 {
		t.Fatalf("level 2 settlement should create second record: %v", previousIDs)
	}
	t.Logf("settlement ids after level 2: %v", previousIDs)

	result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "full-lifecycle-month-end",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
		CycleKey:       previousCycle,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("month-end lifecycle settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("month-end lifecycle result mismatch: %+v", result)
	}
	// 480 钻石达到 2 级后剩余 80 钻石，按 0.01 USD/diamond 折算为 80 美分并标记周期已清算。
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 380)
	if got := repository.CountRows("host_salary_settlement_progress", "user_id = ? AND cycle_key = ? AND last_settled_total_diamonds = ? AND residual_usd_minor = ? AND month_end_cleared_at_ms > 0", int64(10002), previousCycle, int64(480), int64(80)); got != 1 {
		t.Fatalf("month-end progress should store residual and clear marker, got %d", got)
	}
	previousIDs = repository.HostSalarySettlementIDs(10002, previousCycle)
	if len(previousIDs) != 3 {
		t.Fatalf("month-end settlement should create third record: %v", previousIDs)
	}
	t.Logf("settlement ids after month-end clear: %v", previousIDs)

	result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "full-lifecycle-month-end-repeat",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementTypeMonthEnd,
		CycleKey:       previousCycle,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("repeat month-end settlement failed: %v", err)
	}
	if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
		t.Fatalf("cleared cycle must not be claimed by repeat month-end: %+v", result)
	}
	if ids := repository.HostSalarySettlementIDs(10002, previousCycle); len(ids) != len(previousIDs) {
		t.Fatalf("repeat month-end must not create record: before=%v after=%v", previousIDs, ids)
	}

	third, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:               "cmd-full-lifecycle-gift-next-cycle",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10002,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-full-lifecycle",
		TargetIsHost:            true,
		TargetHostRegionID:      8801,
		TargetAgencyOwnerUserID: 30001,
	})
	if err != nil {
		t.Fatalf("next-cycle gift failed: %v", err)
	}
	result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "full-lifecycle-next-cycle-level-1",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       third.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("next-cycle daily settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("next-cycle daily result mismatch: %+v", result)
	}
	// 新 cycle_key 使用独立进度，因此月末清算后的下一周期从 1 级累计权益重新开始发放。
	assertBalance(t, svc, 10002, ledger.AssetHostSalaryUSD, 530)
	// 新周期再收礼返币 36，并重新获得 1 级金币奖励 90。
	assertBalance(t, svc, 10002, ledger.AssetCoin, 468)
	assertBalance(t, svc, 30001, ledger.AssetAgencySalaryUSD, 130)
	nextIDs := repository.HostSalarySettlementIDs(10002, third.HostPeriodCycleKey)
	if len(nextIDs) != 1 || nextIDs[0] == "" {
		t.Fatalf("next-cycle settlement id missing: %v", nextIDs)
	}
	t.Logf("settlement ids after next cycle level 1: %v", nextIDs)
}

// TestHostSalaryHalfMonthSettlementUsesPolicyMode 验证半月结政策不会被日结批次误结算，只会由 half-month 批次处理。
func TestHostSalaryHalfMonthSettlementUsesPolicyMode(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 500)
	repository.SetGiftPrice("rose", "salary-half-month", 200, 10, 10)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               302,
		Name:                   "published half-month salary",
		RegionID:               8802,
		Status:                 "active",
		SettlementMode:         ledger.HostSalarySettlementModeHalfMonth,
		GiftCoinToDiamondRatio: "1",
		Levels: []ledger.HostSalaryPolicyLevel{
			{LevelNo: 1, RequiredDiamonds: 100, HostSalaryUSDMinor: 150, HostCoinReward: 90, AgencySalaryUSDMinor: 50, SortOrder: 1},
		},
	})
	svc := walletservice.New(repository)
	ctx := context.Background()

	receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:               "cmd-half-month-policy-gift",
		RoomID:                  "room-1",
		SenderUserID:            10001,
		TargetUserID:            10003,
		GiftID:                  "rose",
		GiftCount:               1,
		PriceVersion:            "salary-half-month",
		TargetIsHost:            true,
		TargetHostRegionID:      8802,
		TargetAgencyOwnerUserID: 30002,
	})
	if err != nil {
		t.Fatalf("half-month gift failed: %v", err)
	}
	result, err := svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "half-month-daily-should-skip",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeDaily,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("daily settlement for half-month policy failed: %v", err)
	}
	if result.ClaimedCount != 0 || result.ProcessedCount != 0 {
		t.Fatalf("daily batch must skip half-month policy: %+v", result)
	}

	result, err = svc.ProcessHostSalarySettlementBatch(ctx, ledger.HostSalarySettlementBatchCommand{
		AppCode:        "lalu",
		RunID:          "half-month-policy-settlement",
		WorkerID:       "salary-test",
		BatchSize:      10,
		SettlementType: ledger.HostSalarySettlementModeHalfMonth,
		CycleKey:       receipt.HostPeriodCycleKey,
		NowMs:          time.Now().UnixMilli(),
	})
	if err != nil {
		t.Fatalf("half-month settlement failed: %v", err)
	}
	if result.ProcessedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
		t.Fatalf("half-month settlement result mismatch: %+v", result)
	}
	assertBalance(t, svc, 10003, ledger.AssetHostSalaryUSD, 150)
	// 普通礼物返币 60 与半月结等级金币奖励 90 共存。
	assertBalance(t, svc, 10003, ledger.AssetCoin, 150)
	assertBalance(t, svc, 30002, ledger.AssetAgencySalaryUSD, 50)
	if ids := repository.HostSalarySettlementIDs(10003, receipt.HostPeriodCycleKey); len(ids) != 1 || ids[0] == "" {
		t.Fatalf("half-month settlement id missing: %v", ids)
	}
}

// TestDebitGiftAllowsSelfGift 验证用户可以给自己送礼；同一 COIN 账户保留扣费和收礼收入两条分录，但余额事件只发净变更。
func TestDebitGiftAllowsSelfGift(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "v9", 7, 3, 11)
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:               905,
		Name:                   "self host gift coefficient",
		RegionID:               1001,
		GiftCoinToDiamondRatio: "1",
	})
	svc := walletservice.New(repository)

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

	// 公会长同时也是主播并给自己送礼时，金币账户仍按扣 14、返 4 结算；
	// 周期钻石只按 14 金币 × normal 100% × Host 系数 1 累计，不产生 POINT。
	if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.HostPointAdded != 0 || receipt.HostPeriodDiamondAdded != 14 || receipt.BalanceAfter != 90 {
		t.Fatalf("self gift settlement mismatch: %+v", receipt)
	}
	balances, err := svc.GetBalances(context.Background(), 10001, []string{ledger.AssetCoin, ledger.AssetGiftPoint})
	if err != nil {
		t.Fatalf("GetBalances failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 90 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
		t.Fatalf("self gift balances mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 2 {
		t.Fatalf("self gift should write debit and income entries, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletBalanceChanged"); got != 1 {
		t.Fatalf("self gift should publish one net balance event, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ?", receipt.TransactionID, "WalletBalanceChanged", int64(-10)); got != 1 {
		t.Fatalf("self gift balance event should carry net -10 delta, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
		t.Fatalf("self gift should still publish income fact, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "HostPeriodDiamondCredited"); got != 1 {
		t.Fatalf("self host gift should publish one period-diamond fact, got %d", got)
	}
	if got := repository.HostPeriodGiftDiamondTotal(10001); got != 14 {
		t.Fatalf("self host gift period diamonds mismatch, got %d", got)
	}
}

// TestGetUserGiftWallAggregatesSettledGifts 验证礼物墙只统计扣费成功后的收礼事实。
func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 1000)
	repository.SetGiftPrice("rose", "v9", 7, 3, 11)
	repository.SetGiftPrice("rocket", "v2", 100, 50, 200)
	svc := walletservice.New(repository)

	commands := []ledger.DebitGiftCommand{
		{CommandID: "cmd-wall-rose-1", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 2, PriceVersion: "v9"},
		{CommandID: "cmd-wall-rose-2", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rose", GiftCount: 3, PriceVersion: "v9"},
		{CommandID: "cmd-wall-rocket", RoomID: "room-1", SenderUserID: 10001, TargetUserID: 10002, GiftID: "rocket", GiftCount: 1, PriceVersion: "v2"},
	}
	for _, command := range commands {
		if _, err := svc.DebitGift(context.Background(), command); err != nil {
			t.Fatalf("DebitGift %s failed: %v", command.CommandID, err)
		}
	}
	if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 {
		t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got)
	}
	records, err := repository.ClaimPendingWalletOutbox(context.Background(), "gift-wall-mq-test", 20, time.Now().Add(time.Minute).UnixMilli())
	if err != nil {
		t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
	}
	processed := 0
	for _, record := range records {
		handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute)
		if err != nil {
			t.Fatalf("ProcessWalletProjectionMessage failed: %v", err)
		}
		if handled {
			processed++
		}
	}
	if processed != 3 {
		t.Fatalf("projection should process three gift events, got %d", processed)
	}
	processed = 0
	for _, record := range records {
		handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute)
		if err != nil {
			t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err)
		}
		if handled {
			processed++
		}
	}
	if processed != 0 {
		t.Fatalf("projection must be idempotent after done events, got %d", processed)
	}

	wall, err := svc.GetUserGiftWall(context.Background(), 10002)
	if err != nil {
		t.Fatalf("GetUserGiftWall failed: %v", err)
	}
	if wall.GiftKindCount != 2 || wall.GiftTotalCount != 6 || wall.TotalValue != 135 || wall.TotalCoinValue != 135 || wall.TotalDiamondValue != 0 || wall.TotalGiftPoint != 0 || wall.TotalHeatValue != 135 {
		t.Fatalf("gift wall summary mismatch: %+v", wall)
	}
	rose, ok := giftWallItemByID(wall.Items, "rose")
	if len(wall.Items) != 2 || !ok || rose.GiftCount != 5 || rose.TotalValue != 35 {
		t.Fatalf("rose gift wall item mismatch: %+v", wall.Items)
	}
	if rose.Resource.ResourceID == 0 || rose.ResourceSnapshotJSON == "" {
		t.Fatalf("gift wall should keep display resource snapshot: %+v", rose)
	}
	if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 2 {
		t.Fatalf("gift wall should keep one row per gift_id, got %d", got)
	}
	if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "user_gift_wall", "done"); got != 3 {
		t.Fatalf("projection should mark each gift event done, got %d", got)
	}
}

// TestDebitGiftIsIdempotent 验证扣费命令重复提交时返回原始回执且不重复落账。
func TestDebitGiftIsIdempotent(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "default", 10, 10, 10)
	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)
	}
	// 模拟旧版本 transaction metadata 未写 paid_at_ms；重放仍必须读取 wallet_transactions.created_at_ms。
	repository.RemoveGiftPaidAtMetadata(command.CommandID)
	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)
	}
	transactionCreatedAtMS := repository.TransactionCreatedAtMS(command.CommandID)
	if first.PaidAtMS <= 0 || first.PaidAtMS != transactionCreatedAtMS || second.PaidAtMS != transactionCreatedAtMS {
		// paid_at_ms 必须来自首次 wallet transaction，而不是每次 RPC 重放的服务时钟。
		t.Fatalf("gift paid_at_ms is not stable wallet fact: created=%d first=%d second=%d", transactionCreatedAtMS, first.PaidAtMS, second.PaidAtMS)
	}
	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)
	repository.SetGiftPrice("rose", "default", 10, 10, 10)
	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)
	repository.SetGiftPrice("rose", "default", 10, 10, 10)
	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)
	}
}

func TestDebitRobotGiftCreatesRobotCoinAccountAndAutoTopsUp(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetGiftPrice("robot-rose", "default", 10, 10, 10)
	svc := walletservice.New(repository)

	receipt, err := svc.DebitRobotGift(context.Background(), ledger.DebitGiftCommand{
		CommandID:    "cmd-robot-gift-new-account",
		RoomID:       "robot-room-1",
		SenderUserID: 20001,
		TargetUserID: 20002,
		GiftID:       "robot-rose",
		GiftCount:    2,
		AppCode:      "lalu",
	})
	if err != nil {
		t.Fatalf("DebitRobotGift should auto fund missing ROBOT_COIN account: %v", err)
	}
	if receipt.ChargeAssetType != ledger.AssetRobotCoin || receipt.CoinSpent != 20 || receipt.BalanceAfter != 0 {
		t.Fatalf("robot gift receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("wallet_accounts", "user_id = ? AND asset_type = ? AND available_amount = 0", int64(20001), ledger.AssetRobotCoin); got != 1 {
		t.Fatalf("robot gift should create empty ROBOT_COIN account after debit, got %d", got)
	}
	if got := repository.CountRows("wallet_accounts", "user_id = ? AND asset_type = ?", int64(20002), ledger.AssetCoin); got != 0 {
		t.Fatalf("robot gift must not create target COIN income account, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, int64(20001), ledger.AssetRobotCoin); got != 2 {
		t.Fatalf("robot gift should write top-up and debit entries, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 0 {
		t.Fatalf("robot gift should not publish private wallet 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",
		Remark:         "finance ticket approved",
		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)
	}
	if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.remark')) = ?", txID, "finance ticket approved"); got != 1 {
		t.Fatalf("admin credit metadata should carry remark, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.metadata.remark')) = ?", txID, "WalletBalanceChanged", "finance ticket approved"); got != 1 {
		t.Fatalf("admin credit outbox payload should carry remark, got %d", got)
	}

	conflict := command
	conflict.Remark = "different finance ticket"
	if _, _, err := svc.AdminCreditAsset(context.Background(), conflict); !xerr.IsCode(err, xerr.LedgerConflict) {
		t.Fatalf("same command with different remark should conflict, got %v", err)
	}
}

// TestAdminCreditAssetKeepsLegacyIdempotencyWithoutRemark 验证未传 remark 的旧调用仍沿用原幂等哈希。
func TestAdminCreditAssetKeepsLegacyIdempotencyWithoutRemark(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-admin-credit-legacy",
		TargetUserID:   20003,
		AssetType:      ledger.AssetCoin,
		Amount:         300,
		OperatorUserID: 90001,
		Reason:         "legacy manual recharge",
		EvidenceRef:    "ticket-legacy",
	}

	first, txID, err := svc.AdminCreditAsset(context.Background(), command)
	if err != nil {
		t.Fatalf("legacy AdminCreditAsset failed: %v", err)
	}
	second, secondTxID, err := svc.AdminCreditAsset(context.Background(), command)
	if err != nil {
		t.Fatalf("legacy AdminCreditAsset retry failed: %v", err)
	}
	if txID == "" || txID != secondTxID || first.AvailableAmount != 300 || second.AvailableAmount != 300 {
		t.Fatalf("legacy idempotency mismatch: first=%+v second=%+v tx=%s/%s", first, second, txID, secondTxID)
	}
	if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.remark')) = ?", txID, ""); got != 1 {
		t.Fatalf("legacy admin credit metadata should keep empty remark, got %d", got)
	}

	withRemark := command
	withRemark.Remark = "added after legacy request"
	if _, _, err := svc.AdminCreditAsset(context.Background(), withRemark); !xerr.IsCode(err, xerr.LedgerConflict) {
		t.Fatalf("legacy command retried with remark should conflict, got %v", err)
	}
}

// TestAdminCreditAssetAllowsSignedAdjustment 验证后台调账同一 RPC 可表达加金币和扣金币，且扣账不能穿透余额约束。
func TestAdminCreditAssetAllowsSignedAdjustment(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	credit := ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-admin-adjust-credit",
		TargetUserID:   20002,
		AssetType:      ledger.AssetCoin,
		Amount:         500,
		OperatorUserID: 90001,
		Reason:         "manual credit",
		EvidenceRef:    "ticket-credit",
	}
	if _, _, err := svc.AdminCreditAsset(context.Background(), credit); err != nil {
		t.Fatalf("credit adjustment failed: %v", err)
	}

	debit := credit
	debit.CommandID = "cmd-admin-adjust-debit"
	debit.Amount = -120
	debit.Reason = "manual debit"
	debit.EvidenceRef = "ticket-debit"
	balance, txID, err := svc.AdminCreditAsset(context.Background(), debit)
	if err != nil {
		t.Fatalf("debit adjustment failed: %v", err)
	}
	if txID == "" || balance.AvailableAmount != 380 {
		t.Fatalf("debit balance mismatch: balance=%+v tx=%s", balance, txID)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", txID, int64(-120)); got != 1 {
		t.Fatalf("debit adjustment should write one negative entry, got %d", got)
	}

	overdraw := credit
	overdraw.CommandID = "cmd-admin-adjust-overdraw"
	overdraw.Amount = -1000
	overdraw.Reason = "manual overdraw"
	overdraw.EvidenceRef = "ticket-overdraw"
	if _, _, err := svc.AdminCreditAsset(context.Background(), overdraw); err == nil {
		t.Fatalf("overdraw adjustment must fail")
	}
}

func TestRedPacketLifecycleUsesRealMySQL(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(19001, 1000)
	svc := walletservice.New(repository)
	ctx := context.Background()

	config, err := svc.UpdateRedPacketConfig(ctx, ledger.RedPacketConfig{
		AppCode:            "lalu",
		Enabled:            true,
		CountTiers:         []int32{5},
		AmountTiers:        []int64{10},
		DelayedOpenSeconds: 1,
		ExpireSeconds:      2,
		DailySendLimit:     5,
		UpdatedByAdminID:   9001,
	})
	if err != nil {
		t.Fatalf("UpdateRedPacketConfig failed: %v", err)
	}
	if !config.Enabled || len(config.CountTiers) != 1 || len(config.AmountTiers) != 1 {
		t.Fatalf("red packet config mismatch: %+v", config)
	}

	normal, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{
		AppCode:      "lalu",
		CommandID:    "cmd-red-packet-normal",
		SenderUserID: 19001,
		RoomID:       "room-190",
		RegionID:     190,
		PacketType:   ledger.RedPacketTypeNormal,
		TotalAmount:  10,
		PacketCount:  5,
	})
	if err != nil {
		t.Fatalf("CreateRedPacket normal failed: %v", err)
	}
	if normal.BalanceAfter != 990 || normal.Packet.Status != ledger.RedPacketStatusActive {
		t.Fatalf("normal red packet receipt mismatch: %+v", normal)
	}
	normalClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
		AppCode:   "lalu",
		CommandID: "cmd-red-packet-normal-claim",
		PacketID:  normal.Packet.PacketID,
		UserID:    19002,
	})
	if err != nil {
		t.Fatalf("ClaimRedPacket normal failed: %v", err)
	}
	normalAfterClaim, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 19002, true)
	if err != nil {
		t.Fatalf("GetRedPacket normal failed: %v", err)
	}
	if !normalAfterClaim.ClaimedByViewer || normalAfterClaim.ViewerClaimAmount != normalClaim.Claim.Amount {
		t.Fatalf("normal viewer claim mismatch: packet=%+v claim=%+v", normalAfterClaim, normalClaim)
	}
	time.Sleep(2100 * time.Millisecond)
	expired, err := svc.ExpireRedPackets(ctx, "lalu", 10)
	if err != nil {
		t.Fatalf("ExpireRedPackets normal failed: %v", err)
	}
	if expired.ExpiredCount != 1 || expired.RefundedAmount != normalAfterClaim.RemainingAmount {
		t.Fatalf("normal expire result mismatch: %+v remaining=%d", expired, normalAfterClaim.RemainingAmount)
	}
	normalExpired, err := svc.GetRedPacket(ctx, "lalu", normal.Packet.PacketID, 0, false)
	if err != nil {
		t.Fatalf("GetRedPacket normal expired failed: %v", err)
	}
	if normalExpired.Status != ledger.RedPacketStatusRefunded || normalExpired.RemainingAmount != 0 || normalExpired.RemainingCount != 0 {
		t.Fatalf("normal red packet should be refunded: %+v", normalExpired)
	}

	delayed, err := svc.CreateRedPacket(ctx, ledger.RedPacketCreateCommand{
		AppCode:      "lalu",
		CommandID:    "cmd-red-packet-delayed",
		SenderUserID: 19001,
		RoomID:       "room-190",
		RegionID:     190,
		PacketType:   ledger.RedPacketTypeDelayed,
		TotalAmount:  10,
		PacketCount:  5,
	})
	if err != nil {
		t.Fatalf("CreateRedPacket delayed failed: %v", err)
	}
	if delayed.Packet.Status != ledger.RedPacketStatusWaitingOpen {
		t.Fatalf("delayed red packet should wait to open: %+v", delayed.Packet)
	}
	_, err = svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
		AppCode:   "lalu",
		CommandID: "cmd-red-packet-delayed-too-early",
		PacketID:  delayed.Packet.PacketID,
		UserID:    19003,
	})
	if xerr.CodeOf(err) != xerr.RedPacketNotOpen {
		t.Fatalf("delayed claim before open code=%s err=%v", xerr.CodeOf(err), err)
	}
	time.Sleep(1100 * time.Millisecond)
	delayedClaim, err := svc.ClaimRedPacket(ctx, ledger.RedPacketClaimCommand{
		AppCode:   "lalu",
		CommandID: "cmd-red-packet-delayed-claim",
		PacketID:  delayed.Packet.PacketID,
		UserID:    19003,
	})
	if err != nil {
		t.Fatalf("ClaimRedPacket delayed after open failed: %v", err)
	}
	if delayedClaim.Claim.Amount <= 0 {
		t.Fatalf("delayed claim amount should be positive: %+v", delayedClaim)
	}
}

func TestExpireRedPacketsWithoutAppCodeRefundsEveryTenant(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalanceForApp("lalu", 19101, 100)
	repository.SetBalanceForApp("huwaa", 19102, 100)
	svc := walletservice.New(repository)

	create := func(appCode string, commandID string, senderUserID int64) ledger.RedPacketCreateReceipt {
		t.Helper()
		receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
			AppCode: appCode, CommandID: commandID, SenderUserID: senderUserID,
			RoomID: appCode + "_room", RegionID: 1, PacketType: ledger.RedPacketTypeNormal,
			TotalAmount: 10, PacketCount: 1,
		})
		if err != nil {
			t.Fatalf("create %s red packet failed: %v", appCode, err)
		}
		repository.ExpireRedPacket(appCode, receipt.Packet.PacketID)
		return receipt
	}
	lalu := create("lalu", "cmd-red-packet-all-lalu", 19101)
	huwaa := create("huwaa", "cmd-red-packet-all-huwaa", 19102)

	result, err := svc.ExpireRedPackets(context.Background(), "", 10)
	if err != nil {
		t.Fatalf("ExpireRedPackets all tenants failed: %v", err)
	}
	if result.ExpiredCount != 2 || result.RefundedAmount != 20 {
		t.Fatalf("all-tenant expiry result mismatch: %+v", result)
	}
	for _, packet := range []struct {
		appCode  string
		packetID string
	}{{"lalu", lalu.Packet.PacketID}, {"huwaa", huwaa.Packet.PacketID}} {
		got, err := svc.GetRedPacket(context.Background(), packet.appCode, packet.packetID, 0, false)
		if err != nil || got.Status != ledger.RedPacketStatusRefunded {
			t.Fatalf("%s packet must be refunded: packet=%+v err=%v", packet.appCode, got, err)
		}
	}
}

func TestWalletOutboxClaimSeparatesRealtimeEventTypes(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(19101, 1000)
	svc := walletservice.New(repository)
	if _, err := svc.UpdateRedPacketConfig(context.Background(), ledger.RedPacketConfig{
		AppCode:            "lalu",
		Enabled:            true,
		CountTiers:         []int32{5},
		AmountTiers:        []int64{10},
		DelayedOpenSeconds: 1,
		ExpireSeconds:      2,
		DailySendLimit:     5,
		UpdatedByAdminID:   9001,
	}); err != nil {
		t.Fatalf("UpdateRedPacketConfig failed: %v", err)
	}
	receipt, err := svc.CreateRedPacket(context.Background(), ledger.RedPacketCreateCommand{
		AppCode:      "lalu",
		CommandID:    "cmd-red-packet-realtime-outbox",
		SenderUserID: 19101,
		RoomID:       "room-191",
		RegionID:     191,
		PacketType:   ledger.RedPacketTypeNormal,
		TotalAmount:  10,
		PacketCount:  5,
	})
	if err != nil {
		t.Fatalf("CreateRedPacket failed: %v", err)
	}

	ctx := appcode.WithContext(context.Background(), "lalu")
	realtimeTypes := []string{"WalletRedPacketCreated", "WalletRedPacketClaimed", "WalletRedPacketRefunded"}
	realtimeRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-realtime-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
		IncludeEventTypes: realtimeTypes,
	})
	if err != nil {
		t.Fatalf("ClaimPendingWalletOutboxFiltered realtime failed: %v", err)
	}
	if len(realtimeRecords) != 1 || realtimeRecords[0].EventType != "WalletRedPacketCreated" {
		t.Fatalf("realtime claim should only pick red packet fact, got %+v", realtimeRecords)
	}

	normalRecords, err := repository.ClaimPendingWalletOutboxFiltered(ctx, "wallet-normal-outbox-test", 10, time.Now().Add(time.Minute).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
		ExcludeEventTypes: realtimeTypes,
	})
	if err != nil {
		t.Fatalf("ClaimPendingWalletOutboxFiltered normal failed: %v", err)
	}
	if len(normalRecords) == 0 {
		t.Fatalf("normal claim should still pick non-realtime wallet facts for packet %s", receipt.Packet.PacketID)
	}
	for _, record := range normalRecords {
		if record.EventType == "WalletRedPacketCreated" || record.EventType == "WalletRedPacketClaimed" || record.EventType == "WalletRedPacketRefunded" {
			t.Fatalf("normal claim must exclude realtime red packet fact: %+v", normalRecords)
		}
	}
	if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND worker_id = ?", realtimeRecords[0].EventID, "delivering", "wallet-realtime-outbox-test"); got != 1 {
		t.Fatalf("realtime fact should be locked by realtime worker, got %d", got)
	}
}

func TestWalletOutboxClaimUsesRealMySQLStatusFlow(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalance(30001, ledger.AssetCoinSellerCoin, 1000)
	repository.SetRechargePolicy(86, "policy-real-outbox", 100, 100)
	svc := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "lalu")

	receipt, err := svc.TransferCoinFromSeller(ctx, ledger.CoinSellerTransferCommand{
		AppCode:         "lalu",
		CommandID:       "cmd-real-outbox-transfer",
		SellerUserID:    30001,
		TargetUserID:    30002,
		TargetCountryID: 63,
		SellerRegionID:  86,
		TargetRegionID:  86,
		Amount:          200,
		Reason:          "real outbox claim test",
	})
	if err != nil {
		t.Fatalf("TransferCoinFromSeller failed: %v", err)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ?", receipt.TransactionID, "pending"); got != 4 {
		t.Fatalf("transfer should create four pending wallet outbox facts, got %d", got)
	}

	records, err := repository.ClaimPendingWalletOutbox(ctx, "wallet-outbox-real-test", 4, time.Now().Add(time.Minute).UnixMilli())
	if err != nil {
		t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
	}
	if len(records) != 4 {
		t.Fatalf("expected four claimed records, got %d: %+v", len(records), records)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND status = ? AND worker_id = ?", receipt.TransactionID, "delivering", "wallet-outbox-real-test"); got != 4 {
		t.Fatalf("claim should mark all rows delivering for worker, got %d", got)
	}
	var rechargeEventID string
	for _, record := range records {
		if record.EventType == "WalletRechargeRecorded" {
			rechargeEventID = record.EventID
		}
		if record.AppCode != "lalu" || record.TransactionID != receipt.TransactionID || record.PayloadJSON == "" {
			t.Fatalf("claimed record should contain stable wallet fact: %+v", record)
		}
	}
	if rechargeEventID == "" {
		t.Fatalf("claimed records should include WalletRechargeRecorded: %+v", records)
	}

	if err := repository.MarkWalletOutboxDelivered(ctx, rechargeEventID); err != nil {
		t.Fatalf("MarkWalletOutboxDelivered failed: %v", err)
	}
	if got := repository.CountRows("wallet_outbox", "event_id = ? AND status = ? AND (worker_id = '' OR worker_id IS NULL)", rechargeEventID, "delivered"); got != 1 {
		t.Fatalf("delivered record should release worker lock, got %d", got)
	}
}

// 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)
	}
}

func TestCreditTaskRewardPointUsesConfiguredAssetWithoutAppGate(t *testing.T) {
	repository := &fakePointGateRepository{}
	svc := walletservice.New(repository)

	receipt, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{
		AppCode:      "lalu",
		CommandID:    "cmd-fake-lalu-point-task-reward",
		TargetUserID: 23001,
		AssetType:    ledger.AssetPoint,
		Amount:       88,
		TaskType:     "daily",
		TaskID:       "task-point",
		CycleKey:     "2026-05-09",
		Reason:       "daily_task_reward",
	})
	if err != nil {
		t.Fatalf("configured POINT task reward should reach repository without an app-name gate: %v", err)
	}
	if receipt.Balance.AssetType != ledger.AssetPoint ||
		repository.creditTaskRewardCalls != 1 ||
		repository.lastTaskRewardAppFromContext != "lalu" ||
		repository.lastTaskReward.AssetType != ledger.AssetPoint ||
		repository.lastTaskReward.AppCode != "lalu" {
		t.Fatalf("POINT task reward repository payload mismatch: receipt=%+v repo=%+v", receipt, repository)
	}
}

func TestCreditTaskRewardPointIsTenantScoped(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)

	first, err := svc.CreditTaskReward(context.Background(), ledger.TaskRewardCommand{
		AppCode:      "lalu",
		CommandID:    "cmd-lalu-point-task-reward",
		TargetUserID: 21011,
		AssetType:    ledger.AssetPoint,
		Amount:       88,
		TaskType:     "daily",
		TaskID:       "task-point",
		CycleKey:     "2026-05-09",
		Reason:       "daily_task_reward",
	})
	if err != nil {
		t.Fatalf("configured POINT task reward failed: %v", err)
	}
	if first.Balance.AssetType != ledger.AssetPoint || first.Balance.AvailableAmount != 88 {
		t.Fatalf("first tenant POINT balance mismatch: %+v", first)
	}

	command := ledger.TaskRewardCommand{
		AppCode:      "huwaa",
		CommandID:    "cmd-huwaa-point-task-reward",
		TargetUserID: 21012,
		AssetType:    ledger.AssetPoint,
		Amount:       99,
		TaskType:     "daily",
		TaskID:       "task-point",
		CycleKey:     "2026-05-09",
		Reason:       "daily_task_reward",
	}
	huwaaFirst, err := svc.CreditTaskReward(context.Background(), command)
	if err != nil {
		t.Fatalf("Huwaa POINT task reward failed: %v", err)
	}
	second, err := svc.CreditTaskReward(context.Background(), command)
	if err != nil {
		t.Fatalf("Huwaa POINT task reward retry failed: %v", err)
	}
	if huwaaFirst.TransactionID == "" || huwaaFirst.TransactionID != second.TransactionID || huwaaFirst.Balance.AssetType != ledger.AssetPoint || second.Balance.AvailableAmount != 99 {
		t.Fatalf("Huwaa POINT task reward receipt mismatch: first=%+v second=%+v", huwaaFirst, second)
	}
	assertBalanceForApp(t, svc, "huwaa", 21012, ledger.AssetPoint, 99)
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND asset_type = ?", huwaaFirst.TransactionID, ledger.AssetPoint); got != 1 {
		t.Fatalf("Huwaa POINT task reward should write one POINT entry, got %d", got)
	}
}

func TestRemoveDirectGiftPointPolicyMigrationKeepsUnrelatedPointPolicies(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	fixtures := []struct {
		appCode          string
		instanceCode     string
		templateCode     string
		pointsPerUSD     int64
		minimumPoints    int64
		feeBPS           int64
		hostRatioPPM     int64
		agencyRatioPPM   int64
		taskAsset        string
		includeExtension bool
	}{
		{appCode: "fami", instanceCode: "fami-policy", templateCode: "fami_guild_revenue_policy", pointsPerUSD: 100_000, minimumPoints: 1_000_000, feeBPS: 500, hostRatioPPM: 1_000_000, agencyRatioPPM: 200_000, taskAsset: "POINT", includeExtension: true},
		{appCode: "lalu", instanceCode: "lalu-policy", templateCode: "lalu-point-policy", pointsPerUSD: 110_000, minimumPoints: 2_000_000, feeBPS: 300, hostRatioPPM: 600_000, agencyRatioPPM: 50_000, taskAsset: "COIN", includeExtension: true},
		{appCode: "huwaa", instanceCode: "huwaa-policy", templateCode: "huwaa-point-policy", pointsPerUSD: 120_000, minimumPoints: 3_000_000, feeBPS: 250, hostRatioPPM: 700_000, agencyRatioPPM: 100_000, taskAsset: "POINT", includeExtension: true},
	}
	for _, fixture := range fixtures {
		repository.SeedPointWalletPolicy(fixture.appCode, fixture.instanceCode, 0, "active", fixture.pointsPerUSD, fixture.minimumPoints, fixture.feeBPS)
		ruleJSON := fmt.Sprintf(`{"points_per_usd":%d,"host":{"minimum_withdraw_points":%d,"withdraw_fee_bps":%d,"point_ratio_percent":70,"use_gift_type_ratio":true,"affects_room_heat":true},"agency":{"point_ratio_percent":20,"share_base":"host_income"},"agent":{"levels":[]},"bd":{"levels":[]},"manager":{"host_point_commission_percent":2,"future_manager_option":"keep"},"coin_seller":{"seller_point_reward_percent":5,"seller_point_settle_percent":95,"future_coin_seller_option":"keep"},"game_invite":{"enabled":true},"tasks":{"reward_asset_type":%q,"new_host_7d_max_points":350000},"extension":{"enabled":%t}}`, fixture.pointsPerUSD, fixture.minimumPoints, fixture.feeBPS, fixture.taskAsset, fixture.includeExtension)
		repository.SetPointWalletPolicyRawSnapshot(fixture.appCode, fixture.instanceCode, 0, fixture.templateCode, fixture.hostRatioPPM, fixture.agencyRatioPPM, ruleJSON)
	}

	migrationSQL, err := os.ReadFile("../../../deploy/mysql/migrations/015_remove_direct_gift_point_policy.sql")
	if err != nil {
		t.Fatalf("read direct-gift POINT removal migration failed: %v", err)
	}
	// 真实执行两次，同时锁定迁移幂等性；这个迁移只允许动小型配置表。
	repository.ApplySQLScript(string(migrationSQL))
	repository.ApplySQLScript(string(migrationSQL))

	if got := repository.CountRows("wallet_policy_instances", "template_code = ?", "fami_guild_revenue_policy"); got != 0 {
		t.Fatalf("removed Fami direct-gift policy must have no runtime rows, got %d", got)
	}
	if got := repository.CountRows("wallet_policy_instances", "app_code IN (?, ?)", "lalu", "huwaa"); got != 2 {
		t.Fatalf("unrelated POINT policies must remain, got %d", got)
	}
	if got := repository.CountRows("wallet_policy_instances", "app_code IN (?, ?) AND host_point_ratio_ppm = 0 AND agency_point_ratio_ppm = 0", "lalu", "huwaa"); got != 2 {
		t.Fatalf("legacy gift ratios must be neutralized on remaining policies, got %d", got)
	}
	if got := repository.CountRows("wallet_policy_instances", `app_code IN (?, ?)
		AND JSON_EXTRACT(rule_json, '$.tasks') IS NULL
		AND JSON_EXTRACT(rule_json, '$.host.point_ratio_percent') IS NULL
		AND JSON_EXTRACT(rule_json, '$.host.use_gift_type_ratio') IS NULL
		AND JSON_EXTRACT(rule_json, '$.host.affects_room_heat') IS NULL
		AND JSON_EXTRACT(rule_json, '$.agency') IS NULL
		AND JSON_EXTRACT(rule_json, '$.agent') IS NULL
		AND JSON_EXTRACT(rule_json, '$.bd') IS NULL
		AND JSON_EXTRACT(rule_json, '$.manager') IS NULL
		AND JSON_EXTRACT(rule_json, '$.coin_seller') IS NULL
		AND JSON_EXTRACT(rule_json, '$.game_invite') IS NULL`, "lalu", "huwaa"); got != 2 {
		t.Fatalf("unconsumed policy keys must be removed from remaining snapshots, got %d", got)
	}
	if got := repository.CountRows("wallet_policy_instances", `app_code = ? AND points_per_usd = ? AND withdraw_fee_bps = ?
		AND CAST(JSON_UNQUOTE(JSON_EXTRACT(rule_json, '$.host.minimum_withdraw_points')) AS SIGNED) = ?
		AND JSON_EXTRACT(rule_json, '$.extension.enabled') = TRUE`, "lalu", int64(110_000), int64(300), int64(2_000_000)); got != 1 {
		t.Fatalf("Lalu POINT financial fields and unrelated extensions must remain, got %d", got)
	}
}

func seedPointWithdrawalSalaryPolicy(repository *mysqltest.Repository, appCode string, policyID uint64, regionID int64, coinSellerPeriod string, coinSellerCount int64, platformPeriod string, platformCount int64, platformAllowedDays ...string) {
	allowedDays := ""
	if len(platformAllowedDays) > 0 {
		allowedDays = platformAllowedDays[0]
	}
	repository.SetHostSalaryPolicyForApp(appCode, ledger.HostSalaryPolicy{
		PolicyID: policyID, PolicyVersion: policyID, CycleKey: time.Now().UTC().Format("2006-01"),
		Name: "point withdrawal limit policy", RegionID: regionID, Status: "active",
		SettlementMode: ledger.HostSalarySettlementModeDaily, SettlementTriggerMode: ledger.HostSalarySettlementTriggerAutomatic,
		GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0",
		CoinSellerWithdrawalLimitPeriod: coinSellerPeriod, CoinSellerWithdrawalLimitCount: coinSellerCount,
		PlatformWithdrawalLimitPeriod: platformPeriod, PlatformWithdrawalLimitCount: platformCount,
		PlatformWithdrawalAllowedDays: allowedDays,
	})
	// POINT 比例/门槛继续来自原 wallet 政策；Host 政策负责频率和日期限制。
	repository.SeedPointWalletPolicy(appCode, fmt.Sprintf("point-policy-%d", regionID), regionID, "active", 100_000, 1_000_000, 500)
}

func TestHostAgencySalaryWithdrawalWithoutPolicyPreservesLegacyAccessAndRollbackIdempotency(t *testing.T) {
	tests := []struct {
		name      string
		assetType string
		userID    int64
		regionID  int64
	}{
		{name: "host", assetType: ledger.AssetHostSalaryUSD, userID: 22101, regionID: 3101},
		{name: "agency", assetType: ledger.AssetAgencySalaryUSD, userID: 22102, regionID: 3102},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			repository := mysqltest.NewRepository(t)
			repository.SetAssetBalanceForApp(appcode.Default, test.userID, test.assetType, 10_000)
			svc := walletservice.New(repository)
			freezeCommandID := "legacy-policyless-" + test.name + "-freeze"
			withdrawalRef := "legacy-policyless-" + test.name + "-ref"

			freeze, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
				AppCode: appcode.Default, CommandID: freezeCommandID, UserID: test.userID,
				SalaryAssetType: test.assetType, SalaryUSDMinor: 5_000,
				RegionID: test.regionID, WithdrawalRef: withdrawalRef,
			})
			if err != nil {
				t.Fatalf("policyless %s salary freeze must preserve legacy access: %v", test.name, err)
			}
			if freeze.AvailableAfter != 5_000 || freeze.FrozenAfter != 5_000 {
				t.Fatalf("policyless %s salary freeze balance mismatch: %+v", test.name, freeze)
			}
			if got := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ?", appcode.Default, test.userID); got != 0 {
				t.Fatalf("policyless %s salary freeze must not consume a policy counter, got %d", test.name, got)
			}
			if got := repository.CountRows("point_withdrawal_limit_reservations", "app_code = ? AND freeze_command_id = ? AND policy_id = 0 AND policy_version = 0 AND counted = FALSE AND status = 'active'", appcode.Default, freezeCommandID); got != 1 {
				t.Fatalf("policyless %s salary freeze must keep one non-counted rollback reservation, got %d", test.name, got)
			}

			if _, err := svc.ReleaseSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
				AppCode: appcode.Default, CommandID: freezeCommandID + ":rollback", UserID: test.userID,
				SalaryAssetType: test.assetType, SalaryUSDMinor: 5_000,
			}); err != nil {
				t.Fatalf("policyless %s failed-application rollback must restore salary: %v", test.name, err)
			}
			requireWalletBalanceForApp(t, svc, appcode.Default, test.userID, test.assetType, 10_000, 0)
			if got := repository.CountRows("point_withdrawal_limit_reservations", "app_code = ? AND freeze_command_id = ? AND counted = FALSE AND status = 'released'", appcode.Default, freezeCommandID); got != 1 {
				t.Fatalf("policyless %s rollback must release its reservation, got %d", test.name, got)
			}

			_, err = svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
				AppCode: appcode.Default, CommandID: freezeCommandID, UserID: test.userID,
				SalaryAssetType: test.assetType, SalaryUSDMinor: 5_000,
				RegionID: test.regionID, WithdrawalRef: withdrawalRef,
			})
			if !xerr.IsCode(err, xerr.IdempotencyConflict) {
				t.Fatalf("policyless %s rolled-back freeze must not replay stale success, got %v", test.name, err)
			}
			requireWalletBalanceForApp(t, svc, appcode.Default, test.userID, test.assetType, 10_000, 0)
		})
	}
}

func TestHostSalaryWithdrawalInheritsPreviousCyclePolicyLimit(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	const userID int64 = 22121
	const regionID int64 = 3121
	repository.SetAssetBalanceForApp(appcode.Default, userID, ledger.AssetHostSalaryUSD, 10_000)
	now := time.Now().UTC()
	previousCycle := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -1, 0).Format("2006-01")
	repository.SetHostSalaryPolicyForApp(appcode.Default, ledger.HostSalaryPolicy{
		PolicyID: 221, PolicyVersion: 221, CycleKey: previousCycle,
		Name: "previous host withdrawal policy", RegionID: regionID, Status: "active",
		SettlementMode: ledger.HostSalarySettlementModeDaily, SettlementTriggerMode: ledger.HostSalarySettlementTriggerAutomatic,
		GiftCoinToDiamondRatio: "1", ResidualDiamondToUSDRate: "0",
		CoinSellerWithdrawalLimitPeriod: "month", CoinSellerWithdrawalLimitCount: 0,
		PlatformWithdrawalLimitPeriod: "month", PlatformWithdrawalLimitCount: 1,
	})

	if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: appcode.Default, CommandID: "inherited-host-platform-1", UserID: userID,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 1_000,
		RegionID: regionID, WithdrawalRef: "inherited-host-platform-ref-1",
	}); err != nil {
		t.Fatalf("first withdrawal must inherit and use previous-cycle Host policy: %v", err)
	}
	_, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: appcode.Default, CommandID: "inherited-host-platform-2", UserID: userID,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 1_000,
		RegionID: regionID, WithdrawalRef: "inherited-host-platform-ref-2",
	})
	if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("inherited Host policy platform limit must reject the second withdrawal, got %v", err)
	}
	requireWalletBalanceForApp(t, svc, appcode.Default, userID, ledger.AssetHostSalaryUSD, 9_000, 1_000)
}

func TestPointWithdrawalPlatformAllowedDaysUseExactUTCDate(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("fami", 22008, ledger.AssetPoint, 2_000_000)
	today := time.Now().UTC()
	allowedDay := today.Format("2")
	disallowedDay := "1"
	if today.Day() == 1 {
		disallowedDay = "2"
	}
	seedPointWithdrawalSalaryPolicy(repository, "fami", 228, 25, "month", 0, "month", 0, allowedDay)
	svc := walletservice.New(repository)
	if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "fami", CommandID: "point-day-allowed", UserID: 22008, AssetType: ledger.AssetPoint,
		GrossPointAmount: 1_000_000, RegionID: 25, WithdrawalRef: "point-day-allowed-ref",
	}); err != nil {
		t.Fatalf("configured UTC withdrawal day must allow platform freeze: %v", err)
	}

	repository.SetAssetBalanceForApp("fami", 22009, ledger.AssetPoint, 2_000_000)
	seedPointWithdrawalSalaryPolicy(repository, "fami", 229, 26, "month", 0, "month", 0, disallowedDay)
	_, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "fami", CommandID: "point-day-blocked", UserID: 22009, AssetType: ledger.AssetPoint,
		GrossPointAmount: 1_000_000, RegionID: 26, WithdrawalRef: "point-day-blocked-ref",
	})
	if !xerr.IsCode(err, xerr.PointWithdrawalDateNotAllowed) {
		t.Fatalf("date outside configured UTC days must be rejected, got %v", err)
	}
	metadata := xerr.MetadataOf(err)
	if metadata["allowed_days"] != disallowedDay || metadata["current_day"] != allowedDay || metadata["timezone"] != "UTC" {
		t.Fatalf("withdrawal date metadata mismatch: %+v", metadata)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND command_id = ?", "fami", "point-day-blocked"); got != 0 {
		t.Fatalf("rejected date must not create a wallet transaction, got %d", got)
	}
}

func TestPointWithdrawalAllowsAdminConfiguredZeroFee(t *testing.T) {
	repository := &fakePointGateRepository{pointWalletPolicy: ledger.PointWithdrawalRuntimeConfig{
		Found: true, PointsPerUSD: 200_000, MinimumPoints: 1_000_000, FeeBPS: 0, PolicyInstanceCode: "fake-zero-fee-policy",
	}}
	svc := walletservice.New(repository)
	receipt, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "fami", CommandID: "cmd-fami-zero-fee", UserID: 23013,
		AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, PointsPerUSD: 200_000, FeeBPS: 0,
		RegionID: 25,
	})
	if err != nil {
		t.Fatalf("zero-fee point withdrawal failed: %v", err)
	}
	if receipt.FeePointAmount != 0 || receipt.NetPointAmount != 1_000_000 || receipt.PointsPerUSD != 200_000 || receipt.FeeBPS != 0 {
		t.Fatalf("zero-fee policy snapshot mismatch: %+v", receipt)
	}
}

func TestPointWithdrawalFreezeSettleReleaseIsTenantScoped(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("huwaa", 22001, ledger.AssetPoint, 2_000_000)
	repository.SetAssetBalanceForApp("huwaa", 22002, ledger.AssetPoint, 2_000_000)
	seedPointWithdrawalSalaryPolicy(repository, "huwaa", 220, 25, "month", 0, "month", 0)
	svc := walletservice.New(repository)

	_, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode:          "lalu",
		CommandID:        "cmd-lalu-point-freeze",
		UserID:           22001,
		AssetType:        ledger.AssetPoint,
		GrossPointAmount: 1_000_000,
		RegionID:         25,
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("POINT withdrawal must not reuse another tenant's wallet policy, got %v", err)
	}
	requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 2_000_000, 0)

	freeze, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode:          "huwaa",
		CommandID:        "cmd-huwaa-point-freeze",
		UserID:           22001,
		AssetType:        ledger.AssetPoint,
		GrossPointAmount: 1_000_000,
		FeeBPS:           500,
		RegionID:         25,
		WithdrawalRef:    "point-freeze-22001",
	})
	if err != nil {
		t.Fatalf("FreezePointWithdrawal failed: %v", err)
	}
	if freeze.AvailableAfter != 1_000_000 || freeze.FrozenAfter != 1_000_000 || freeze.FeePointAmount != 50_000 || freeze.NetPointAmount != 950_000 {
		t.Fatalf("point freeze receipt mismatch: %+v", freeze)
	}
	requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 1_000_000, 1_000_000)

	settle, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode:                 "huwaa",
		CommandID:               "cmd-huwaa-point-settle",
		UserID:                  22001,
		AssetType:               ledger.AssetPoint,
		GrossPointAmount:        1_000_000,
		FeePointAmount:          freeze.FeePointAmount,
		NetPointAmount:          freeze.NetPointAmount,
		PointsPerUSD:            freeze.PointsPerUSD,
		FeeBPS:                  freeze.FeeBPS,
		OperatorUserID:          90001,
		WithdrawalApplicationID: "77",
	})
	if err != nil {
		t.Fatalf("SettlePointWithdrawal failed: %v", err)
	}
	if settle.AvailableAfter != 1_000_000 || settle.FrozenAfter != 0 {
		t.Fatalf("point settle receipt mismatch: %+v", settle)
	}
	requireWalletBalanceForApp(t, svc, "huwaa", 22001, ledger.AssetPoint, 1_000_000, 0)

	freezeForRelease, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode:          "huwaa",
		CommandID:        "cmd-huwaa-point-freeze-release",
		UserID:           22002,
		AssetType:        ledger.AssetPoint,
		GrossPointAmount: 1_000_000,
		FeeBPS:           500,
		RegionID:         25,
		WithdrawalRef:    "point-freeze-22002",
	})
	if err != nil {
		t.Fatalf("FreezePointWithdrawal for release failed: %v", err)
	}
	release, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode:                 "huwaa",
		CommandID:               "cmd-huwaa-point-release",
		UserID:                  22002,
		AssetType:               ledger.AssetPoint,
		GrossPointAmount:        1_000_000,
		FeePointAmount:          freezeForRelease.FeePointAmount,
		NetPointAmount:          freezeForRelease.NetPointAmount,
		PointsPerUSD:            freezeForRelease.PointsPerUSD,
		FeeBPS:                  freezeForRelease.FeeBPS,
		WithdrawalApplicationID: "78",
	})
	if err != nil {
		t.Fatalf("ReleasePointWithdrawal failed: %v", err)
	}
	if release.AvailableAfter != 2_000_000 || release.FrozenAfter != 0 {
		t.Fatalf("point release receipt mismatch: %+v", release)
	}
	requireWalletBalanceForApp(t, svc, "huwaa", 22002, ledger.AssetPoint, 2_000_000, 0)
}

func TestPointWithdrawalPolicyLimitRollbackOnlyReleasesFailedApplicationReservation(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("huwaa", 22006, ledger.AssetPoint, 3_000_000)
	seedPointWithdrawalSalaryPolicy(repository, "huwaa", 222, 25, "month", 0, "day", 1)
	svc := walletservice.New(repository)

	first := ledger.PointWithdrawalCommand{
		AppCode: "huwaa", CommandID: "point-limit-freeze-1", UserID: 22006, AssetType: ledger.AssetPoint,
		GrossPointAmount: 1_000_000, FeeBPS: 500, RegionID: 25, WithdrawalRef: "point-limit-ref-1",
	}
	firstReceipt, err := svc.FreezePointWithdrawal(context.Background(), first)
	if err != nil {
		t.Fatalf("first limited platform withdrawal failed: %v", err)
	}
	// request_id、用户当前 region 和服务端政策快照都可能在 HTTP 重试前变化；客户端稳定 command 必须返回首次冻结回执。
	replayedFirst := first
	replayedFirst.RegionID = 26
	replayedFirst.PointsPerUSD = 777_777
	replayedFirst.FeeBPS = 0
	replayedFirst.FeePointAmount = 0
	replayedFirst.NetPointAmount = 0
	replayedReceipt, err := svc.FreezePointWithdrawal(context.Background(), replayedFirst)
	if err != nil || replayedReceipt.TransactionID != firstReceipt.TransactionID || replayedReceipt.FeeBPS != firstReceipt.FeeBPS || replayedReceipt.PointsPerUSD != firstReceipt.PointsPerUSD {
		t.Fatalf("derived policy changes must replay the first freeze receipt: first=%+v replay=%+v err=%v", firstReceipt, replayedReceipt, err)
	}
	// 用户 region 变化后会命中另一份政策，但自然日计数属于用户/渠道周期，不能因为政策版本不同获得新额度。
	seedPointWithdrawalSalaryPolicy(repository, "huwaa", 223, 26, "month", 0, "day", 1)
	changedRegion := first
	changedRegion.CommandID = "point-limit-region-change"
	changedRegion.WithdrawalRef = "point-limit-region-change-ref"
	changedRegion.RegionID = 26
	if _, err := svc.FreezePointWithdrawal(context.Background(), changedRegion); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("region change must preserve the shared natural-period count, got %v", err)
	}
	// 同月从继承版本切到显式发布版本也只能改变后续限额值，不能用新的 policy_version 重置已用次数。
	seedPointWithdrawalSalaryPolicy(repository, "huwaa", 224, 25, "month", 0, "day", 1)
	replacedPolicy := first
	replacedPolicy.CommandID = "point-limit-policy-replaced"
	replacedPolicy.WithdrawalRef = "point-limit-policy-replaced-ref"
	if _, err := svc.FreezePointWithdrawal(context.Background(), replacedPolicy); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("same-cycle policy replacement must preserve the shared natural-period count, got %v", err)
	}
	second := first
	second.CommandID = "point-limit-freeze-2"
	second.WithdrawalRef = "point-limit-ref-2"
	if _, err := svc.FreezePointWithdrawal(context.Background(), second); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("second platform withdrawal must hit day limit, got %v", err)
	} else if metadata := xerr.MetadataOf(err); metadata["channel"] != "platform" || metadata["period"] != "day" || metadata["limit"] != "1" {
		t.Fatalf("limit metadata mismatch: %+v", metadata)
	}

	// gateway 创建申请失败时传回原 withdrawal_ref；计数和冻结余额在同一补偿事务中一起恢复。
	if _, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "huwaa", CommandID: "point-limit-freeze-1:rollback", UserID: 22006, AssetType: ledger.AssetPoint,
		GrossPointAmount: firstReceipt.GrossPointAmount, FeePointAmount: firstReceipt.FeePointAmount,
		NetPointAmount: firstReceipt.NetPointAmount, PointsPerUSD: firstReceipt.PointsPerUSD, FeeBPS: firstReceipt.FeeBPS,
		WithdrawalRef: "retry-request-ref-changed",
	}); err != nil {
		t.Fatalf("failed-application rollback must release reservation: %v", err)
	}
	if _, err := svc.FreezePointWithdrawal(context.Background(), first); !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("rolled-back freeze command must not replay a stale success receipt, got %v", err)
	}
	secondReceipt, err := svc.FreezePointWithdrawal(context.Background(), second)
	if err != nil {
		t.Fatalf("withdrawal after failed-application rollback should reuse the slot: %v", err)
	}
	if _, err := svc.ReleasePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "huwaa", CommandID: "point-limit-manual-reject", UserID: 22006, AssetType: ledger.AssetPoint,
		GrossPointAmount: secondReceipt.GrossPointAmount, FeePointAmount: secondReceipt.FeePointAmount,
		NetPointAmount: secondReceipt.NetPointAmount, PointsPerUSD: secondReceipt.PointsPerUSD, FeeBPS: secondReceipt.FeeBPS,
		WithdrawalApplicationID: "point-application-2",
	}); err != nil {
		t.Fatalf("manual rejection release failed: %v", err)
	}
	third := second
	third.CommandID = "point-limit-freeze-3"
	third.WithdrawalRef = "point-limit-ref-3"
	if _, err := svc.FreezePointWithdrawal(context.Background(), third); !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("manual rejection must still count as an application, got %v", err)
	}
}

func TestSalaryWithdrawalTerminalReusesLegacyCommandAcrossReviewerRetryAndRejectsOppositeDecision(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("lalu", 22003, ledger.AssetHostSalaryUSD, 10_000)
	svc := walletservice.New(repository)

	if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: "lalu", CommandID: "salary-withdrawal:legacy-freeze", UserID: 22003,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "legacy-freeze", RegionID: 31,
	}); err != nil {
		t.Fatalf("freeze salary withdrawal failed: %v", err)
	}
	legacy, err := svc.SettleSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: "lalu", CommandID: "salary-withdrawal:901:approved", UserID: 22003,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000,
		OperatorUserID: 7, Reason: "legacy finance remark", WithdrawalApplicationID: "901",
	})
	if err != nil {
		t.Fatalf("legacy finance settlement failed: %v", err)
	}

	// admin 事务若在 wallet 成功后失败，新版会换用 stage command，并可能由另一审核人修改备注后重试。
	// wallet 必须按 application id 找回旧回执，不再扣减 frozen，也不因审计 metadata 改变产生 hash 冲突。
	retry, err := svc.SettleSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: "lalu", CommandID: "salary-withdrawal:901:finance", UserID: 22003,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000,
		OperatorUserID: 9, Reason: "new reviewer changed remark", WithdrawalApplicationID: "901",
	})
	if err != nil {
		t.Fatalf("stage command retry must reuse legacy terminal: %v", err)
	}
	if retry.TransactionID != legacy.TransactionID {
		t.Fatalf("retry transaction = %s, want legacy %s", retry.TransactionID, legacy.TransactionID)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "lalu", "901", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 {
		t.Fatalf("withdrawal application must own one terminal transaction, got %d", got)
	}
	requireWalletBalanceForApp(t, svc, "lalu", 22003, ledger.AssetHostSalaryUSD, 5_000, 0)

	_, err = svc.ReleaseSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: "lalu", CommandID: "salary-withdrawal:901:finance", UserID: 22003,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000,
		OperatorUserID: 10, Reason: "opposite decision", WithdrawalApplicationID: "901",
	})
	if !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("opposite terminal decision must conflict, got %v", err)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "lalu", "901", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 {
		t.Fatalf("opposite decision must not create a second terminal transaction, got %d", got)
	}
}

func TestPointDiamondHistoryHidesInternalSettlementEntry(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("fami", 22006, ledger.AssetPointDiamond, 1_000_000)
	repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{
		PolicyID: 222, PolicyVersion: 222, Name: "point diamond withdrawal history", PolicyType: ledger.HostPolicyTypePointDiamond,
		RegionID: 25, PointDiamondsPerUSD: 300_000, CoinsPerUSD: 80_000, MinimumWithdrawUSDMinor: 100, WithdrawFeeBPS: 500,
	})
	svc := walletservice.New(repository)
	freeze, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "fami", CommandID: "point-diamond-history-freeze", UserID: 22006,
		AssetType: ledger.AssetPointDiamond, GrossUSDMinor: 100, RegionID: 25, WithdrawalRef: "point-diamond-history-ref",
	})
	if err != nil {
		t.Fatalf("freeze POINT_DIAMOND withdrawal: %v", err)
	}
	if freeze.GrossPointAmount != 300_000 || freeze.FeePointAmount != 15_000 || freeze.NetPointAmount != 285_000 || freeze.GrossUSDMinor != 100 || freeze.FeeUSDMinor != 5 || freeze.NetUSDMinor != 95 {
		t.Fatalf("POINT_DIAMOND freeze precision snapshot mismatch: %+v", freeze)
	}
	if _, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "fami", CommandID: "point-diamond-history-settle", UserID: 22006, AssetType: ledger.AssetPointDiamond,
		GrossPointAmount: freeze.GrossPointAmount, FeePointAmount: freeze.FeePointAmount, NetPointAmount: freeze.NetPointAmount,
		PointsPerUSD: freeze.PointsPerUSD, FeeBPS: freeze.FeeBPS, OperatorUserID: 9, Reason: "approved", WithdrawalApplicationID: "906",
	}); err != nil {
		t.Fatalf("settle POINT_DIAMOND withdrawal: %v", err)
	}

	items, total, err := svc.ListWalletTransactions(appcode.WithContext(context.Background(), "fami"), ledger.ListWalletTransactionsQuery{
		AppCode: "fami", UserID: 22006, AssetType: ledger.AssetPointDiamond, Page: 1, PageSize: 20,
	})
	if err != nil {
		t.Fatalf("list POINT_DIAMOND withdrawal history: %v", err)
	}
	if total != 1 || len(items) != 1 || items[0].EntryRole != "platform_withdrawal" || items[0].AvailableDelta != -300_000 {
		t.Fatalf("history must keep freeze and hide zero-available settle with matching total: total=%d items=%+v", total, items)
	}
}

func TestPointWithdrawalTerminalReusesLegacyCommandWithNormalizedPolicySnapshot(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("huwaa", 22005, ledger.AssetPoint, 2_000_000)
	seedPointWithdrawalSalaryPolicy(repository, "huwaa", 221, 25, "month", 0, "month", 0)
	svc := walletservice.New(repository)

	if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "huwaa", CommandID: "point-withdrawal:legacy-freeze", UserID: 22005,
		AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000,
		FeePointAmount: 50_000, NetPointAmount: 950_000, PointsPerUSD: 100_000, FeeBPS: 500,
		WithdrawalRef: "point-legacy-freeze",
		RegionID:      25,
	}); err != nil {
		t.Fatalf("freeze point withdrawal failed: %v", err)
	}
	legacy, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "huwaa", CommandID: "salary-withdrawal:903:approved", UserID: 22005,
		AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000,
		FeePointAmount: 50_000, NetPointAmount: 950_000, PointsPerUSD: 100_000, FeeBPS: 500,
		OperatorUserID: 7, Reason: "legacy point finance remark", WithdrawalApplicationID: "903",
	})
	if err != nil {
		t.Fatalf("legacy point withdrawal settlement failed: %v", err)
	}

	// 新版重试可由另一审核人发起且省略可由政策快照确定的 fee/net；service 归一化后必须命中旧终局 metadata。
	retry, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{
		AppCode: "huwaa", CommandID: "salary-withdrawal:903:finance", UserID: 22005,
		AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, PointsPerUSD: 100_000, FeeBPS: 500,
		OperatorUserID: 9, Reason: "new point reviewer remark", WithdrawalApplicationID: "903",
	})
	if err != nil {
		t.Fatalf("stage point command retry must reuse legacy terminal: %v", err)
	}
	if retry.TransactionID != legacy.TransactionID || retry.FeePointAmount != 50_000 || retry.NetPointAmount != 950_000 || retry.PointsPerUSD != 100_000 || retry.FeeBPS != 500 {
		t.Fatalf("point terminal retry snapshot mismatch: legacy=%+v retry=%+v", legacy, retry)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "huwaa", "903", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 {
		t.Fatalf("point withdrawal application must own one terminal transaction, got %d", got)
	}
	requireWalletBalanceForApp(t, svc, "huwaa", 22005, ledger.AssetPoint, 1_000_000, 0)
}

func TestSalaryWithdrawalTerminalSerializesConcurrentOppositeDecisions(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("lalu", 22004, ledger.AssetHostSalaryUSD, 10_000)
	svc := walletservice.New(repository)
	if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
		AppCode: "lalu", CommandID: "salary-withdrawal:concurrent-freeze", UserID: 22004,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "concurrent-freeze", RegionID: 31,
	}); err != nil {
		t.Fatalf("freeze salary withdrawal failed: %v", err)
	}

	start := make(chan struct{})
	errs := make(chan error, 2)
	var workers sync.WaitGroup
	workers.Add(2)
	go func() {
		defer workers.Done()
		<-start
		_, err := svc.SettleSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
			AppCode: "lalu", CommandID: "salary-withdrawal:902:approved", UserID: 22004,
			SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000,
			OperatorUserID: 7, Reason: "approve", WithdrawalApplicationID: "902",
		})
		errs <- err
	}()
	go func() {
		defer workers.Done()
		<-start
		_, err := svc.ReleaseSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{
			AppCode: "lalu", CommandID: "salary-withdrawal:902:finance", UserID: 22004,
			SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000,
			OperatorUserID: 8, Reason: "reject", WithdrawalApplicationID: "902",
		})
		errs <- err
	}()
	close(start)
	workers.Wait()
	close(errs)

	succeeded, conflicted := 0, 0
	for err := range errs {
		switch {
		case err == nil:
			succeeded++
		case xerr.IsCode(err, xerr.IdempotencyConflict):
			conflicted++
		default:
			t.Fatalf("unexpected concurrent terminal error: %v", err)
		}
	}
	if succeeded != 1 || conflicted != 1 {
		t.Fatalf("concurrent terminal results: succeeded=%d conflicted=%d", succeeded, conflicted)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "lalu", "902", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 {
		t.Fatalf("concurrent opposite decisions created %d terminal rows", got)
	}
}

// TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例，wallet 会按互斥的区域/国家范围和 active 配置原子扣 POINT/加币商库存。
func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("fami", 22501, ledger.AssetPoint, 2_000_000)
	repository.SetPointWithdrawalCoinSellerConfig("fami", 22502, 100_000, 92_000, []int64{26}, nil, "active", 1)
	repository.SetPointWithdrawalCoinSellerConfig("fami", 22503, 100_000, 92_000, nil, []string{"SA"}, "active", 2)
	seedPointWithdrawalSalaryPolicy(repository, "fami", 225, 26, "month", 1, "month", 0)
	svc := walletservice.New(repository)

	sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 26, "SA", false)
	if err != nil || len(sellers) != 2 || sellers[0].SellerUserID != 22502 || sellers[1].SellerUserID != 22503 {
		t.Fatalf("eligible point withdrawal sellers mismatch: sellers=%+v err=%v", sellers, err)
	}
	if regional, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 26, "TR", false); err != nil || len(regional) != 1 || regional[0].SellerUserID != 22502 {
		t.Fatalf("region-scoped seller mismatch: sellers=%+v err=%v", regional, err)
	}
	if national, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 27, "SA", false); err != nil || len(national) != 1 || national[0].SellerUserID != 22503 {
		t.Fatalf("country-scoped seller mismatch: sellers=%+v err=%v", national, err)
	}
	if blocked, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 27, "TR", false); err != nil || len(blocked) != 0 {
		t.Fatalf("out-of-scope sellers must be hidden: sellers=%+v err=%v", blocked, err)
	}

	command := ledger.PointToCoinSellerCommand{
		AppCode: "fami", CommandID: "cmd-fami-point-seller-1", SourceUserID: 22501,
		SellerUserID: 22502, PointAmount: 1_000_000, SourceCountryCode: "SA", Reason: "withdraw to seller",
		RegionID: 26,
	}
	receipt, err := svc.TransferPointToCoinSeller(context.Background(), command)
	if err != nil {
		t.Fatalf("TransferPointToCoinSeller failed: %v", err)
	}
	if receipt.SellerCoinAmount != 920_000 || receipt.RatioPointAmount != 100_000 || receipt.RatioSellerCoinAmount != 92_000 || receipt.SourcePointBalanceAfter != 1_000_000 || receipt.SellerBalanceAfter != 920_000 {
		t.Fatalf("point seller transfer receipt mismatch: %+v", receipt)
	}
	replayed, err := svc.TransferPointToCoinSeller(context.Background(), command)
	if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.SellerCoinAmount != receipt.SellerCoinAmount {
		t.Fatalf("point seller transfer replay mismatch: replay=%+v err=%v", replayed, err)
	}
	requireWalletBalanceForApp(t, svc, "fami", 22501, ledger.AssetPoint, 1_000_000, 0)
	requireWalletBalanceForApp(t, svc, "fami", 22502, ledger.AssetCoinSellerCoin, 920_000, 0)
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND biz_type = ?", "fami", "point_transfer_to_coin_seller"); got != 1 {
		t.Fatalf("idempotent point seller transfer must write one transaction, got %d", got)
	}

	_, err = svc.TransferPointToCoinSeller(context.Background(), ledger.PointToCoinSellerCommand{
		AppCode: "fami", CommandID: "cmd-fami-point-seller-limit", SourceUserID: 22501,
		SellerUserID: 22502, PointAmount: 1_000_000, SourceCountryCode: "SA",
		RegionID: 26,
	})
	if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("second unique coin-seller withdrawal must hit monthly limit, got %v", err)
	}
	requireWalletBalanceForApp(t, svc, "fami", 22501, ledger.AssetPoint, 1_000_000, 0)
}

func TestTransferPointDiamondToCoinSellerUsesGrossUSDAndIsolatesLimitByAsset(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPoint, 100_000)
	repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPointDiamond, 30_003_000)
	repository.SetPointWithdrawalCoinSellerConfig("fami", 22522, 100_000, 92_000, nil, []string{"SA"}, "active", 1)
	repository.SeedPointWalletPolicy("fami", "legacy-point-for-limit-isolation", 27, "active", 100_000, 1, 0)
	repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{
		PolicyID: 226, PolicyVersion: 226, Name: "point diamond seller policy", PolicyType: ledger.HostPolicyTypePointDiamond,
		RegionID: 27, PointDiamondsPerUSD: 300_000, CoinsPerUSD: 80_000, MinimumWithdrawUSDMinor: 100,
		WithdrawFeeBPS: 333, CoinSellerWithdrawalLimitPeriod: "month", CoinSellerWithdrawalLimitCount: 1,
	})
	svc := walletservice.New(repository)

	if _, err := svc.TransferPointToCoinSeller(context.Background(), ledger.PointToCoinSellerCommand{
		AppCode: "fami", CommandID: "cmd-limit-isolation-legacy-point", SourceUserID: 22521, SellerUserID: 22522,
		PointAmount: 100_000, SourceAssetType: ledger.AssetPoint, SourceCountryCode: "SA", RegionID: 27,
	}); err != nil {
		t.Fatalf("legacy POINT seller transfer: %v", err)
	}
	command := ledger.PointToCoinSellerCommand{
		AppCode: "fami", CommandID: "cmd-point-diamond-seller", SourceUserID: 22521, SellerUserID: 22522,
		SourceAssetType: ledger.AssetPointDiamond, GrossUSDMinor: 10_001, SourceCountryCode: "SA", RegionID: 27,
	}
	receipt, err := svc.TransferPointToCoinSeller(context.Background(), command)
	if err != nil {
		t.Fatalf("POINT_DIAMOND seller transfer must have an independent limit counter: %v", err)
	}
	if receipt.PointAmount != 30_003_000 || receipt.SellerCoinAmount != 7_734_400 || receipt.GrossUSDMinor != 10_001 || receipt.FeeUSDMinor != 333 || receipt.NetUSDMinor != 9_668 || receipt.WithdrawFeeBPS != 333 || receipt.PolicyID != 226 || receipt.PolicyVersion != 226 || receipt.SourceAssetType != ledger.AssetPointDiamond {
		t.Fatalf("POINT_DIAMOND seller gross/fee/net snapshot mismatch: %+v", receipt)
	}
	replay, err := svc.TransferPointToCoinSeller(context.Background(), command)
	if err != nil || replay.TransactionID != receipt.TransactionID || replay.SellerCoinAmount != receipt.SellerCoinAmount {
		t.Fatalf("POINT_DIAMOND seller replay mismatch: replay=%+v err=%v", replay, err)
	}
	requireWalletBalanceForApp(t, svc, "fami", 22521, ledger.AssetPointDiamond, 0, 0)
	requireWalletBalanceForApp(t, svc, "fami", 22522, ledger.AssetCoinSellerCoin, 7_826_400, 0)
	legacyCounters := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND source_asset_type = ?", "fami", int64(22521), ledger.AssetPoint)
	diamondCounters := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND source_asset_type = ?", "fami", int64(22521), ledger.AssetPointDiamond)
	if legacyCounters != 1 || diamondCounters != 1 {
		t.Fatalf("POINT and POINT_DIAMOND must own separate withdrawal counters: point=%d point_diamond=%d", legacyCounters, diamondCounters)
	}
}

// TestExchangePointToCoinUsesWalletPolicy 验证本人兑换继续读取原 wallet 政策，并且重试不重复扣款。
func TestExchangePointToCoinUsesWalletPolicy(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetAssetBalanceForApp("fami", 22511, ledger.AssetPoint, 2_500_000)
	repository.SetAssetBalanceForApp("fami", 22511, ledger.AssetCoin, 10_000)
	repository.SeedPointWalletPolicy("fami", "fami-point-exchange", 24, "active", 1_200_000, 1_000_000, 0)
	svc := walletservice.New(repository)

	runtimeConfig, err := svc.GetPointWithdrawalConfig(appcode.WithContext(context.Background(), "fami"), "fami", 24, time.Now().UTC().UnixMilli())
	if err != nil || !runtimeConfig.Found || runtimeConfig.PointsPerUSD != 1_200_000 || runtimeConfig.CoinsPerUSD != ledger.SalaryExchangeCoinPerUSD {
		t.Fatalf("point exchange runtime config mismatch: config=%+v err=%v", runtimeConfig, err)
	}
	command := ledger.PointToCoinCommand{AppCode: "fami", CommandID: "cmd-fami-point-exchange-1", UserID: 22511, PointAmount: 1_800_000, RegionID: 24, NowMS: time.Now().UTC().UnixMilli()}
	receipt, err := svc.ExchangePointToCoin(context.Background(), command)
	if err != nil {
		t.Fatalf("ExchangePointToCoin failed: %v", err)
	}
	if receipt.CoinAmount != 120_000 || receipt.PointBalanceAfter != 700_000 || receipt.CoinBalanceAfter != 130_000 || receipt.RatioPointAmount != 1_200_000 || receipt.RatioCoinAmount != 80_000 {
		t.Fatalf("point exchange receipt mismatch: %+v", receipt)
	}
	replayed, err := svc.ExchangePointToCoin(context.Background(), command)
	if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.CoinAmount != receipt.CoinAmount {
		t.Fatalf("point exchange replay mismatch: replay=%+v err=%v", replayed, err)
	}
	requireWalletBalanceForApp(t, svc, "fami", 22511, ledger.AssetPoint, 700_000, 0)
	requireWalletBalanceForApp(t, svc, "fami", 22511, ledger.AssetCoin, 130_000, 0)
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND biz_type = ?", "fami", "point_exchange_to_coin"); got != 1 {
		t.Fatalf("idempotent point exchange must write one transaction, got %d", got)
	}
}

// TestCreditLuckyGiftRewardIsIdempotent 验证幸运礼物返奖只按 draw_id 派一次，并产出余额通知事实。
func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.LuckyGiftRewardCommand{
		CommandID:       "lucky_reward:lucky_draw_1",
		TargetUserID:    21002,
		Amount:          188,
		DrawID:          "lucky_draw_1",
		RoomID:          "room-1",
		GiftID:          "rose",
		PoolID:          "super_lucky",
		CountryID:       15,
		VisibleRegionID: 10,
		Reason:          "lucky_gift_reward",
	}

	first, err := svc.CreditLuckyGiftReward(context.Background(), command)
	if err != nil {
		t.Fatalf("CreditLuckyGiftReward failed: %v", err)
	}
	second, err := svc.CreditLuckyGiftReward(context.Background(), command)
	if err != nil {
		t.Fatalf("CreditLuckyGiftReward retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || second.Balance.AvailableAmount != 188 {
		t.Fatalf("lucky reward receipt mismatch: first=%+v second=%+v", first, second)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", command.CommandID, "lucky_gift_reward"); got != 1 {
		t.Fatalf("lucky reward should write one transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletBalanceChanged"); got != 1 {
		t.Fatalf("lucky reward should write one balance change event, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
		t.Fatalf("lucky reward should write one reward fact event, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.country_id') = 15 AND JSON_EXTRACT(payload_json, '$.region_id') = 10", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
		t.Fatalf("lucky reward fact should carry country and region dimensions, got %d", got)
	}
}

// TestCreditWheelRewardReconciliationReturnsExistingTransaction 验证 Activity 补偿重复提交稳定 draw command 时，
// 钱包先按 command_id + request hash 找回既有交易和原回执，不会再次增加余额或重复生成 outbox。
func TestCreditWheelRewardReconciliationReturnsExistingTransaction(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	command := ledger.WheelRewardCommand{
		AppCode:         "fami",
		CommandID:       "wheel_reward:wheel_draw_reconcile_1",
		TargetUserID:    21102,
		Amount:          288,
		DrawID:          "wheel_draw_reconcile_1",
		WheelID:         "classic",
		SelectedTierID:  "classic-coin-288",
		VisibleRegionID: 10,
		Reason:          "wheel_reward",
	}

	first, err := svc.CreditWheelReward(context.Background(), command)
	if err != nil {
		t.Fatalf("CreditWheelReward failed: %v", err)
	}
	second, err := svc.CreditWheelReward(context.Background(), command)
	if err != nil {
		t.Fatalf("CreditWheelReward reconciliation retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || second.Balance.AvailableAmount != 288 {
		t.Fatalf("wheel reward reconciliation receipt mismatch: first=%+v second=%+v", first, second)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND command_id = ? AND biz_type = ?", "fami", command.CommandID, "wheel_reward"); got != 1 {
		t.Fatalf("wheel reward reconciliation must keep one transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND transaction_id = ? AND event_type = ?", "fami", first.TransactionID, "WalletBalanceChanged"); got != 1 {
		t.Fatalf("wheel reward reconciliation must keep one balance event, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND transaction_id = ? AND event_type = ?", "fami", first.TransactionID, "WalletWheelRewardCredited"); got != 1 {
		t.Fatalf("wheel reward reconciliation must keep one reward fact, got %d", got)
	}
}

// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。
func TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	_, _, 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,
		TargetCountryID: 63,
		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 != 0 {
		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)
	}
	bills, total, err := svc.ListRechargeBills(context.Background(), ledger.ListRechargeBillsQuery{
		AppCode:  appcode.Default,
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("ListRechargeBills after seller transfer failed: %v", err)
	}
	if total != 0 || len(bills) != 0 {
		t.Fatalf("finance recharge bills must hide coin seller user transfers: total=%d bills=%+v", total, bills)
	}
	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)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.target_country_id') = 63 AND JSON_EXTRACT(payload_json, '$.country_id') = 63 AND JSON_EXTRACT(payload_json, '$.target_region_id') = 1001", first.TransactionID, "WalletRechargeRecorded"); got != 1 {
		t.Fatalf("seller transfer recharge event must carry target country/region for statistics, got %d", got)
	}
}

// TestTransferCoinFromSellerAllowsSelfTransfer 验证币商可以把自己的专用库存转入自己的普通金币账户。
func TestTransferCoinFromSellerAllowsSelfTransfer(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-credit-self-seller",
		TargetUserID:   30003,
		AssetType:      ledger.AssetCoinSellerCoin,
		Amount:         100000,
		OperatorUserID: 90001,
		Reason:         "seed seller balance",
		EvidenceRef:    "ticket-self-seller",
	})
	if err != nil {
		t.Fatalf("seed seller asset failed: %v", err)
	}

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

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

// TestTransferCoinFromSellerRecordsCoinsWithoutUSDConversion 验证币商转账只按金币记录充值事实，不再走 USD 换算。
func TestTransferCoinFromSellerRecordsCoinsWithoutUSDConversion(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	_, _, err := svc.AdminCreditAsset(context.Background(), ledger.AdminCreditAssetCommand{
		CommandID:      "cmd-credit-small-seller",
		TargetUserID:   30004,
		AssetType:      ledger.AssetCoinSellerCoin,
		Amount:         11,
		OperatorUserID: 90001,
		Reason:         "seed small seller balance",
		EvidenceRef:    "ticket-small-seller",
	})
	if err != nil {
		t.Fatalf("seed seller asset failed: %v", err)
	}

	receipt, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
		CommandID:       "cmd-seller-small-transfer",
		SellerUserID:    30004,
		TargetUserID:    30004,
		TargetCountryID: 63,
		SellerRegionID:  1001,
		TargetRegionID:  1001,
		Amount:          1,
		Reason:          "self recharge",
	})
	if err != nil {
		t.Fatalf("TransferCoinFromSeller small amount failed: %v", err)
	}
	if receipt.SellerBalanceAfter != 10 || receipt.TargetBalanceAfter != 1 || receipt.RechargeUSDMinor != 0 {
		t.Fatalf("small transfer receipt mismatch: %+v", receipt)
	}
	balances, err := svc.GetBalances(context.Background(), 30004, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 10 || balanceAmount(balances, ledger.AssetCoin) != 1 {
		t.Fatalf("small transfer balances mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND policy_id = ? AND policy_version = ? AND exchange_coin_amount = ? AND exchange_usd_minor_amount = ?", receipt.TransactionID, int64(1), int64(0), int64(0), "", int64(1), int64(0)); got != 1 {
		t.Fatalf("small transfer should write recharge record with zero USD minor, 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,
		TargetCountryID: 63,
		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)
	}
}

// TestTransferCoinFromSellerDoesNotRequireRechargePolicy 验证币商转账不依赖外部充值定价策略。
func TestTransferCoinFromSellerDoesNotRequireRechargePolicy(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)
	}

	receipt, err := svc.TransferCoinFromSeller(context.Background(), ledger.CoinSellerTransferCommand{
		CommandID:       "cmd-seller-no-policy",
		SellerUserID:    32001,
		TargetUserID:    32002,
		TargetCountryID: 63,
		SellerRegionID:  2001,
		TargetRegionID:  2001,
		Amount:          80000,
		Reason:          "player recharge",
	})
	if err != nil {
		t.Fatalf("TransferCoinFromSeller without recharge policy failed: %v", err)
	}
	if receipt.RechargeUSDMinor != 0 || receipt.RechargePolicyID != 0 || receipt.RechargePolicyVersion != "" {
		t.Fatalf("policy-free transfer receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-seller-no-policy"); got != 1 {
		t.Fatalf("policy-free seller transfer should write transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND policy_id = ?", receipt.TransactionID, int64(80000), int64(0), int64(0)); got != 1 {
		t.Fatalf("policy-free seller transfer should write coin-only recharge record, got %d", got)
	}
}

// TestExchangeSalaryToCoinMovesSalaryIntoPlayerCoin 验证工资兑换按固定 1 USD = 80000 COIN 扣工资并加普通金币，重复 command 只返回同一笔事实。
func TestExchangeSalaryToCoinMovesSalaryIntoPlayerCoin(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetAssetBalance(34001, ledger.AssetAdminSalaryUSD, 2500)

	command := ledger.SalaryExchangeCommand{
		AppCode:         appcode.Default,
		CommandID:       "cmd-salary-exchange",
		UserID:          34001,
		SalaryAssetType: ledger.AssetAdminSalaryUSD,
		SalaryUSDMinor:  1000,
		Reason:          "salary exchange",
	}
	first, err := svc.ExchangeSalaryToCoin(context.Background(), command)
	if err != nil {
		t.Fatalf("ExchangeSalaryToCoin failed: %v", err)
	}
	second, err := svc.ExchangeSalaryToCoin(context.Background(), command)
	if err != nil {
		t.Fatalf("ExchangeSalaryToCoin retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SalaryBalanceAfter != 1500 || first.CoinBalanceAfter != 800000 || first.CoinAmount != 800000 || first.CoinPerUSD != ledger.SalaryExchangeCoinPerUSD {
		t.Fatalf("salary exchange receipt mismatch: first=%+v second=%+v", first, second)
	}

	assertBalance(t, svc, 34001, ledger.AssetAdminSalaryUSD, 1500)
	assertBalance(t, svc, 34001, ledger.AssetCoin, 800000)
	if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
		t.Fatalf("idempotent salary exchange should write one transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
		t.Fatalf("salary exchange should write two entries, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
		t.Fatalf("salary exchange must not write player recharge record, got %d", got)
	}
}

// TestExchangeSalaryToCoinRejectsCommandPayloadConflict 验证同 command_id 只能代表同一笔工资兑换请求。
func TestExchangeSalaryToCoinRejectsCommandPayloadConflict(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetAssetBalance(34002, ledger.AssetHostSalaryUSD, 3000)

	command := ledger.SalaryExchangeCommand{
		AppCode:         appcode.Default,
		CommandID:       "cmd-salary-exchange-conflict",
		UserID:          34002,
		SalaryAssetType: ledger.AssetHostSalaryUSD,
		SalaryUSDMinor:  500,
		Reason:          "salary exchange",
	}
	if _, err := svc.ExchangeSalaryToCoin(context.Background(), command); err != nil {
		t.Fatalf("ExchangeSalaryToCoin initial command failed: %v", err)
	}
	command.SalaryUSDMinor = 600
	if _, err := svc.ExchangeSalaryToCoin(context.Background(), command); !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("expected IDEMPOTENCY_CONFLICT for changed salary exchange payload, got %v", err)
	}
	assertBalance(t, svc, 34002, ledger.AssetHostSalaryUSD, 2500)
	assertBalance(t, svc, 34002, ledger.AssetCoin, 400000)
}

// TestTransferSalaryToCoinSellerUsesRegionRateTier 验证工资转币商按命中的区域区间扣工资并加币商专用金币，不写玩家充值事实。
func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetAssetBalance(35001, ledger.AssetBDSalaryUSD, 10000)
	repository.SetAssetBalance(35001, ledger.AssetCoin, 1234)
	repository.SetAssetBalance(35001, ledger.AssetCoinSellerCoin, 0)
	repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7001, 1100, 5000, 92000)

	command := ledger.SalaryTransferToCoinSellerCommand{
		AppCode:         appcode.Default,
		CommandID:       "cmd-salary-transfer-seller",
		SourceUserID:    35001,
		SellerUserID:    35002,
		SalaryAssetType: ledger.AssetBDSalaryUSD,
		SalaryUSDMinor:  4000,
		RegionID:        7001,
		Reason:          "salary transfer",
	}
	first, err := svc.TransferSalaryToCoinSeller(context.Background(), command)
	if err != nil {
		t.Fatalf("TransferSalaryToCoinSeller failed: %v", err)
	}
	second, err := svc.TransferSalaryToCoinSeller(context.Background(), command)
	if err != nil {
		t.Fatalf("TransferSalaryToCoinSeller retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.SourceSalaryBalanceAfter != 6000 || first.SellerBalanceAfter != 3680000 || first.CoinAmount != 3680000 || first.CoinPerUSD != 92000 || first.RateMinUSDMinor != 1100 || first.RateMaxUSDMinor != 5000 {
		t.Fatalf("salary transfer receipt mismatch: first=%+v second=%+v", first, second)
	}

	assertBalance(t, svc, 35001, ledger.AssetBDSalaryUSD, 6000)
	assertBalance(t, svc, 35002, ledger.AssetCoinSellerCoin, 3680000)
	assertBalance(t, svc, 35001, ledger.AssetCoin, 1234)
	assertBalance(t, svc, 35001, ledger.AssetCoinSellerCoin, 0)
	if got := repository.CountRows("wallet_transactions", "command_id = ?", command.CommandID); got != 1 {
		t.Fatalf("idempotent salary transfer should write one transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35001), ledger.AssetCoinSellerCoin, int64(3680000)); got != 0 {
		t.Fatalf("salary transfer must not credit sender coin seller wallet, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", first.TransactionID, int64(35002), ledger.AssetCoinSellerCoin, int64(3680000)); got != 1 {
		t.Fatalf("salary transfer must credit target coin seller wallet once, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 2 {
		t.Fatalf("salary transfer should write two entries, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
		t.Fatalf("salary transfer must not write player recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.biz_type')) = ?", first.TransactionID, "WalletBalanceChanged", "salary_transfer_to_coin_seller"); got != 2 {
		t.Fatalf("salary transfer balance events must expose biz_type for statistics, got %d", got)
	}
	transactions, total, err := svc.ListWalletTransactions(context.Background(), ledger.ListWalletTransactionsQuery{
		AppCode:   appcode.Default,
		UserID:    35002,
		AssetType: ledger.AssetCoinSellerCoin,
		Page:      1,
		PageSize:  10,
	})
	if err != nil {
		t.Fatalf("ListWalletTransactions for seller failed: %v", err)
	}
	if total != 1 || len(transactions) != 1 || transactions[0].TransferUSDMinor != 4000 || transactions[0].TransferCurrencyCode != "USD" {
		t.Fatalf("seller transfer amount history mismatch: total=%d transactions=%+v", total, transactions)
	}
}

// TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly 验证别人主播把工资转给币商时，
// 只扣主播工资并给目标币商的币商专用金币入账，主播自己的普通金币和币商金币都不能增加。
func TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetAssetBalance(35011, ledger.AssetHostSalaryUSD, 10000)
	repository.SetAssetBalance(35011, ledger.AssetCoin, 1234)
	repository.SetAssetBalance(35011, ledger.AssetCoinSellerCoin, 0)
	repository.SetAssetBalance(35012, ledger.AssetCoinSellerCoin, 1000)
	repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, 7002, 10, 10000, 81000)

	receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{
		AppCode:         appcode.Default,
		CommandID:       "cmd-host-salary-transfer-target-seller",
		SourceUserID:    35011,
		SellerUserID:    35012,
		SalaryAssetType: ledger.AssetHostSalaryUSD,
		SalaryUSDMinor:  50,
		RegionID:        7002,
		Reason:          "host salary transfer to seller",
	})
	if err != nil {
		t.Fatalf("TransferSalaryToCoinSeller host to target seller failed: %v", err)
	}
	if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 41500 || receipt.CoinAmount != 40500 {
		t.Fatalf("host salary transfer receipt mismatch: %+v", receipt)
	}

	assertBalance(t, svc, 35011, ledger.AssetHostSalaryUSD, 9950)
	assertBalance(t, svc, 35011, ledger.AssetCoin, 1234)
	assertBalance(t, svc, 35011, ledger.AssetCoinSellerCoin, 0)
	assertBalance(t, svc, 35012, ledger.AssetCoinSellerCoin, 41500)
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoin, int64(40500)); got != 0 {
		t.Fatalf("host salary transfer must not credit host player coin wallet, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35011), ledger.AssetCoinSellerCoin, int64(40500)); got != 0 {
		t.Fatalf("host salary transfer must not credit host coin seller wallet, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, int64(35012), ledger.AssetCoinSellerCoin, int64(40500)); got != 1 {
		t.Fatalf("host salary transfer must credit target seller coin seller wallet once, got %d", got)
	}
	if got := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND channel = ?", appcode.Default, int64(35011), "coin_seller"); got != 0 {
		t.Fatalf("host salary transfer without Host policy must not consume a withdrawal counter, got %d", got)
	}
}

func TestTransferHostSalaryToCoinSellerUsesPublishedPolicyLimit(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	const sourceUserID int64 = 35021
	const sellerUserID int64 = 35022
	const regionID int64 = 7003
	repository.SetAssetBalanceForApp(appcode.Default, sourceUserID, ledger.AssetHostSalaryUSD, 10_000)
	repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, regionID, 10, 10_000, 81_000)
	seedPointWithdrawalSalaryPolicy(repository, appcode.Default, 350, regionID, "month", 1, "month", 0)

	first := ledger.SalaryTransferToCoinSellerCommand{
		AppCode: appcode.Default, CommandID: "host-salary-policy-seller-1",
		SourceUserID: sourceUserID, SellerUserID: sellerUserID,
		SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 50,
		RegionID: regionID, Reason: "host salary transfer with policy",
	}
	if _, err := svc.TransferSalaryToCoinSeller(context.Background(), first); err != nil {
		t.Fatalf("first Host salary transfer within published policy limit failed: %v", err)
	}
	second := first
	second.CommandID = "host-salary-policy-seller-2"
	_, err := svc.TransferSalaryToCoinSeller(context.Background(), second)
	if !xerr.IsCode(err, xerr.PointWithdrawalLimitReached) {
		t.Fatalf("published Host policy must reject the second coin-seller transfer, got %v", err)
	}
	requireWalletBalanceForApp(t, svc, appcode.Default, sourceUserID, ledger.AssetHostSalaryUSD, 9_950, 0)
	requireWalletBalanceForApp(t, svc, appcode.Default, sellerUserID, ledger.AssetCoinSellerCoin, 40_500, 0)
	if got := repository.CountRows("point_withdrawal_policy_counters", "app_code = ? AND user_id = ? AND channel = ? AND withdrawal_count = 1", appcode.Default, sourceUserID, "coin_seller"); got != 1 {
		t.Fatalf("published Host policy must persist exactly one consumed coin-seller slot, got %d", got)
	}
}

// TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets 验证同一用户同时具备主播/代理/BD/Admin 和币商身份时，
// 工资转币商允许 source_user_id == seller_user_id；账务只扣对应身份工资钱包并增加币商专用金币，不增加玩家普通金币。
func TestTransferSalaryToCoinSellerAllowsSelfSellerForAllSalaryAssets(t *testing.T) {
	tests := []struct {
		name      string
		assetType string
		userID    int64
		regionID  int64
	}{
		{name: "host", assetType: ledger.AssetHostSalaryUSD, userID: 35101, regionID: 7101},
		{name: "agency", assetType: ledger.AssetAgencySalaryUSD, userID: 35102, regionID: 7102},
		{name: "bd", assetType: ledger.AssetBDSalaryUSD, userID: 35103, regionID: 7103},
		{name: "admin", assetType: ledger.AssetAdminSalaryUSD, userID: 35104, regionID: 7104},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			repository := mysqltest.NewRepository(t)
			svc := walletservice.New(repository)
			repository.SetAssetBalance(test.userID, test.assetType, 10000)
			repository.SetAssetBalance(test.userID, ledger.AssetCoin, 1234)
			repository.SetCoinSellerSalaryExchangeRateTier(appcode.Default, test.regionID, 10, 10000, 81000)

			receipt, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{
				AppCode:         appcode.Default,
				CommandID:       "cmd-salary-self-" + test.name,
				SourceUserID:    test.userID,
				SellerUserID:    test.userID,
				SalaryAssetType: test.assetType,
				SalaryUSDMinor:  50,
				RegionID:        test.regionID,
				Reason:          "salary transfer to own coin seller wallet",
			})
			if err != nil {
				t.Fatalf("TransferSalaryToCoinSeller self %s failed: %v", test.name, err)
			}
			if receipt.SourceSalaryBalanceAfter != 9950 || receipt.SellerBalanceAfter != 40500 || receipt.CoinAmount != 40500 || receipt.CoinPerUSD != 81000 || receipt.RateMinUSDMinor != 10 || receipt.RateMaxUSDMinor != 10000 {
				t.Fatalf("self salary transfer receipt mismatch: %+v", receipt)
			}

			assertBalance(t, svc, test.userID, test.assetType, 9950)
			assertBalance(t, svc, test.userID, ledger.AssetCoinSellerCoin, 40500)
			assertBalance(t, svc, test.userID, ledger.AssetCoin, 1234)
			if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, test.assetType, int64(-50)); got != 1 {
				t.Fatalf("self transfer must debit salary asset once, got %d", got)
			}
			if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ? AND available_delta = ?", receipt.TransactionID, test.userID, ledger.AssetCoinSellerCoin, int64(40500)); got != 1 {
				t.Fatalf("self transfer must credit coin seller asset once, got %d", got)
			}
			if got := repository.CountRows("wallet_entries", "transaction_id = ? AND user_id = ? AND asset_type = ?", receipt.TransactionID, test.userID, ledger.AssetCoin); got != 0 {
				t.Fatalf("self transfer must not credit player coin wallet, got %d", got)
			}
			if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", receipt.TransactionID); got != 0 {
				t.Fatalf("salary transfer must not write player recharge record, got %d", got)
			}
		})
	}
}

// TestTransferSalaryToCoinSellerRequiresConfiguredRate 验证当前区域没有可命中的工资转币商比例时不会产生半成品账务。
func TestTransferSalaryToCoinSellerRequiresConfiguredRate(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetAssetBalance(35003, ledger.AssetAgencySalaryUSD, 10000)

	_, err := svc.TransferSalaryToCoinSeller(context.Background(), ledger.SalaryTransferToCoinSellerCommand{
		AppCode:         appcode.Default,
		CommandID:       "cmd-salary-transfer-no-rate",
		SourceUserID:    35003,
		SellerUserID:    35004,
		SalaryAssetType: ledger.AssetAgencySalaryUSD,
		SalaryUSDMinor:  4000,
		RegionID:        8001,
		Reason:          "salary transfer",
	})
	if !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("expected NOT_FOUND when salary transfer rate is missing, got %v", err)
	}
	assertBalance(t, svc, 35003, ledger.AssetAgencySalaryUSD, 10000)
	assertBalance(t, svc, 35004, ledger.AssetCoinSellerCoin, 0)
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-salary-transfer-no-rate"); got != 0 {
		t.Fatalf("missing salary transfer rate must not write transaction, got %d", got)
	}
}

// TestH5RechargeOptionsFilterMethodsByUserCountry 验证 H5 支付方式严格按目标用户国家返回，同时按普通用户/币商区分商品档位。
func TestH5RechargeOptionsFilterMethodsByUserCountry(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	svc.SetMifaPayClient(&fakeMifaPayClient{})
	normalProduct := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7100, 1_500_000, 120000)
	sellerProduct := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7100, 10_000_000, 880000)

	normalOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
		AppCode:           appcode.Default,
		TargetUserID:      41001,
		TargetRegionID:    7100,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceNormal,
	})
	if err != nil {
		t.Fatalf("ListH5RechargeOptions normal failed: %v", err)
	}
	if len(normalOptions.Products) != 1 || normalOptions.Products[0].ProductID != normalProduct.ProductID {
		t.Fatalf("normal h5 products must only include normal product: %+v", normalOptions.Products)
	}
	if !paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Card", "MADA") ||
		!paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Ewallet", "ApplePay") ||
		!paymentMethodsContain(normalOptions.PaymentMethods, "SA", "Ewallet", "STCPay") ||
		paymentMethodsContain(normalOptions.PaymentMethods, "ID", "BankTransfer", "BCA") {
		t.Fatalf("SA options must only expose SA MiFaPay methods: %+v", normalOptions.PaymentMethods)
	}
	for _, method := range normalOptions.PaymentMethods {
		if method.CountryCode != "SA" && method.CountryCode != "GLOBAL" {
			t.Fatalf("payment method country leaked for SA user: %+v", method)
		}
	}

	sellerOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
		AppCode:           appcode.Default,
		TargetUserID:      41002,
		TargetRegionID:    7100,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceCoinSeller,
	})
	if err != nil {
		t.Fatalf("ListH5RechargeOptions coin seller failed: %v", err)
	}
	if len(sellerOptions.Products) != 1 || sellerOptions.Products[0].ProductID != sellerProduct.ProductID {
		t.Fatalf("coin seller h5 products must only include seller product: %+v", sellerOptions.Products)
	}
	if !paymentMethodsContain(sellerOptions.PaymentMethods, "SA", "Card", "MADA") || paymentMethodsContain(sellerOptions.PaymentMethods, "ID", "BankTransfer", "BCA") {
		t.Fatalf("coin seller SA options must use the same country-filtered MiFaPay methods: %+v", sellerOptions.PaymentMethods)
	}
}

func TestH5RechargeOptionsFilterV5PayByRuntimeProductTypes(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	v5Pay := &fakeV5PayClient{
		productTypes: map[string]walletservice.V5PayProductTypesResponse{
			"PH|PHP": {
				PayinList: []walletservice.V5PayProductType{
					{ProductType: "COINS_ONLINE", ProductName: "Coins.ph", Currency: "PHP", SupportCashierMode: 1},
					{ProductType: "GCASH_ONLINE", ProductName: "GCash", Currency: "PHP", SupportCashierMode: 1},
					{ProductType: "INSTAPAY_QR", ProductName: "QRPH", Currency: "PHP", SupportCashierMode: 1},
					{ProductType: "QRPH_GCASH", ProductName: "QRPh GCash", Currency: "PHP", SupportCashierMode: 1},
					{ProductType: "TEST_BANK", ProductName: "Test Bank", Currency: "PHP", SupportCashierMode: 1},
					{ProductType: "SHOPEE_PAY", ProductName: "Shopee Pay", Currency: "PHP", SupportCashierMode: 1},
				},
			},
		},
		productErrs: map[string]error{
			"SA|SAR": xerr.New(xerr.Unavailable, "v5pay product types rejected: 1013: app is invalid"),
		},
	}
	svc.SetV5PayClient(v5Pay)
	repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7150, 1_000_000, 800000)

	phOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
		AppCode:           appcode.Default,
		TargetUserID:      41101,
		TargetRegionID:    7150,
		TargetCountryCode: "PH",
		AudienceType:      ledger.RechargeAudienceCoinSeller,
	})
	if err != nil {
		t.Fatalf("ListH5RechargeOptions PH failed: %v", err)
	}
	if !paymentMethodsContain(phOptions.PaymentMethods, "PH", "Ewallet", "COINS_ONLINE") ||
		!paymentMethodsContain(phOptions.PaymentMethods, "PH", "Ewallet", "GCASH_ONLINE") ||
		!paymentMethodsContain(phOptions.PaymentMethods, "PH", "QR", "INSTAPAY_QR") ||
		paymentMethodsContain(phOptions.PaymentMethods, "PH", "BankTransfer", "UB_ONLINE") {
		t.Fatalf("PH options must only expose V5Pay runtime-enabled cashier products: %+v", phOptions.PaymentMethods)
	}

	saOptions, err := svc.ListH5RechargeOptions(context.Background(), ledger.H5RechargeOptionsQuery{
		AppCode:           appcode.Default,
		TargetUserID:      41102,
		TargetRegionID:    7150,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceCoinSeller,
	})
	if err != nil {
		t.Fatalf("ListH5RechargeOptions SA failed: %v", err)
	}
	if paymentMethodsContain(saOptions.PaymentMethods, "SA", "Ewallet", "STCPAY") ||
		paymentMethodsContain(saOptions.PaymentMethods, "SA", "Card", "MADA") {
		t.Fatalf("SA V5Pay methods must be hidden when app is invalid for SA/SAR: %+v", saOptions.PaymentMethods)
	}
}

func TestSyncThirdPartyPaymentMethodsAddsV5PayRuntimeProducts(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	phCoinsMethodID := repository.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderV5Pay, "PH", "Ewallet", "COINS_ONLINE")
	repository.SetThirdPartyPaymentRate(phCoinsMethodID, "52.50000000")
	v5Pay := &fakeV5PayClient{
		productTypes: map[string]walletservice.V5PayProductTypesResponse{
			"PH|PHP": {
				PayinList: []walletservice.V5PayProductType{
					{ProductType: "COINS_ONLINE", ProductName: "Coins.ph Online Updated", Currency: "PHP", ProductMode: "Ewallet", SupportCashierMode: 1},
				},
			},
			"TR|TRY": {
				PayinList: []walletservice.V5PayProductType{
					{ProductType: "CREDIT_CARD", ProductName: "Credit Card", Currency: "TRY", ProductMode: "Card", SupportCashierMode: 1},
					{ProductType: "PAPARA_ONLINE", ProductName: "Papara Online", Currency: "TRY", ProductMode: "Ewallet", SupportCashierMode: 1},
					{ProductType: "PAPARA_QR", ProductName: "Papara QR", Currency: "TRY", ProductMode: "QR", SupportCashierMode: 0},
				},
			},
		},
		productErrs: map[string]error{
			"MX|MXN": fakeProviderPayloadError{message: "v5pay product types rejected", rawJSON: `{"code":"1013","message":"app is invalid"}`},
		},
	}
	svc.SetV5PayClient(v5Pay)

	result, err := svc.SyncThirdPartyPaymentMethods(context.Background(), ledger.ThirdPartyPaymentMethodSyncCommand{
		AppCode:        appcode.Default,
		ProviderCode:   ledger.PaymentProviderV5Pay,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("SyncThirdPartyPaymentMethods failed: %v", err)
	}
	if result.CreatedCount != 2 || result.UpdatedCount != 1 || result.SkippedCount != 1 || len(result.Issues) != 1 || result.Issues[0].Code != "1013" {
		t.Fatalf("sync result mismatch: %+v", result)
	}
	if !v5PayProductRequestSeen(v5Pay.productReqs, "TR", "TRY") || !v5PayProductRequestSeen(v5Pay.productReqs, "MX", "MXN") {
		t.Fatalf("sync must scan configured V5Pay country/currency candidates: %+v", v5Pay.productReqs)
	}

	channels, err := svc.ListThirdPartyPaymentChannels(context.Background(), ledger.ListThirdPartyPaymentChannelsQuery{
		AppCode:                appcode.Default,
		ProviderCode:           ledger.PaymentProviderV5Pay,
		IncludeDisabledMethods: true,
	})
	if err != nil {
		t.Fatalf("ListThirdPartyPaymentChannels after sync failed: %v", err)
	}
	methods := channels[0].Methods
	creditCard, ok := thirdPartyPaymentMethodByKey(methods, "TR", "Card", "CREDIT_CARD")
	if !ok || creditCard.CurrencyCode != "TRY" || creditCard.USDToCurrencyRate != "0.00000000" {
		t.Fatalf("TR credit card should be inserted with unset rate: %+v ok=%v", creditCard, ok)
	}
	papara, ok := thirdPartyPaymentMethodByKey(methods, "TR", "Ewallet", "PAPARA_ONLINE")
	if !ok || papara.MethodName != "Papara Online" {
		t.Fatalf("TR Papara should be inserted from V5Pay product: %+v ok=%v", papara, ok)
	}
	phCoins, ok := thirdPartyPaymentMethodByKey(methods, "PH", "Ewallet", "COINS_ONLINE")
	if !ok || phCoins.MethodName != "Coins.ph Online Updated" || phCoins.USDToCurrencyRate != "52.50000000" {
		t.Fatalf("existing PH method should update display fields but preserve rate: %+v ok=%v", phCoins, ok)
	}
}

// TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry 锁定下单阶段再次校验国家，防止前端伪造 method_id 绕过 options 过滤。
func TestH5MifaPayOrderRejectsPaymentMethodFromAnotherCountry(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	svc.SetMifaPayClient(&fakeMifaPayClient{})
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7200, 1_500_000, 120000)
	idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA")

	_, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-cross-country-method",
		TargetUserID:      42001,
		TargetRegionID:    7200,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderMifaPay,
		PaymentMethodID:   idBankMethodID,
	})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("expected CONFLICT for cross-country MiFaPay method, got %v", err)
	}
}

// TestH5MifaPayOrderRoundsZeroDecimalCurrency 锁定 IDR 等零小数币种的三方下单金额：
// MiFaPay 按 fixed minor 接收 amount，但会校验 IDR 主单位不能带小数，因此本地要把主单位先归整。
func TestH5MifaPayOrderRoundsZeroDecimalCurrency(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7201, 300_000_000, 28_200_000)
	idBankMethodID := repository.ThirdPartyPaymentMethodID("ID", "BankTransfer", "BCA")
	repository.SetThirdPartyPaymentRate(idBankMethodID, "17706.65883333")

	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-mifapay-idr-integer",
		TargetUserID:      42002,
		TargetRegionID:    7201,
		TargetCountryCode: "ID",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderMifaPay,
		PaymentMethodID:   idBankMethodID,
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder IDR failed: %v", err)
	}
	if order.ProviderAmountMinor != 531_199_800 || mifaPay.createReq.ProviderAmountMinor != 531_199_800 {
		t.Fatalf("IDR amount must be rounded to whole major units before fixed-minor submit: order=%+v req=%+v", order, mifaPay.createReq)
	}
}

// TestH5MifaPayOrderUsesWholeINRProviderAmount 锁定印度 MiFaPay 的金额边界：上游 amount 传整数卢比，
// 且 1 USD 档位按 99 INR 汇率会低于网关 100 INR 下限，本地订单必须保存实际提交的 100.00 INR。
func TestH5MifaPayOrderUsesWholeINRProviderAmount(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7210, 1_000_000, 80300)
	inPaytmMethodID := repository.ThirdPartyPaymentMethodID("IN", "Ewallet", "Paytm")
	repository.SetThirdPartyPaymentRate(inPaytmMethodID, "99.00000000")

	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-mifapay-inr-minimum",
		TargetUserID:      42003,
		TargetRegionID:    7210,
		TargetCountryCode: "IN",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderMifaPay,
		PaymentMethodID:   inPaytmMethodID,
		PayerName:         "normal55",
		PayerAccount:      "163055",
		PayerEmail:        "normal55@lalu.com",
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder INR failed: %v", err)
	}
	if order.ProviderAmountMinor != 10_000 || mifaPay.createReq.ProviderAmountMinor != 10_000 {
		t.Fatalf("INR amount must respect MiFaPay 100 INR minimum: order=%+v req=%+v", order, mifaPay.createReq)
	}

	mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
		OrderID:         order.OrderID,
		ProviderOrderID: order.ProviderOrderID,
		Status:          "1",
		Amount:          "100",
		Currency:        order.CurrencyCode,
		PayType:         order.PayType,
		RawJSON:         `{"status":"1","amount":"100","currency":"INR"}`,
	}
	credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
	if err != nil {
		t.Fatalf("GetH5RechargeOrder INR query failed: %v", err)
	}
	if credited.Status != ledger.ExternalRechargeStatusCredited || credited.ProviderAmountMinor != 10_000 {
		t.Fatalf("INR query should match whole-major MiFaPay amount and credit: %+v", credited)
	}
}

// TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable 锁定 MiFaPay 网关拒单的边界：本地订单要落失败便于排障，
// 对外错误必须保持上游依赖失败语义，不能伪装成商品、幂等或状态冲突。
func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{err: fakeMifaPayProviderPayloadError{
		cause:   xerr.New(xerr.Unavailable, "mifapay order rejected: 100999: System exception"),
		payload: `{"code":"100999","msg":"System exception"}`,
	}}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7250, 1_500_000, 120000)
	saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA")

	_, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-mifapay-system-error",
		TargetUserID:      42501,
		TargetRegionID:    7250,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderMifaPay,
		PaymentMethodID:   saCardMethodID,
		ReturnURL:         "https://h5.global-interaction.com/recharge/index.html?app_code=lalu#checkout",
		Language:          "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
		PayerName:         "normal55",
		PayerAccount:      "163055",
		PayerEmail:        "163055@lalu.com",
	})
	if !xerr.IsCode(err, xerr.Unavailable) {
		t.Fatalf("expected UNAVAILABLE for MiFaPay gateway rejection, got %v", err)
	}
	if mifaPay.createReq.PayerName != "normal55" || mifaPay.createReq.PayerAccount != "163055" || mifaPay.createReq.PayerEmail != "163055@lalu.com" {
		t.Fatalf("mifapay payer snapshot mismatch: %+v", mifaPay.createReq)
	}
	if mifaPay.createReq.Language != "zh-CN" {
		t.Fatalf("mifapay language should be normalized before upstream call: %+v", mifaPay.createReq)
	}
	if mifaPay.createReq.ReturnURL != "https://h5.global-interaction.com/recharge/index.html" {
		t.Fatalf("mifapay return url should be sent as stable provider redirect URL: %+v", mifaPay.createReq)
	}
	if len(mifaPay.createReq.OrderID) != 32 {
		t.Fatalf("mifapay order_id must be 32 chars for upstream compatibility, got %q", mifaPay.createReq.OrderID)
	}
	if got := repository.CountRows("external_recharge_orders", "command_id = ? AND CHAR_LENGTH(order_id) = 32", "cmd-h5-mifapay-system-error"); got != 1 {
		t.Fatalf("mifapay local external order should also use 32-char order_id, got %d", got)
	}
	if got := repository.CountRows("external_recharge_orders", "command_id = ? AND status = ? AND failure_reason LIKE ? AND JSON_UNQUOTE(JSON_EXTRACT(provider_payload, '$.code')) = ?", "cmd-h5-mifapay-system-error", ledger.ExternalRechargeStatusFailed, "UNAVAILABLE:%System exception%", "100999"); got != 1 {
		t.Fatalf("mifapay upstream rejection should persist one failed external order, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-h5-mifapay-system-error"); got != 0 {
		t.Fatalf("failed mifapay order must not write wallet transaction, got %d", got)
	}
}

func TestH5MifaPayStatusQueryCreditsOrder(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-success", 42601, 7260)
	mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
		OrderID:         order.OrderID,
		ProviderOrderID: order.ProviderOrderID,
		Status:          "1",
		Amount:          "100",
		Currency:        order.CurrencyCode,
		PayType:         order.PayType,
		RawJSON:         `{"status":"1"}`,
	}

	credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
	if err != nil {
		t.Fatalf("GetH5RechargeOrder query success failed: %v", err)
	}
	if mifaPay.queryReq.ProviderOrderID != order.ProviderOrderID || mifaPay.queryReq.OrderID != order.OrderID {
		t.Fatalf("mifapay query input mismatch: %+v", mifaPay.queryReq)
	}
	if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
		t.Fatalf("mifapay query should credit successful order: %+v", credited)
	}
	assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 120000)
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
		t.Fatalf("mifapay query credit should write one recharge record, got %d", got)
	}
	bills, total, err := svc.ListRechargeBills(context.Background(), ledger.ListRechargeBillsQuery{
		AppCode:  appcode.Default,
		Status:   "succeeded",
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("ListRechargeBills failed: %v", err)
	}
	if total != 1 || len(bills) != 1 || bills[0].TransactionID != credited.TransactionID || bills[0].ProviderAmountMinor != order.ProviderAmountMinor {
		t.Fatalf("recharge bill should expose provider paid amount: total=%d bills=%+v order=%+v", total, bills, order)
	}
	if got := repository.CountRows("external_recharge_orders", "order_id = ? AND target_country_id = ?", order.OrderID, order.TargetCountryID); got != 1 {
		t.Fatalf("mifapay local order should persist target country id, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ?", credited.TransactionID, "WalletRechargeRecorded", order.TargetCountryID, order.TargetCountryID, order.TargetRegionID); got != 1 {
		t.Fatalf("mifapay recharge fact should carry target country/region for BI, got %d", got)
	}
}

func TestH5MifaPayCoinSellerCreditsSellerStockUSDTEquivalent(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7265, 10_000_000, 880000)
	saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA")
	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-mifapay-seller-query-success",
		TargetUserID:      42605,
		TargetRegionID:    7265,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceCoinSeller,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderMifaPay,
		PaymentMethodID:   saCardMethodID,
		PayerName:         "seller55",
		PayerAccount:      "163055",
		PayerEmail:        "seller55@lalu.com",
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder seller mifapay failed: %v", err)
	}
	mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
		OrderID:         order.OrderID,
		ProviderOrderID: order.ProviderOrderID,
		Status:          "1",
		Amount:          "1000",
		Currency:        order.CurrencyCode,
		PayType:         order.PayType,
		RawJSON:         `{"status":"1"}`,
	}

	credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
	if err != nil {
		t.Fatalf("GetH5RechargeOrder seller query success failed: %v", err)
	}
	if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
		t.Fatalf("mifapay seller query should credit order: %+v", credited)
	}
	assertBalance(t, svc, order.TargetUserID, ledger.AssetCoinSellerCoin, 880000)
	assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 0)
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 0 {
		t.Fatalf("seller mifapay recharge must not write user recharge record, got %d", got)
	}
	if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_currency_code = ? AND paid_amount_micro = ?", credited.TransactionID, int64(880000), ledger.PaidCurrencyUSDT, int64(10_000_000)); got != 1 {
		t.Fatalf("seller mifapay recharge should write one usdt-equivalent seller stock record, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "transaction_id = ? AND biz_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.paid_currency_code')) = ? AND JSON_EXTRACT(metadata_json, '$.paid_amount_micro') = 10000000", credited.TransactionID, "coin_seller_recharge", ledger.PaidCurrencyUSDT); got != 1 {
		t.Fatalf("seller mifapay transaction metadata should use usdt-equivalent cumulative amount, got %d", got)
	}
}

func TestH5MifaPayStatusQueryMarksFailedOrder(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-failed", 42602, 7261)
	mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
		OrderID:         order.OrderID,
		ProviderOrderID: order.ProviderOrderID,
		Status:          "4",
		Amount:          "100",
		Currency:        order.CurrencyCode,
		PayType:         order.PayType,
		RawJSON:         `{"status":"4"}`,
	}

	failed, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
	if err != nil {
		t.Fatalf("GetH5RechargeOrder query failed status failed: %v", err)
	}
	if failed.Status != ledger.ExternalRechargeStatusFailed || !strings.Contains(failed.FailureReason, "mifapay order status 4") {
		t.Fatalf("mifapay query should mark failed status: %+v", failed)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", order.CommandID); got != 0 {
		t.Fatalf("failed mifapay query must not write wallet transaction, got %d", got)
	}
}

func TestH5MifaPayStatusQueryRejectsOrderMismatch(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	mifaPay := &fakeMifaPayClient{}
	svc.SetMifaPayClient(mifaPay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
	order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-mismatch", 42603, 7262)
	mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
		OrderID:         "other_" + order.OrderID,
		ProviderOrderID: order.ProviderOrderID,
		Status:          "1",
		Amount:          "100",
		Currency:        order.CurrencyCode,
		PayType:         order.PayType,
		RawJSON:         `{"status":"1","orderId":"other"}`,
	}

	failed, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
	if err != nil {
		t.Fatalf("GetH5RechargeOrder mismatch query failed: %v", err)
	}
	if failed.Status != ledger.ExternalRechargeStatusFailed || !strings.Contains(failed.FailureReason, "mifapay query order data mismatch") {
		t.Fatalf("mifapay query mismatch should fail local order: %+v", failed)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", order.CommandID); got != 0 {
		t.Fatalf("mifapay query mismatch must not write wallet transaction, got %d", got)
	}
}

func TestH5MifaPayStatusQueryKeepsWaitingOnPendingAndQueryError(t *testing.T) {
	for _, tc := range []struct {
		name     string
		status   string
		queryErr error
	}{
		{name: "unpaid", status: "0"},
		{name: "processing", status: "5"},
		{name: "query-error", queryErr: xerr.New(xerr.Unavailable, "mifapay query temporary error")},
	} {
		t.Run(tc.name, func(t *testing.T) {
			repository := mysqltest.NewRepository(t)
			svc := walletservice.New(repository)
			mifaPay := &fakeMifaPayClient{queryErr: tc.queryErr}
			svc.SetMifaPayClient(mifaPay)
			svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{MifaPayNotifyURL: "https://api.example.com/api/v1/payment/mifapay/notify"})
			order := createRedirectedMifaPayOrder(t, repository, svc, "cmd-h5-mifapay-query-"+tc.name, 42610, 7270)
			mifaPay.queryResp = walletservice.MifaPayQueryOrderResponse{
				OrderID:         order.OrderID,
				ProviderOrderID: order.ProviderOrderID,
				Status:          tc.status,
				Amount:          "100",
				Currency:        order.CurrencyCode,
				PayType:         order.PayType,
				RawJSON:         `{"status":"` + tc.status + `"}`,
			}

			current, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
			if err != nil {
				t.Fatalf("GetH5RechargeOrder should keep local order on %s: %v", tc.name, err)
			}
			if current.Status != ledger.ExternalRechargeStatusRedirected || current.TransactionID != "" {
				t.Fatalf("mifapay query should keep waiting order on %s: %+v", tc.name, current)
			}
			if got := repository.CountRows("wallet_transactions", "command_id = ?", order.CommandID); got != 0 {
				t.Fatalf("waiting mifapay query must not write wallet transaction, got %d", got)
			}
		})
	}
}

func createRedirectedMifaPayOrder(t *testing.T, repository *mysqltest.Repository, svc *walletservice.Service, commandID string, targetUserID int64, targetRegionID int64) ledger.ExternalRechargeOrder {
	t.Helper()
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, targetRegionID, 1_000_000, 120000)
	saCardMethodID := repository.ThirdPartyPaymentMethodID("SA", "Card", "MADA")
	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         commandID,
		TargetUserID:      targetUserID,
		TargetRegionID:    targetRegionID,
		TargetCountryID:   63,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderMifaPay,
		PaymentMethodID:   saCardMethodID,
		PayerName:         "normal55",
		PayerAccount:      "163055",
		PayerEmail:        "163055@lalu.com",
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder mifapay failed: %v", err)
	}
	if order.Status != ledger.ExternalRechargeStatusRedirected || order.ProviderOrderID == "" || order.PayURL == "" {
		t.Fatalf("mifapay test order should be redirected: %+v", order)
	}
	return order
}

func TestExternalRechargeReconcileCreditsV5PayOrderWithoutH5Return(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	v5Pay := &fakeV5PayClient{}
	svc.SetV5PayClient(v5Pay)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{V5PayNotifyURL: "https://api.example.com/api/v1/payment/v5pay/notify"})
	order := createRedirectedV5PayOrder(t, repository, svc, "cmd-h5-v5pay-reconcile-success", 42620, 7280)
	v5Pay.queryResp = walletservice.V5PayQueryOrderResponse{
		OrderID:         order.OrderID,
		ProviderOrderID: order.ProviderOrderID,
		Status:          "2",
		Amount:          "1.00",
		Currency:        order.CurrencyCode,
		ProductType:     order.PayType,
		RawJSON:         `{"orderStatus":"2"}`,
	}

	processed, err := svc.ReconcileExternalRechargeOrders(context.Background(), appcode.Default, 10)
	if err != nil {
		t.Fatalf("ReconcileExternalRechargeOrders v5pay failed: %v", err)
	}
	if processed != 1 {
		t.Fatalf("v5pay reconcile should process one redirected order, got %d", processed)
	}
	if v5Pay.queryReq.ProviderOrderID != order.ProviderOrderID || v5Pay.queryReq.OrderID != order.OrderID || v5Pay.queryReq.CountryCode != "PH" || v5Pay.queryReq.CurrencyCode != "PHP" {
		t.Fatalf("v5pay reconcile query input mismatch: %+v", v5Pay.queryReq)
	}
	credited, err := svc.GetH5RechargeOrder(context.Background(), appcode.Default, order.OrderID, order.TargetUserID)
	if err != nil {
		t.Fatalf("GetH5RechargeOrder after reconcile failed: %v", err)
	}
	if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
		t.Fatalf("v5pay reconcile should credit successful order: %+v", credited)
	}
	assertBalance(t, svc, order.TargetUserID, ledger.AssetCoin, 120000)
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
		t.Fatalf("v5pay reconcile credit should write one recharge record, got %d", got)
	}
	processed, err = svc.ReconcileExternalRechargeOrders(context.Background(), appcode.Default, 10)
	if err != nil {
		t.Fatalf("second v5pay reconcile failed: %v", err)
	}
	if processed != 0 {
		t.Fatalf("credited v5pay order must not stay in reconcile queue, got %d", processed)
	}
}

func createRedirectedV5PayOrder(t *testing.T, repository *mysqltest.Repository, svc *walletservice.Service, commandID string, targetUserID int64, targetRegionID int64) ledger.ExternalRechargeOrder {
	t.Helper()
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, targetRegionID, 1_000_000, 120000)
	phQRMethodID := repository.ThirdPartyProviderPaymentMethodID(ledger.PaymentProviderV5Pay, "PH", "QR", "INSTAPAY_QR")
	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         commandID,
		TargetUserID:      targetUserID,
		TargetRegionID:    targetRegionID,
		TargetCountryCode: "PH",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderV5Pay,
		PaymentMethodID:   phQRMethodID,
		PayerName:         "normal65",
		PayerAccount:      "163065",
		PayerEmail:        "normal65@lalu.com",
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder v5pay failed: %v", err)
	}
	if order.Status != ledger.ExternalRechargeStatusRedirected || order.ProviderOrderID == "" || order.PayURL == "" || order.PayType != "INSTAPAY_QR" {
		t.Fatalf("v5pay test order should be redirected: %+v", order)
	}
	return order
}

// TestH5USDTRechargeCreditsNormalUserCoinWallet 验证 H5 普通用户 USDT 充值只进入普通 COIN 钱包，并写普通充值记录。
func TestH5USDTRechargeCreditsNormalUserCoinWallet(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	tron := &fakeTronUSDTClient{}
	svc.SetTronUSDTClient(tron)
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
		USDTTRC20Enabled: true,
		USDTTRC20Address: "TPlatformReceiveAddress",
	})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceNormal, 7300, 1_500_000, 120000)

	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-normal-usdt",
		TargetUserID:      43001,
		TargetRegionID:    7300,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceNormal,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderUSDTTRC20,
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder normal usdt failed: %v", err)
	}
	if order.Status != ledger.ExternalRechargeStatusPending || order.ReceiveAddress != "TPlatformReceiveAddress" || order.USDMinorAmount != 150 {
		t.Fatalf("normal usdt order snapshot mismatch: %+v", order)
	}
	credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{
		AppCode:      appcode.Default,
		OrderID:      order.OrderID,
		TargetUserID: 43001,
		TxHash:       "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
	})
	if err != nil {
		t.Fatalf("SubmitH5RechargeTx normal failed: %v", err)
	}
	if credited.Status != ledger.ExternalRechargeStatusCredited || credited.TransactionID == "" {
		t.Fatalf("normal usdt credited order mismatch: %+v", credited)
	}
	if tron.lastTxHash != "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" || tron.lastToAddress != "TPlatformReceiveAddress" || tron.lastAmountMinor != 150 {
		t.Fatalf("tron verification input mismatch: %+v", tron)
	}
	assertBalance(t, svc, 43001, ledger.AssetCoin, 120000)
	assertBalance(t, svc, 43001, ledger.AssetCoinSellerCoin, 0)
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 1 {
		t.Fatalf("normal h5 recharge should write one user recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ?", int64(43001)); got != 1 {
		t.Fatalf("normal h5 recharge should update user recharge stats, got %d", got)
	}
	if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ?", credited.TransactionID); got != 0 {
		t.Fatalf("normal h5 recharge must not write seller stock record, got %d", got)
	}
}

// TestH5USDTRechargeCreditsCoinSellerWalletOnly 验证 H5 币商充值只进入币商专用钱包，并写币商库存记录而不是普通用户充值记录。
func TestH5USDTRechargeCreditsCoinSellerWalletOnly(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	svc.SetTronUSDTClient(&fakeTronUSDTClient{})
	svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
		USDTTRC20Enabled: true,
		USDTTRC20Address: "TPlatformReceiveAddress",
	})
	product := repository.CreateH5RechargeProduct(ledger.RechargeAudienceCoinSeller, 7400, 10_000_000, 880000)

	order, err := svc.CreateH5RechargeOrder(context.Background(), ledger.CreateExternalRechargeOrderCommand{
		AppCode:           appcode.Default,
		CommandID:         "cmd-h5-seller-usdt",
		TargetUserID:      44001,
		TargetRegionID:    7400,
		TargetCountryCode: "SA",
		AudienceType:      ledger.RechargeAudienceCoinSeller,
		ProductID:         product.ProductID,
		ProviderCode:      ledger.PaymentProviderUSDTTRC20,
	})
	if err != nil {
		t.Fatalf("CreateH5RechargeOrder seller usdt failed: %v", err)
	}
	credited, err := svc.SubmitH5RechargeTx(context.Background(), ledger.SubmitExternalRechargeTxCommand{
		AppCode:      appcode.Default,
		OrderID:      order.OrderID,
		TargetUserID: 44001,
		TxHash:       "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
	})
	if err != nil {
		t.Fatalf("SubmitH5RechargeTx seller failed: %v", err)
	}
	assertBalance(t, svc, 44001, ledger.AssetCoinSellerCoin, 880000)
	assertBalance(t, svc, 44001, ledger.AssetCoin, 0)
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", credited.TransactionID); got != 0 {
		t.Fatalf("seller h5 recharge must not write user recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ?", int64(44001)); got != 0 {
		t.Fatalf("seller h5 recharge must not update user recharge stats, got %d", got)
	}
	if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_currency_code = ? AND paid_amount_micro = ?", credited.TransactionID, int64(880000), ledger.PaidCurrencyUSDT, int64(10_000_000)); got != 1 {
		t.Fatalf("seller h5 recharge should write one seller stock record, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", credited.TransactionID, "WalletCoinSellerStockPurchased"); got != 1 {
		t.Fatalf("seller h5 recharge should emit seller stock outbox event, 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,
		SellerCountryID: 63,
		SellerRegionID:  7,
		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)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.seller_country_id') = 63 AND JSON_EXTRACT(payload_json, '$.seller_region_id') = 7 AND JSON_EXTRACT(payload_json, '$.country_id') = 63 AND JSON_EXTRACT(payload_json, '$.region_id') = 7", first.TransactionID, "WalletCoinSellerStockPurchased"); got != 1 {
		t.Fatalf("stock outbox must carry seller country/region for statistics, got %d", got)
	}
}

// TestAdminCreditCoinSellerStockDeductionDebitsDedicatedBalanceAndRechargeAmount 验证 USDT 扣除会同时冲回库存和币商充值金额。
func TestAdminCreditCoinSellerStockDeductionDebitsDedicatedBalanceAndRechargeAmount(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	credit := ledger.CoinSellerStockCreditCommand{
		CommandID:       "cmd-stock-usdt-deduct-seed",
		SellerUserID:    33011,
		SellerCountryID: 63,
		SellerRegionID:  7,
		StockType:       ledger.StockTypeUSDTPurchase,
		CoinAmount:      8000000,
		PaidAmountMicro: 100000000,
		OperatorUserID:  90001,
	}
	if _, err := svc.AdminCreditCoinSellerStock(context.Background(), credit); err != nil {
		t.Fatalf("seed stock purchase failed: %v", err)
	}

	deduction := ledger.CoinSellerStockCreditCommand{
		CommandID:       "cmd-stock-usdt-deduct",
		SellerUserID:    33011,
		SellerCountryID: 63,
		SellerRegionID:  7,
		StockType:       ledger.StockTypeUSDTDeduction,
		CoinAmount:      3000000,
		PaidAmountMicro: 37500000,
		OperatorUserID:  90002,
		Reason:          "reverse bad stock",
	}
	first, err := svc.AdminCreditCoinSellerStock(context.Background(), deduction)
	if err != nil {
		t.Fatalf("AdminCreditCoinSellerStock deduction failed: %v", err)
	}
	second, err := svc.AdminCreditCoinSellerStock(context.Background(), deduction)
	if err != nil {
		t.Fatalf("AdminCreditCoinSellerStock deduction retry failed: %v", err)
	}
	if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 5000000 || first.CoinAmount != -3000000 || first.PaidAmountMicro != -37500000 || !first.CountsAsSellerRecharge {
		t.Fatalf("stock deduction receipt mismatch: first=%+v second=%+v", first, second)
	}
	assertBalance(t, svc, 33011, ledger.AssetCoinSellerCoin, 5000000)
	if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND stock_type = ? AND counts_as_seller_recharge = TRUE AND coin_amount = ? AND paid_amount_micro = ?", first.TransactionID, ledger.StockTypeUSDTDeduction, int64(-3000000), int64(-37500000)); got != 1 {
		t.Fatalf("stock deduction should write one negative seller recharge record, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", first.TransactionID, int64(-3000000)); got != 1 {
		t.Fatalf("stock deduction should write one negative wallet entry, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ? AND JSON_EXTRACT(payload_json, '$.paid_amount_micro') = ?", first.TransactionID, "WalletCoinSellerStockPurchased", int64(-3000000), int64(-37500000)); got != 1 {
		t.Fatalf("stock deduction should emit negative seller stock outbox event, 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,
		SellerCountryID:  63,
		SellerRegionID:   7,
		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,
		SellerCountryID: 63,
		SellerRegionID:  7,
		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,
		SellerCountryID: 63,
		SellerRegionID:  7,
		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.SellerCountryID = 971
	command.SellerRegionID = 8
	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,
		SellerCountryID: 63,
		SellerRegionID:  7,
		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)
	}

	_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:         "vip-spark-missing-level",
		ResourceID:     giftResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "VIP Spark Missing Level",
		PriceVersion:   "v1",
		CoinPrice:      5,
		GiftTypeCode:   resourcedomain.GiftTypeNoble,
		OperatorUserID: 90001,
	})
	if !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("expected INVALID_ARGUMENT for VIP gift without level, got %v", err)
	}
	vipGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:         "vip-spark",
		ResourceID:     giftResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "VIP Spark",
		PriceVersion:   "v1",
		CoinPrice:      5,
		GiftTypeCode:   resourcedomain.GiftTypeNoble,
		VIPLevel:       3,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create VIP gift config failed: %v", err)
	}
	if vipGift.GiftTypeCode != resourcedomain.GiftTypeNoble || vipGift.VIPLevel != 3 {
		t.Fatalf("VIP gift level must round-trip from MySQL: %+v", vipGift)
	}

	repository.SetBalance(41003, 100)
	_, err = svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-vip-spark-denied",
		RoomID:       "room-1",
		SenderUserID: 41003,
		TargetUserID: 41002,
		GiftID:       "vip-spark",
		GiftCount:    1,
	})
	if !xerr.IsCode(err, xerr.VIPGiftLevelRequired) {
		t.Fatalf("expected VIP_GIFT_LEVEL_REQUIRED below configured level, got %v", err)
	}
	if detail, ok := xerr.As(err); !ok || detail.Metadata["required_level"] != "3" || detail.Metadata["current_effective_level"] != "0" {
		t.Fatalf("VIP gift denial must expose stable level metadata, got %+v", detail)
	}
	assertBalance(t, svc, 41003, ledger.AssetCoin, 100)
	// VIP 礼物只声明最低等级；VIP9 必须同时覆盖 VIP3 的单目标和批量送礼入口。
	// 测试库只发布 normal 倍率，确保 noble 分类能继承真实运营政策而非依赖专属配置行。
	if got := repository.CountRows("gift_diamond_ratio_configs", "app_code = ? AND gift_type_code = ?", appcode.Default, resourcedomain.GiftTypeNoble); got != 0 {
		t.Fatalf("VIP gift regression requires no dedicated noble ratio, got %d", got)
	}
	nowMS := time.Now().UnixMilli()
	repository.SeedPaidVIPAtWindow(appcode.Default, 41003, 9, nowMS-1000, nowMS+60_000)
	vipReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-vip-spark-allowed",
		RoomID:       "room-1",
		SenderUserID: 41003,
		TargetUserID: 41002,
		GiftID:       "vip-spark",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("DebitGift must allow a higher VIP level to send a lower-level gift: %v", err)
	}
	if vipReceipt.CoinSpent != 5 {
		t.Fatalf("VIP gift settlement mismatch: %+v", vipReceipt)
	}
	batchReceipt, err := svc.BatchDebitGift(ctx, ledger.BatchDebitGiftCommand{
		CommandID:    "cmd-vip-spark-batch-allowed",
		RoomID:       "room-1",
		SenderUserID: 41003,
		GiftID:       "vip-spark",
		GiftCount:    1,
		Targets: []ledger.DebitGiftTargetCommand{
			{CommandID: "cmd-vip-spark-batch-allowed:41002", TargetUserID: 41002},
			{CommandID: "cmd-vip-spark-batch-allowed:41004", TargetUserID: 41004},
		},
	})
	if err != nil {
		t.Fatalf("BatchDebitGift must allow a higher VIP level to send a lower-level gift: %v", err)
	}
	if batchReceipt.Aggregate.CoinSpent != 10 || len(batchReceipt.Targets) != 2 {
		t.Fatalf("VIP batch gift settlement mismatch: %+v", batchReceipt)
	}

	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 != 0 || receipt.HeatValue != 10 {
		t.Fatalf("resource-backed gift settlement mismatch: %+v", receipt)
	}
}

// TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit 验证背包送礼只扣礼物权益库存，但房间贡献仍按礼物原价结算。
func TestDebitGiftFromBagConsumesEntitlementWithoutCoinDebit(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_bag_spark",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Bag Spark",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create bag gift resource failed: %v", err)
	}
	if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:         "bag-spark",
		ResourceID:     giftResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "Bag Spark",
		PriceVersion:   "v1",
		CoinPrice:      5,
		OperatorUserID: 90001,
	}); err != nil {
		t.Fatalf("create bag gift config failed: %v", err)
	}
	grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-bag-spark",
		TargetUserID:   61001,
		ResourceID:     giftResource.ResourceID,
		Quantity:       3,
		Reason:         "bag gift test",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("GrantResource bag gift failed: %v", err)
	}
	if len(grant.Items) != 1 || grant.Items[0].EntitlementID == "" {
		t.Fatalf("bag gift grant must return entitlement: %+v", grant)
	}

	receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:     "cmd-send-bag-spark",
		RoomID:        "room-bag",
		SenderUserID:  61001,
		TargetUserID:  61002,
		GiftID:        "bag-spark",
		GiftCount:     2,
		EntitlementID: grant.Items[0].EntitlementID,
		ChargeSource:  "bag",
	})
	if err != nil {
		t.Fatalf("DebitGift from bag failed: %v", err)
	}
	if receipt.CoinSpent != 10 || receipt.ChargeAmount != 10 || receipt.HeatValue != 10 || receipt.ChargeAssetType != ledger.AssetBagGift {
		t.Fatalf("bag gift must keep original gift value in receipt: %+v", receipt)
	}
	if receipt.ChargeSource != "bag" || receipt.EntitlementID != grant.Items[0].EntitlementID || receipt.BalanceAfter != 0 {
		t.Fatalf("bag gift source/entitlement/balance mismatch: %+v", receipt)
	}
	_, quantity, remaining := repository.ActiveResourceEntitlement(61001, giftResource.ResourceID)
	if quantity != 3 || remaining != 1 {
		t.Fatalf("bag gift entitlement quantity mismatch: quantity=%d remaining=%d", quantity, remaining)
	}
	balances, err := svc.GetBalances(ctx, 61002, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances bag gift target failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 3 {
		t.Fatalf("bag gift target income mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
		t.Fatalf("bag gift must only write target COIN income entry, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftDebited"); got != 1 {
		t.Fatalf("bag gift must still publish WalletGiftDebited for normal room projections, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", receipt.TransactionID, "WalletGiftIncomeCredited"); got != 1 {
		t.Fatalf("bag gift must publish target income fact, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "command_id = ? AND event_type = ?", "cmd-send-bag-spark", "UserResourceChanged"); got != 1 {
		t.Fatalf("bag gift must publish resource change event, got %d", got)
	}

	_, err = svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:          "cmd-send-bag-spark-policyless-host",
		RoomID:             "room-bag-policyless-host",
		SenderUserID:       61001,
		TargetUserID:       61003,
		TargetIsHost:       true,
		TargetHostRegionID: 9911,
		GiftID:             "bag-spark",
		GiftCount:          1,
		EntitlementID:      grant.Items[0].EntitlementID,
		ChargeSource:       "bag",
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("bag gift to a Host without published policy must fail closed, got %v", err)
	}
	// 背包库存锁和 consume 都位于 Host 政策/倍率预解析之后；失败不能吃掉最后一个礼物。
	_, quantity, remaining = repository.ActiveResourceEntitlement(61001, giftResource.ResourceID)
	if quantity != 3 || remaining != 1 {
		t.Fatalf("policyless Host failure mutated bag inventory: quantity=%d remaining=%d", quantity, remaining)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ?", "cmd-send-bag-spark-policyless-host"); got != 0 {
		t.Fatalf("policyless bag gift must not write transaction, got %d", got)
	}
}

func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(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: "DIAMOND",
		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.AssetCoin || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
		t.Fatalf("gift config must not keep DIAMOND charge: %+v", gift)
	}

	repository.SetBalance(52001, 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.AssetCoin || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
		t.Fatalf("gift settlement must use COIN after DIAMOND removal: %+v", receipt)
	}
}

func TestDeleteGiftConfigKeepsResourceData(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_delete_keep_resource",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Delete Keep Resource",
		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:         "delete-keep-resource",
		ResourceID:     giftResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "Delete Keep Resource",
		PriceVersion:   "v1",
		CoinPrice:      9,
		HeatValue:      9,
		OperatorUserID: 90001,
		RegionIDs:      []int64{0},
	})
	if err != nil {
		t.Fatalf("create gift config failed: %v", err)
	}
	if gift.ResourceID != giftResource.ResourceID {
		t.Fatalf("gift resource id mismatch: %+v", gift)
	}
	if got := repository.CountRows("gift_configs", "gift_id = ?", "delete-keep-resource"); got != 1 {
		t.Fatalf("expected gift config before delete, got %d", got)
	}
	if got := repository.CountRows("wallet_gift_prices", "gift_id = ?", "delete-keep-resource"); got != 1 {
		t.Fatalf("expected gift price before delete, got %d", got)
	}
	if got := repository.CountRows("gift_config_regions", "gift_id = ?", "delete-keep-resource"); got != 1 {
		t.Fatalf("expected gift region before delete, got %d", got)
	}

	deleted, err := svc.DeleteGiftConfig(ctx, resourcedomain.StatusCommand{
		StringID:       "delete-keep-resource",
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("delete gift config failed: %v", err)
	}
	if deleted.GiftID != "delete-keep-resource" || deleted.ResourceID != giftResource.ResourceID {
		t.Fatalf("deleted gift snapshot mismatch: %+v", deleted)
	}
	if got := repository.CountRows("gift_configs", "gift_id = ?", "delete-keep-resource"); got != 0 {
		t.Fatalf("gift config should be deleted, got %d", got)
	}
	if got := repository.CountRows("wallet_gift_prices", "gift_id = ?", "delete-keep-resource"); got != 0 {
		t.Fatalf("gift price should be deleted with gift config, got %d", got)
	}
	if got := repository.CountRows("gift_config_regions", "gift_id = ?", "delete-keep-resource"); got != 0 {
		t.Fatalf("gift region should be deleted with gift config, got %d", got)
	}
	if got := repository.CountRows("resources", "resource_id = ?", giftResource.ResourceID); got != 1 {
		t.Fatalf("gift resource must stay in resource list, got %d", got)
	}
	if _, err := svc.GetResource(ctx, "", giftResource.ResourceID); err != nil {
		t.Fatalf("gift resource should still be readable after deleting gift config: %v", err)
	}
	items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
		Keyword:  "delete-keep-resource",
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("list gifts after delete failed: %v", err)
	}
	if total != 0 || len(items) != 0 {
		t.Fatalf("deleted gift should not stay in gift list total=%d items=%+v", total, items)
	}
}

func TestDeleteResourceHidesFromDefaultList(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "delete_resource_hidden",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Delete Resource Hidden",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyNewEntitlement,
		UsageScopes:    []string{"badge"},
		OperatorUserID: 91001,
	})
	if err != nil {
		t.Fatalf("create resource failed: %v", err)
	}

	deleted, err := svc.DeleteResource(ctx, resourcedomain.StatusCommand{
		ID:             resource.ResourceID,
		OperatorUserID: 91002,
	})
	if err != nil {
		t.Fatalf("delete resource failed: %v", err)
	}
	if deleted.ResourceID != resource.ResourceID || deleted.Status != resourcedomain.StatusDeleted {
		t.Fatalf("deleted resource snapshot mismatch: %+v", deleted)
	}
	if got := repository.CountRows("resources", "resource_id = ? AND status = ?", resource.ResourceID, resourcedomain.StatusDeleted); got != 1 {
		t.Fatalf("resource should be soft deleted, got %d", got)
	}
	if got := repository.CountRows("resources", "resource_code = ?", "delete_resource_hidden"); got != 0 {
		t.Fatalf("deleted resource should release resource_code uniqueness, got %d", got)
	}
	items, total, err := svc.ListResources(ctx, resourcedomain.ListResourcesQuery{
		Keyword:  "delete_resource_hidden",
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("list resources failed: %v", err)
	}
	if total != 0 || len(items) != 0 {
		t.Fatalf("deleted resource should be hidden from default list: total=%d items=%+v", total, items)
	}
	items, total, err = svc.ListResources(ctx, resourcedomain.ListResourcesQuery{
		Keyword:  "delete_resource_hidden",
		Status:   resourcedomain.StatusDeleted,
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("list deleted status resources failed: %v", err)
	}
	if total != 0 || len(items) != 0 {
		t.Fatalf("deleted resource should not be exposed by status filter: total=%d items=%+v", total, items)
	}
	loaded, err := svc.GetResource(ctx, "", resource.ResourceID)
	if err != nil {
		t.Fatalf("deleted resource should remain readable for historical references: %v", err)
	}
	if loaded.Status != resourcedomain.StatusDeleted {
		t.Fatalf("loaded deleted resource status mismatch: %+v", loaded)
	}
	recreated, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "delete_resource_hidden",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Recreated Resource",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyNewEntitlement,
		UsageScopes:    []string{"badge"},
		OperatorUserID: 91003,
	})
	if err != nil {
		t.Fatalf("recreate resource with deleted code failed: %v", err)
	}
	if recreated.ResourceID == resource.ResourceID {
		t.Fatalf("recreated resource should be a new row: old=%d new=%d", resource.ResourceID, recreated.ResourceID)
	}
}

func TestDeleteGiftResourceHidesGiftConfigList(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "delete_gift_resource_hidden",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Delete Gift Resource Hidden",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 91101,
	})
	if err != nil {
		t.Fatalf("create gift resource failed: %v", err)
	}
	if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:         "delete-resource-hide-gift",
		ResourceID:     resource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "Delete Resource Hide Gift",
		PriceVersion:   "default",
		CoinPrice:      10,
		HeatValue:      10,
		OperatorUserID: 91101,
		RegionIDs:      []int64{0},
	}); err != nil {
		t.Fatalf("create gift config failed: %v", err)
	}
	if _, err := svc.DeleteResource(ctx, resourcedomain.StatusCommand{
		ID:             resource.ResourceID,
		OperatorUserID: 91102,
	}); err != nil {
		t.Fatalf("delete gift resource failed: %v", err)
	}
	gifts, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
		Keyword:  "delete-resource-hide-gift",
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("list gift configs failed: %v", err)
	}
	if total != 0 || len(gifts) != 0 {
		t.Fatalf("gift config should be hidden when resource is deleted: total=%d gifts=%+v", total, gifts)
	}
	if got := repository.CountRows("gift_configs", "gift_id = ?", "delete-resource-hide-gift"); got != 1 {
		t.Fatalf("soft-deleting resource should keep gift config row for history, got %d", got)
	}
}

func TestBatchCreateGiftConfigsRollsBackOnFailure(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "batch_gift_valid_resource",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Batch Gift Valid Resource",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 91201,
	})
	if err != nil {
		t.Fatalf("create gift resource failed: %v", err)
	}
	_, err = svc.BatchCreateGiftConfigs(ctx, "", []resourcedomain.GiftConfigCommand{
		{
			GiftID:         "batch-create-rollback-ok",
			ResourceID:     resource.ResourceID,
			Status:         resourcedomain.StatusActive,
			Name:           "Batch Create Rollback OK",
			PriceVersion:   "default",
			CoinPrice:      10,
			HeatValue:      10,
			OperatorUserID: 91201,
			RegionIDs:      []int64{0},
		},
		{
			GiftID:         "batch-create-rollback-bad",
			ResourceID:     99999999,
			Status:         resourcedomain.StatusActive,
			Name:           "Batch Create Rollback Bad",
			PriceVersion:   "default",
			CoinPrice:      10,
			HeatValue:      10,
			OperatorUserID: 91201,
			RegionIDs:      []int64{0},
		},
	}, 91201)
	if err == nil {
		t.Fatalf("batch create should fail when any item is invalid")
	}
	if got := repository.CountRows("gift_configs", "gift_id IN (?, ?)", "batch-create-rollback-ok", "batch-create-rollback-bad"); got != 0 {
		t.Fatalf("batch create should roll back all gift rows, got %d", got)
	}
	if got := repository.CountRows("wallet_gift_prices", "gift_id IN (?, ?)", "batch-create-rollback-ok", "batch-create-rollback-bad"); got != 0 {
		t.Fatalf("batch create should roll back all gift prices, got %d", got)
	}
}

func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	paidResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_price_sync",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Price Sync Gift",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		PriceType:      resourcedomain.PriceTypeCoin,
		CoinPrice:      77,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create priced gift resource failed: %v", err)
	}
	if paidResource.GiftPointAmount != 0 {
		t.Fatalf("resource gift point should stay disabled: %+v", paidResource)
	}
	gift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:          "price-sync",
		ResourceID:      paidResource.ResourceID,
		Status:          resourcedomain.StatusActive,
		Name:            "Price Sync Gift",
		PriceVersion:    "v1",
		ChargeAssetType: "DIAMOND",
		CoinPrice:       1,
		GiftPointAmount: 1,
		HeatValue:       3,
		GiftTypeCode:    resourcedomain.GiftTypeNormal,
		OperatorUserID:  90001,
		RegionIDs:       []int64{0},
	})
	if err != nil {
		t.Fatalf("create synced gift config failed: %v", err)
	}
	if gift.ChargeAssetType != ledger.AssetCoin || gift.CoinPrice != 77 || gift.GiftPointAmount != 0 {
		t.Fatalf("gift price should be overridden by resource price: %+v", gift)
	}
	updatedResource, err := svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
		ResourceID:     paidResource.ResourceID,
		ResourceCode:   paidResource.ResourceCode,
		ResourceType:   resourcedomain.TypeGift,
		Name:           paidResource.Name,
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		PriceType:      resourcedomain.PriceTypeCoin,
		CoinPrice:      88,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("update priced gift resource failed: %v", err)
	}
	if updatedResource.CoinPrice != 88 || updatedResource.GiftPointAmount != 0 {
		t.Fatalf("updated resource price mismatch: %+v", updatedResource)
	}
	repository.SetBalance(54001, 100)
	updatedReceipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-price-sync-after-resource-update",
		RoomID:       "room-price-sync",
		SenderUserID: 54001,
		TargetUserID: 54002,
		GiftID:       "price-sync",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("debit gift after resource price update failed: %v", err)
	}
	if updatedReceipt.CoinSpent != 88 || updatedReceipt.GiftPointAdded != 0 {
		t.Fatalf("gift price should follow updated resource price: %+v", updatedReceipt)
	}

	freeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_free_sync",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Free Sync Gift",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		PriceType:      resourcedomain.PriceTypeFree,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create free gift resource failed: %v", err)
	}
	freeGift, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:          "free-sync",
		ResourceID:      freeResource.ResourceID,
		Status:          resourcedomain.StatusActive,
		Name:            "Free Sync Gift",
		PriceVersion:    "v1",
		CoinPrice:       9,
		GiftPointAmount: 9,
		HeatValue:       0,
		GiftTypeCode:    resourcedomain.GiftTypeNormal,
		OperatorUserID:  90001,
		RegionIDs:       []int64{0},
	})
	if err != nil {
		t.Fatalf("create free synced gift config failed: %v", err)
	}
	if freeGift.CoinPrice != 0 || freeGift.GiftPointAmount != 0 {
		t.Fatalf("free gift price should sync to zero: %+v", freeGift)
	}
	receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID:    "cmd-free-sync",
		RoomID:       "room-free",
		SenderUserID: 53001,
		TargetUserID: 53002,
		GiftID:       "free-sync",
		GiftCount:    1,
	})
	if err != nil {
		t.Fatalf("free gift debit should not require existing balance: %v", err)
	}
	if receipt.ChargeAmount != 0 || receipt.GiftPointAdded != 0 {
		t.Fatalf("free gift receipt mismatch: %+v", receipt)
	}
}

func TestGiftTypeConfigDefaultsAndUpsert(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	items, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{})
	if err != nil {
		t.Fatalf("list gift type configs failed: %v", err)
	}
	if len(items) != 10 || items[0].TypeCode != resourcedomain.GiftTypeNormal || items[0].TabKey != resourcedomain.GiftTypeTabKeyNormal {
		t.Fatalf("default gift type configs mismatch: %+v", items)
	}

	updated, err := svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{
		TypeCode:       resourcedomain.GiftTypeNormal,
		Name:           "普通礼物",
		TabKey:         "Gift",
		Status:         resourcedomain.StatusDisabled,
		SortOrder:      11,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("update gift type config failed: %v", err)
	}
	if updated.Status != resourcedomain.StatusDisabled || updated.SortOrder != 11 {
		t.Fatalf("updated gift type config mismatch: %+v", updated)
	}

	activeItems, err := svc.ListGiftTypeConfigs(ctx, resourcedomain.ListGiftTypeConfigsQuery{ActiveOnly: true})
	if err != nil {
		t.Fatalf("list active gift type configs failed: %v", err)
	}
	if giftTypeCodesContain(activeItems, resourcedomain.GiftTypeNormal) {
		t.Fatalf("disabled gift type must not be returned as active: %+v", activeItems)
	}
}

func TestGiftCatalogVersionTracksPanelConfigChanges(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	baseVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get empty gift catalog version failed: %v", err)
	}

	waitForNextWalletVersionMillisecond(t)
	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		AppCode:        "lalu",
		ResourceCode:   "gift_catalog_version",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Version Gift",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create version gift resource failed: %v", err)
	}
	resourceVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get resource catalog version failed: %v", err)
	}
	requireWalletVersionAdvanced(t, "gift resource create", baseVersion, resourceVersion)

	waitForNextWalletVersionMillisecond(t)
	_, err = svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		AppCode:        "lalu",
		GiftID:         "catalog-version",
		ResourceID:     giftResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "Version Gift",
		PriceVersion:   "v1",
		CoinPrice:      5,
		GiftTypeCode:   resourcedomain.GiftTypeNormal,
		RegionIDs:      []int64{0},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create version gift config failed: %v", err)
	}
	giftVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get gift config catalog version failed: %v", err)
	}
	requireWalletVersionAdvanced(t, "gift config create", resourceVersion, giftVersion)

	waitForNextWalletVersionMillisecond(t)
	_, err = svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		AppCode:        "lalu",
		GiftID:         "catalog-version",
		ResourceID:     giftResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "Version Gift Updated",
		PriceVersion:   "v2",
		CoinPrice:      9,
		GiftTypeCode:   resourcedomain.GiftTypeNormal,
		RegionIDs:      []int64{0, 42},
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("update version gift config failed: %v", err)
	}
	priceRegionVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get gift price/region catalog version failed: %v", err)
	}
	requireWalletVersionAdvanced(t, "gift price and region update", giftVersion, priceRegionVersion)

	waitForNextWalletVersionMillisecond(t)
	_, err = svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
		AppCode:        "lalu",
		ResourceID:     giftResource.ResourceID,
		ResourceCode:   giftResource.ResourceCode,
		ResourceType:   resourcedomain.TypeGift,
		Name:           "Version Gift Asset Updated",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		AssetURL:       "https://static.example.test/gift-version.png",
		OperatorUserID: 90003,
	})
	if err != nil {
		t.Fatalf("update version gift resource failed: %v", err)
	}
	resourceUpdateVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get updated resource catalog version failed: %v", err)
	}
	requireWalletVersionAdvanced(t, "gift resource update", priceRegionVersion, resourceUpdateVersion)

	waitForNextWalletVersionMillisecond(t)
	_, err = svc.UpsertGiftTypeConfig(ctx, resourcedomain.GiftTypeConfigCommand{
		AppCode:        "lalu",
		TypeCode:       resourcedomain.GiftTypeNormal,
		Name:           "Normal Gifts",
		TabKey:         resourcedomain.GiftTypeTabKeyNormal,
		Status:         resourcedomain.StatusActive,
		SortOrder:      12,
		OperatorUserID: 90004,
	})
	if err != nil {
		t.Fatalf("update gift type config failed: %v", err)
	}
	typeVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get gift type catalog version failed: %v", err)
	}
	requireWalletVersionAdvanced(t, "gift type update", resourceUpdateVersion, typeVersion)

	waitForNextWalletVersionMillisecond(t)
	if _, err := svc.DeleteGiftConfig(ctx, resourcedomain.StatusCommand{
		AppCode:        "lalu",
		StringID:       "catalog-version",
		OperatorUserID: 90005,
	}); err != nil {
		t.Fatalf("delete gift config failed: %v", err)
	}
	deleteVersion, err := svc.GetGiftCatalogVersion(ctx, "lalu")
	if err != nil {
		t.Fatalf("get deleted gift catalog version failed: %v", err)
	}
	requireWalletVersionAdvanced(t, "gift config delete", typeVersion, deleteVersion)
}

// 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)
		}
	}
	if _, err := svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID:         "regional-saudi",
		ResourceID:     saudiResource.ResourceID,
		Status:         resourcedomain.StatusActive,
		Name:           "Regional Saudi",
		PriceVersion:   "v2",
		CoinPrice:      22,
		HeatValue:      22,
		OperatorUserID: 90001,
		RegionIDs:      []int64{1002, 1001},
	}); err != nil {
		t.Fatalf("update regional saudi gift price failed: %v", err)
	}
	repository.InsertRawGiftPrice("regional-saudi", "v8-inactive", "inactive", 0)
	repository.InsertRawGiftPrice("regional-saudi", "v9-future", "active", time.Now().Add(time.Hour).UnixMilli())

	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)
	}
	saudiGift, ok := giftConfigByID(items, "regional-saudi")
	if !ok {
		t.Fatalf("regional saudi gift must be returned: %+v", items)
	}
	if saudiGift.PriceVersion != "v2" || saudiGift.CoinPrice != 22 || saudiGift.GiftPointAmount != 0 || saudiGift.HeatValue != 22 {
		t.Fatalf("batch latest gift price mismatch: %+v", saudiGift)
	}
	if !int64sEqual(saudiGift.RegionIDs, []int64{1001, 1002}) {
		t.Fatalf("batch region ids must return full configured regions, got %+v", saudiGift.RegionIDs)
	}

	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)
	}
}

// TestListGiftConfigsWithoutTypeFilterIncludesLuckyTypes 验证礼物面板预加载全部礼物时不会被空类型过滤误收窄到普通礼物。
func TestListGiftConfigsWithoutTypeFilterIncludesLuckyTypes(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	normalResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_all_normal",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "All Normal",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create normal gift resource failed: %v", err)
	}
	luckyResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_all_lucky",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "All Lucky",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create lucky gift resource failed: %v", err)
	}
	superLuckyResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "gift_all_super_lucky",
		ResourceType:   resourcedomain.TypeGift,
		Name:           "All Super Lucky",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"gift"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create super lucky gift resource failed: %v", err)
	}
	commands := []resourcedomain.GiftConfigCommand{
		{
			GiftID:         "all-normal",
			ResourceID:     normalResource.ResourceID,
			Status:         resourcedomain.StatusActive,
			Name:           "All Normal",
			PriceVersion:   "v1",
			CoinPrice:      100,
			HeatValue:      100,
			OperatorUserID: 90001,
			RegionIDs:      []int64{0},
		},
		{
			GiftID:         "all-lucky",
			ResourceID:     luckyResource.ResourceID,
			Status:         resourcedomain.StatusActive,
			Name:           "All Lucky",
			GiftTypeCode:   resourcedomain.GiftTypeLucky,
			PriceVersion:   "v1",
			CoinPrice:      200,
			HeatValue:      200,
			OperatorUserID: 90001,
			RegionIDs:      []int64{0},
		},
		{
			GiftID:         "all-super-lucky",
			ResourceID:     superLuckyResource.ResourceID,
			Status:         resourcedomain.StatusActive,
			Name:           "All Super Lucky",
			GiftTypeCode:   resourcedomain.GiftTypeSuperLucky,
			PriceVersion:   "v1",
			CoinPrice:      300,
			HeatValue:      300,
			OperatorUserID: 90001,
			RegionIDs:      []int64{0},
		},
	}
	for _, command := range commands {
		if _, err := svc.CreateGiftConfig(ctx, command); err != nil {
			t.Fatalf("create gift config %s failed: %v", command.GiftID, err)
		}
	}

	// 空 gift_type_code 表示“不按类型过滤”，gateway 的 gift-tabs 会依赖这次查询一次性拿到所有礼物类型。
	items, total, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
		ActiveOnly:   true,
		FilterRegion: true,
		RegionID:     0,
		Page:         1,
		PageSize:     10,
	})
	if err != nil {
		t.Fatalf("list all gift configs failed: %v", err)
	}
	if total != 3 || !giftIDsContain(items, "all-normal") || !giftIDsContain(items, "all-lucky") || !giftIDsContain(items, "all-super-lucky") {
		t.Fatalf("all gift type list mismatch total=%d items=%+v", total, items)
	}

	// 显式传入 lucky 时仍保留类型过滤能力，避免修复全部礼物预加载时破坏后台按类型筛选。
	luckyItems, luckyTotal, err := svc.ListGiftConfigs(ctx, resourcedomain.ListGiftConfigsQuery{
		GiftTypeCode: resourcedomain.GiftTypeLucky,
		ActiveOnly:   true,
		FilterRegion: true,
		RegionID:     0,
		Page:         1,
		PageSize:     10,
	})
	if err != nil {
		t.Fatalf("list lucky gift configs failed: %v", err)
	}
	if luckyTotal != 1 || !giftIDsContain(luckyItems, "all-lucky") || giftIDsContain(luckyItems, "all-normal") || giftIDsContain(luckyItems, "all-super-lucky") {
		t.Fatalf("lucky gift type filter mismatch total=%d items=%+v", luckyTotal, luckyItems)
	}
}

// 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)
	}
	equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs:       []int64{42001},
		ResourceTypes: []string{resourcedomain.TypeBadge},
	})
	if err != nil {
		t.Fatalf("batch get group-granted badge equipment failed: %v", err)
	}
	if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].ResourceID != badgeResource.ResourceID {
		t.Fatalf("group-granted badge should be auto equipped: %+v", equippedResources)
	}
	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)
	}
	if got := repository.CountRows("wallet_transactions", "biz_type = ? AND command_id LIKE 'rgg:%' AND CHAR_LENGTH(command_id) <= 128", "resource_grant"); got != 1 {
		t.Fatalf("group wallet asset grant should use one short internal wallet command, got %d", got)
	}
	longCommand := resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cp_weekly_rank:1781481600000:1:cp_rel:327091463391285248:327104359357747200:327091463391285248",
		TargetUserID:   42002,
		GroupID:        group.GroupID,
		Reason:         "test grant long command",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	}
	if _, err := svc.GrantResourceGroup(ctx, longCommand); err != nil {
		t.Fatalf("GrantResourceGroup should accept long upstream command by shortening internal item commands: %v", err)
	}
	if got := repository.CountRows("resource_grants", "command_id = ?", longCommand.CommandID); got != 1 {
		t.Fatalf("long upstream command should still be preserved on resource grant, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "biz_type = ? AND command_id LIKE 'rgg:%' AND CHAR_LENGTH(command_id) <= 128", "resource_grant"); got != 2 {
		t.Fatalf("long upstream group wallet asset grant should use a second short internal wallet command, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "biz_type = ? AND CHAR_LENGTH(command_id) > 128", "resource_grant"); got != 0 {
		t.Fatalf("resource grant wallet transactions must not exceed command_id storage length, got %d", got)
	}
}

func TestPinnedResourceGroupGrantPreservesResourceSemanticsAfterCatalogMutation(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode: "activity_snapshot_badge", ResourceType: resourcedomain.TypeBadge, Name: "Season One Badge",
		Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes: []string{"profile"}, AssetURL: "https://static.example.test/season-one.png", OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create snapshot resource: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "activity_snapshot_pack", Name: "Season One Reward", Status: resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{{ResourceID: resource.ResourceID, Quantity: 2}}, OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create snapshot group: %v", err)
	}
	sourceHash, err := resourcedomain.ResourceGroupContentHash(group)
	if err != nil {
		t.Fatalf("hash source group: %v", err)
	}
	pinned, err := svc.PinResourceGroupSnapshot(ctx, resourcedomain.PinResourceGroupSnapshotCommand{
		PinKey: "activity-template:test:v1", GroupID: group.GroupID, OperatorUserID: 90001,
		ExpectedGroupUpdatedAtMS: group.UpdatedAtMS, ExpectedSourceContentHash: sourceHash,
	})
	if err != nil {
		t.Fatalf("pin source group: %v", err)
	}
	if pinned.SnapshotHash != sourceHash || pinned.SourceContentHash != sourceHash {
		t.Fatalf("pinned source identity mismatch: %+v", pinned)
	}

	// The resource is independently edited and disabled without touching resource_groups.updated_at_ms.
	// A pinned activity reward must still execute and render the published bytes.
	if _, err := svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
		ResourceID: resource.ResourceID, ResourceCode: resource.ResourceCode, ResourceType: resourcedomain.TypeBadge,
		Name: "Retired Badge", Status: resourcedomain.StatusDisabled, Grantable: false,
		GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"profile"},
		AssetURL: "https://static.example.test/retired.png", OperatorUserID: 90002,
	}); err != nil {
		t.Fatalf("mutate source resource: %v", err)
	}
	grant, err := svc.GrantPinnedResourceGroup(ctx, resourcedomain.GrantPinnedResourceGroupCommand{
		CommandID: "activity-template:test:v1:user:42009", TargetUserID: 42009, SnapshotID: pinned.SnapshotID,
		ExpectedSnapshotHash: pinned.SnapshotHash, Reason: "activity rank reward", OperatorUserID: 42009,
		GrantSource: resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant pinned group after source disable: %v", err)
	}
	items, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{UserID: 42009, ActiveOnly: true})
	if err != nil {
		t.Fatalf("list pinned entitlement: %v", err)
	}
	if len(items) != 1 || items[0].SourceSnapshotID != pinned.SnapshotID || items[0].ResourceSnapshotHash == "" ||
		items[0].Resource.Name != "Season One Badge" || items[0].Resource.AssetURL != "https://static.example.test/season-one.png" || items[0].Quantity != 2 {
		t.Fatalf("entitlement drifted to mutable catalog: %+v", items)
	}
	equipped, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs: []int64{42009}, ResourceTypes: []string{resourcedomain.TypeBadge},
	})
	if err != nil || len(equipped) != 1 || len(equipped[0].Resources) != 1 || equipped[0].Resources[0].Resource.Name != "Season One Badge" {
		t.Fatalf("pinned equipment depends on current resource status: items=%+v err=%v", equipped, err)
	}
	if got := repository.CountRows("wallet_outbox", "event_type = ? AND command_id = ?", "ResourceGroupGranted", "activity-template:test:v1:user:42009"); got != 1 {
		t.Fatalf("pinned grant must reuse ResourceGroupGranted projection contract, got %d", got)
	}
	if grant.GrantSubjectType != resourcedomain.GrantSubjectGroupSnapshot || grant.GrantSubjectID != pinned.SnapshotID {
		t.Fatalf("pinned grant provenance mismatch: %+v", grant)
	}

	// A later active resource meaning gets a different owner content hash and must not merge into v1's entitlement.
	updated, err := svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
		ResourceID: resource.ResourceID, ResourceCode: resource.ResourceCode, ResourceType: resourcedomain.TypeBadge,
		Name: "Season Two Badge", Status: resourcedomain.StatusActive, Grantable: true,
		GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, UsageScopes: []string{"profile"},
		AssetURL: "https://static.example.test/season-two.png", OperatorUserID: 90003,
	})
	if err != nil {
		t.Fatalf("activate changed resource: %v", err)
	}
	group.Items[0].Resource = updated
	newSourceHash, err := resourcedomain.ResourceGroupContentHash(group)
	if err != nil || newSourceHash == sourceHash {
		t.Fatalf("nested resource edit must change source hash: old=%s new=%s err=%v", sourceHash, newSourceHash, err)
	}
	pinnedV2, err := svc.PinResourceGroupSnapshot(ctx, resourcedomain.PinResourceGroupSnapshotCommand{
		PinKey: "activity-template:test:v2", GroupID: group.GroupID, OperatorUserID: 90003,
		ExpectedGroupUpdatedAtMS: group.UpdatedAtMS, ExpectedSourceContentHash: newSourceHash,
	})
	if err != nil {
		t.Fatalf("pin changed nested resource: %v", err)
	}
	if _, err := svc.GrantPinnedResourceGroup(ctx, resourcedomain.GrantPinnedResourceGroupCommand{
		CommandID: "activity-template:test:v2:user:42009", TargetUserID: 42009, SnapshotID: pinnedV2.SnapshotID,
		ExpectedSnapshotHash: pinnedV2.SnapshotHash, Reason: "activity rank reward", OperatorUserID: 42009,
		GrantSource: resourcedomain.GrantSourceAdmin,
	}); err != nil {
		t.Fatalf("grant changed semantic snapshot: %v", err)
	}
	if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(42009), resource.ResourceID); got != 2 {
		t.Fatalf("different pinned resource semantics must not merge, got %d rows", got)
	}
}

func TestPinnedGiftEntitlementUsesPinTimeCatalogAndRejectsTampering(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	giftResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode: "activity_pinned_gift", ResourceType: resourcedomain.TypeGift, Name: "Pinned Star Resource",
		Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
		PriceType: resourcedomain.PriceTypeCoin, CoinPrice: 7, UsageScopes: []string{"gift"},
		PreviewURL: "https://static.example.test/pinned-star-v1.png", AnimationURL: "https://static.example.test/pinned-star-v1.svga",
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create pinned gift resource: %v", err)
	}
	if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID: "activity-pinned-star", ResourceID: giftResource.ResourceID, Status: resourcedomain.StatusActive,
		Name: "Pinned Star V1", PresentationJSON: `{"theme":"season-one"}`, PriceVersion: "v1", CoinPrice: 7,
		GiftTypeCode: resourcedomain.GiftTypeCP, CPRelationType: "brother",
		EffectTypes: []string{resourcedomain.GiftEffectAnimation}, RegionIDs: []int64{0}, OperatorUserID: 90001,
	}); err != nil {
		t.Fatalf("create pinned gift config: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "activity_pinned_gift_pack", Name: "Pinned Gift Pack", Status: resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{{ResourceID: giftResource.ResourceID, Quantity: 3}}, OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create pinned gift group: %v", err)
	}
	if len(group.Items) != 1 || len(group.Items[0].GiftConfigs) != 1 {
		t.Fatalf("gift group inspection must include wallet-owned pin semantics: %+v", group.Items)
	}
	sourceHash, err := resourcedomain.ResourceGroupContentHash(group)
	if err != nil {
		t.Fatalf("hash pinned gift group: %v", err)
	}
	pinned, err := svc.PinResourceGroupSnapshot(ctx, resourcedomain.PinResourceGroupSnapshotCommand{
		PinKey: "activity-template:pinned-gift:v1", GroupID: group.GroupID, OperatorUserID: 90001,
		ExpectedGroupUpdatedAtMS: group.UpdatedAtMS, ExpectedSourceContentHash: sourceHash, RequiredAllRegions: true,
	})
	if err != nil {
		t.Fatalf("pin gift group: %v", err)
	}

	updatedResource, err := svc.UpdateResource(ctx, resourcedomain.ResourceCommand{
		ResourceID: giftResource.ResourceID, ResourceCode: giftResource.ResourceCode, ResourceType: resourcedomain.TypeGift,
		Name: "Mutable Star Resource V2", Status: resourcedomain.StatusActive, Grantable: true,
		GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity, PriceType: resourcedomain.PriceTypeCoin, CoinPrice: 31,
		UsageScopes: []string{"gift"}, PreviewURL: "https://static.example.test/pinned-star-v2.png",
		AnimationURL: "https://static.example.test/pinned-star-v2.pag", OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("edit mutable gift resource: %v", err)
	}
	if _, err := svc.UpdateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID: "activity-pinned-star", ResourceID: updatedResource.ResourceID, Status: resourcedomain.StatusActive,
		Name: "Mutable Star V2", PresentationJSON: `{"theme":"season-two"}`, PriceVersion: "v2", CoinPrice: 31,
		GiftTypeCode: resourcedomain.GiftTypeNormal, EffectTypes: []string{resourcedomain.GiftEffectMusic},
		RegionIDs: []int64{99}, OperatorUserID: 90002,
	}); err != nil {
		t.Fatalf("edit mutable gift config: %v", err)
	}
	if _, err := svc.SetGiftConfigStatus(ctx, resourcedomain.StatusCommand{
		StringID: "activity-pinned-star", Status: resourcedomain.StatusDisabled, OperatorUserID: 90002,
	}); err != nil {
		t.Fatalf("disable mutable gift config: %v", err)
	}
	if _, err := svc.SetResourceStatus(ctx, resourcedomain.StatusCommand{
		ID: giftResource.ResourceID, Status: resourcedomain.StatusDisabled, OperatorUserID: 90002,
	}); err != nil {
		t.Fatalf("disable mutable gift resource: %v", err)
	}

	grantPinned := func(commandID string, userID int64) resourcedomain.ResourceGrant {
		t.Helper()
		grant, grantErr := svc.GrantPinnedResourceGroup(ctx, resourcedomain.GrantPinnedResourceGroupCommand{
			CommandID: commandID, TargetUserID: userID, SnapshotID: pinned.SnapshotID,
			ExpectedSnapshotHash: pinned.SnapshotHash, Reason: "pinned activity gift reward",
			OperatorUserID: 90003, GrantSource: resourcedomain.GrantSourceAdmin,
		})
		if grantErr != nil {
			t.Fatalf("grant pinned gift after catalog mutation: %v", grantErr)
		}
		if len(grant.Items) != 1 || grant.Items[0].EntitlementID == "" {
			t.Fatalf("pinned gift grant must create entitlement: %+v", grant)
		}
		return grant
	}
	grant := grantPinned("activity-pinned-gift:user:63001", 63001)
	entitlementID := grant.Items[0].EntitlementID

	// Cross-app lookup is resolved before any catalog fallback and must never consume the original tenant row.
	if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		AppCode: "hiyou", CommandID: "cross-app-pinned-gift", RoomID: "room-cross-app", SenderUserID: 63001,
		TargetUserID: 63002, GiftID: "activity-pinned-star", GiftCount: 1, PriceVersion: "v2",
		EntitlementID: entitlementID, ChargeSource: "bag",
	}); err == nil {
		t.Fatal("cross-app pinned gift entitlement must be rejected")
	}
	_, _, remaining := repository.ActiveResourceEntitlement(63001, giftResource.ResourceID)
	if remaining != 3 {
		t.Fatalf("cross-app attempt consumed pinned inventory: remaining=%d", remaining)
	}

	command := ledger.DebitGiftCommand{
		CommandID: "send-pinned-gift-v1", RoomID: "room-pinned-gift", SenderUserID: 63001, TargetUserID: 63002,
		GiftID: "activity-pinned-star", GiftCount: 2, PriceVersion: "caller-spoof-v999",
		EntitlementID: entitlementID, ChargeSource: "bag",
	}
	receipt, err := svc.DebitGift(ctx, command)
	if err != nil {
		t.Fatalf("send disabled pinned gift: %v", err)
	}
	if receipt.CoinSpent != 14 || receipt.PriceVersion != "v1" || receipt.GiftName != "Pinned Star V1" ||
		receipt.GiftIconURL != "https://static.example.test/pinned-star-v1.png" ||
		receipt.GiftAnimationURL != "https://static.example.test/pinned-star-v1.svga" ||
		receipt.GiftTypeCode != resourcedomain.GiftTypeCP || receipt.CPRelationType != "brother" ||
		len(receipt.GiftEffectTypes) != 1 || receipt.GiftEffectTypes[0] != resourcedomain.GiftEffectAnimation {
		t.Fatalf("pinned gift send drifted to mutable catalog or caller price: %+v", receipt)
	}
	replayCommand := command
	replayCommand.PriceVersion = "another-caller-version"
	replayed, err := svc.DebitGift(ctx, replayCommand)
	if err != nil || replayed.TransactionID != receipt.TransactionID || replayed.CoinSpent != receipt.CoinSpent {
		t.Fatalf("disabled pinned gift replay must return original receipt: replay=%+v err=%v", replayed, err)
	}
	_, _, remaining = repository.ActiveResourceEntitlement(63001, giftResource.ResourceID)
	if remaining != 1 {
		t.Fatalf("idempotent replay consumed inventory twice: remaining=%d", remaining)
	}

	batchGrant := grantPinned("activity-pinned-gift:user:63005", 63005)
	batchCommand := ledger.BatchDebitGiftCommand{
		CommandID: "batch-send-pinned-gift", RoomID: "room-pinned-gift", SenderUserID: 63005,
		GiftID: "activity-pinned-star", GiftCount: 1, PriceVersion: "batch-spoof-v999",
		EntitlementID: batchGrant.Items[0].EntitlementID, ChargeSource: "bag",
		Targets: []ledger.DebitGiftTargetCommand{
			{CommandID: "batch-send-pinned-gift:63006", TargetUserID: 63006},
			{CommandID: "batch-send-pinned-gift:63007", TargetUserID: 63007},
		},
	}
	batchReceipt, err := svc.BatchDebitGift(ctx, batchCommand)
	if err != nil {
		t.Fatalf("batch send disabled pinned gift: %v", err)
	}
	if len(batchReceipt.Targets) != 2 || batchReceipt.Aggregate.CoinSpent != 14 {
		t.Fatalf("pinned batch gift aggregate mismatch: %+v", batchReceipt)
	}
	for _, target := range batchReceipt.Targets {
		if target.Receipt.CoinSpent != 7 || target.Receipt.PriceVersion != "v1" || target.Receipt.GiftName != "Pinned Star V1" {
			t.Fatalf("pinned batch target drifted to mutable catalog: %+v", target)
		}
	}
	batchReplayCommand := batchCommand
	batchReplayCommand.PriceVersion = "batch-another-caller-version"
	batchReplayed, err := svc.BatchDebitGift(ctx, batchReplayCommand)
	if err != nil || len(batchReplayed.Targets) != 2 || batchReplayed.Targets[0].Receipt.TransactionID != batchReceipt.Targets[0].Receipt.TransactionID {
		t.Fatalf("pinned batch replay must ignore caller price version: replay=%+v err=%v", batchReplayed, err)
	}
	_, _, remaining = repository.ActiveResourceEntitlement(63005, giftResource.ResourceID)
	if remaining != 1 {
		t.Fatalf("pinned batch replay consumed inventory twice: remaining=%d", remaining)
	}

	tamperedGrant := grantPinned("activity-pinned-gift:user:63003", 63003)
	repository.TamperPinnedGiftSnapshot(tamperedGrant.Items[0].EntitlementID)
	if _, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
		CommandID: "send-tampered-pinned-gift", RoomID: "room-pinned-gift", SenderUserID: 63003, TargetUserID: 63004,
		GiftID: "activity-pinned-star", GiftCount: 1, EntitlementID: tamperedGrant.Items[0].EntitlementID, ChargeSource: "bag",
	}); err == nil {
		t.Fatal("tampered pinned gift snapshot must fail closed")
	}
	_, _, remaining = repository.ActiveResourceEntitlement(63003, giftResource.ResourceID)
	if remaining != 3 {
		t.Fatalf("tampered snapshot attempt consumed inventory: remaining=%d", remaining)
	}
}

func TestPinGiftResourceGroupRequiresActivityRegionCoverage(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()
	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode: "regional_reward_gift", ResourceType: resourcedomain.TypeGift, Name: "Regional Reward Gift",
		Status: resourcedomain.StatusActive, Grantable: true, GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
		PriceType: resourcedomain.PriceTypeCoin, CoinPrice: 9, UsageScopes: []string{"gift"}, OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create regional reward gift: %v", err)
	}
	if _, err := svc.CreateGiftConfig(ctx, resourcedomain.GiftConfigCommand{
		GiftID: "regional-reward-gift", ResourceID: resource.ResourceID, Status: resourcedomain.StatusActive,
		Name: "Regional Reward Gift", PriceVersion: "v1", GiftTypeCode: resourcedomain.GiftTypeNormal,
		RegionIDs: []int64{99}, OperatorUserID: 90001,
	}); err != nil {
		t.Fatalf("create region-99 gift config: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "regional_reward_pack", Name: "Regional Reward Pack", Status: resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{{ResourceID: resource.ResourceID, Quantity: 1}}, OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create regional reward group: %v", err)
	}
	hash, err := resourcedomain.ResourceGroupContentHash(group)
	if err != nil {
		t.Fatalf("hash regional reward group: %v", err)
	}
	pin := func(key string, regions []int64, all bool) error {
		_, pinErr := svc.PinResourceGroupSnapshot(ctx, resourcedomain.PinResourceGroupSnapshotCommand{
			PinKey: key, GroupID: group.GroupID, OperatorUserID: 90001,
			ExpectedGroupUpdatedAtMS: group.UpdatedAtMS, ExpectedSourceContentHash: hash,
			RequiredRegionIDs: regions, RequiredAllRegions: all,
		})
		return pinErr
	}
	if err := pin("activity-region-missing", []int64{1}, false); err == nil {
		t.Fatal("pin must reject gift reward unavailable in a targeted activity region")
	}
	if err := pin("activity-all-regions-missing-global", nil, true); err == nil {
		t.Fatal("all-regions pin must require a global gift config")
	}
	if err := pin("activity-region-covered", []int64{99}, false); err != nil {
		t.Fatalf("pin rejected covered target region: %v", err)
	}
}

// TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement 锁定撤销只按原发放明细反向处理，不受资源组后续配置变化影响。
func TestRevokeResourceGroupGrantReversesWalletCreditAndEntitlement(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "badge_revoke_vip",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Revoke VIP Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create revoke badge resource failed: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "revoke_pack",
		Name:      "Revoke 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 revoke resource group failed: %v", err)
	}
	grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-grant-revoke-pack",
		TargetUserID:   42101,
		GroupID:        group.GroupID,
		Reason:         "test revoke group",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("GrantResourceGroup for revoke failed: %v", err)
	}
	entitlementID := entitlementIDFromGrant(t, grant)

	revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-pack",
		GrantID:        grant.GrantID,
		Reason:         "test revoke",
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("RevokeResourceGrant failed: %v", err)
	}
	if revoked.Status != resourcedomain.GrantStatusRevoked || revoked.RevokedAtMS == 0 || revoked.RevokedByUserID != 90002 || revoked.RevokeReason != "test revoke" {
		t.Fatalf("revoked grant metadata mismatch: %+v", revoked)
	}
	balances, err := svc.GetBalances(ctx, 42101, []string{ledger.AssetCoin})
	if err != nil {
		t.Fatalf("GetBalances after revoke failed: %v", err)
	}
	if balanceAmount(balances, ledger.AssetCoin) != 0 {
		t.Fatalf("revoked coin balance mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42101), ledger.AssetCoin, int64(-2000)); got != 1 {
		t.Fatalf("revoke should write one reverse coin entry, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 1 {
		t.Fatalf("revoke should write one reverse wallet transaction, got %d", got)
	}
	if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 {
		t.Fatalf("revoke should mark entitlement unavailable, got %d", got)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42101), entitlementID); got != 0 {
		t.Fatalf("revoke should remove equipment row, got %d", got)
	}

	again, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-pack-again",
		GrantID:        grant.GrantID,
		Reason:         "test revoke again",
		OperatorUserID: 90003,
	})
	if err != nil {
		t.Fatalf("RevokeResourceGrant retry failed: %v", err)
	}
	if again.Status != resourcedomain.GrantStatusRevoked || again.RevokedByUserID != 90002 {
		t.Fatalf("idempotent revoke should keep first metadata: %+v", again)
	}
	if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42101), ledger.AssetCoin, int64(-2000)); got != 1 {
		t.Fatalf("idempotent revoke must not double debit, got %d", got)
	}
}

// TestRevokeUserResourceKeepsResourceGroupSiblings proves the admin detail action removes only the
// selected entitlement. Reusing grant-level revoke here would also remove every sibling in the group.
func TestRevokeUserResourceKeepsResourceGroupSiblings(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	hostBadge, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "host_badge_precise_revoke",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Host Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create host badge failed: %v", err)
	}
	avatarFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "host_frame_precise_revoke",
		ResourceType:   resourcedomain.TypeAvatarFrame,
		Name:           "Host Frame",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create host frame failed: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "host_assets_precise_revoke",
		Name:      "Host Assets",
		Status:    resourcedomain.StatusActive,
		Items: []resourcedomain.ResourceGroupItemInput{
			{ResourceID: hostBadge.ResourceID, Quantity: 1, SortOrder: 1},
			{ResourceID: avatarFrame.ResourceID, Quantity: 1, SortOrder: 2},
		},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create host resource group failed: %v", err)
	}
	grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-grant-host-assets-precise-revoke",
		TargetUserID:   42106,
		GroupID:        group.GroupID,
		Reason:         "test precise user resource revoke",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant host resource group failed: %v", err)
	}

	var badgeEntitlementID, frameEntitlementID string
	for _, item := range grant.Items {
		switch item.ResourceID {
		case hostBadge.ResourceID:
			badgeEntitlementID = item.EntitlementID
		case avatarFrame.ResourceID:
			frameEntitlementID = item.EntitlementID
		}
	}
	if badgeEntitlementID == "" || frameEntitlementID == "" {
		t.Fatalf("grant entitlements are incomplete: %+v", grant.Items)
	}
	for resourceID, entitlementID := range map[int64]string{
		hostBadge.ResourceID:   badgeEntitlementID,
		avatarFrame.ResourceID: frameEntitlementID,
	} {
		if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
			UserID: 42106, ResourceID: resourceID, EntitlementID: entitlementID,
		}); err != nil {
			t.Fatalf("equip resource %d failed: %v", resourceID, err)
		}
	}

	revoked, err := svc.RevokeUserResource(ctx, resourcedomain.RevokeUserResourceCommand{
		RequestID:      "request-revoke-host-badge",
		UserID:         42106,
		EntitlementID:  badgeEntitlementID,
		Reason:         "remove host badge",
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("RevokeUserResource failed: %v", err)
	}
	if revoked.Status != resourcedomain.GrantStatusRevoked || revoked.RemainingQuantity != 0 || revoked.Equipped {
		t.Fatalf("revoked entitlement state mismatch: %+v", revoked)
	}
	active, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{UserID: 42106, ActiveOnly: true})
	if err != nil {
		t.Fatalf("list active resources failed: %v", err)
	}
	if len(active) != 1 || active[0].EntitlementID != frameEntitlementID {
		t.Fatalf("resource-group sibling must remain active: %+v", active)
	}
	if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusDone); got != 1 {
		t.Fatalf("precise revoke must not revoke the source group grant, got %d", got)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42106), badgeEntitlementID); got != 0 {
		t.Fatalf("revoked badge equipment row must be removed, got %d", got)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42106), frameEntitlementID); got != 1 {
		t.Fatalf("sibling frame equipment row must remain, got %d", got)
	}

	outboxCount := repository.CountRows("wallet_outbox", "event_type = ? AND user_id = ?", "UserResourceChanged", int64(42106))
	if _, err := svc.RevokeUserResource(ctx, resourcedomain.RevokeUserResourceCommand{
		RequestID:      "request-revoke-host-badge-retry",
		UserID:         42106,
		EntitlementID:  badgeEntitlementID,
		Reason:         "remove host badge retry",
		OperatorUserID: 90003,
	}); err != nil {
		t.Fatalf("idempotent RevokeUserResource retry failed: %v", err)
	}
	if got := repository.CountRows("wallet_outbox", "event_type = ? AND user_id = ?", "UserResourceChanged", int64(42106)); got != outboxCount {
		t.Fatalf("idempotent retry must not duplicate outbox facts: got %d want %d", got, outboxCount)
	}
}

// TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient 验证撤回金币时余额不足也不能扣成负数。
func TestRevokeResourceGroupGrantClampsWalletDebitWhenBalanceInsufficient(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "badge_revoke_insufficient",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Revoke Insufficient Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create insufficient badge resource failed: %v", err)
	}
	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode: "revoke_insufficient_pack",
		Name:      "Revoke Insufficient 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 insufficient resource group failed: %v", err)
	}
	grant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-grant-revoke-insufficient",
		TargetUserID:   42102,
		GroupID:        group.GroupID,
		Reason:         "test revoke insufficient",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("GrantResourceGroup insufficient setup failed: %v", err)
	}
	entitlementID := entitlementIDFromGrant(t, grant)
	repository.SetBalance(42102, 100)

	revoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-insufficient",
		GrantID:        grant.GrantID,
		Reason:         "test insufficient",
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("RevokeResourceGrant should clamp insufficient balance: %v", err)
	}
	if revoked.Status != resourcedomain.GrantStatusRevoked {
		t.Fatalf("insufficient balance revoke should still mark grant revoked: %+v", revoked)
	}
	assertBalance(t, svc, 42102, ledger.AssetCoin, 0)
	if got := repository.CountRows("resource_grants", "grant_id = ? AND status = ?", grant.GrantID, resourcedomain.GrantStatusRevoked); got != 1 {
		t.Fatalf("clamped revoke must mark grant revoked, got %d", got)
	}
	if got := repository.CountRows("user_resource_entitlements", "entitlement_id = ? AND status = ? AND remaining_quantity = 0", entitlementID, resourcedomain.GrantStatusRevoked); got != 1 {
		t.Fatalf("clamped revoke must still revoke entitlement, got %d", got)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND entitlement_id = ?", int64(42102), entitlementID); got != 0 {
		t.Fatalf("clamped revoke must remove equipment row, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42102), ledger.AssetCoin, int64(-100)); got != 1 {
		t.Fatalf("clamped revoke should write one partial reverse coin entry, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "external_ref = ? AND biz_type = ?", grant.GrantID, "resource_grant_revoke"); got != 1 {
		t.Fatalf("clamped revoke should write one reverse transaction, got %d", got)
	}
}

// TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration 锁定单资源撤回的金币夹零和素材天数扣减语义。
func TestRevokeSingleResourceGrantClampsCoinAndDeductsEntitlementDuration(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	coinResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:      "coin_single_revoke_reject",
		ResourceType:      resourcedomain.TypeCoin,
		Name:              "Coin Single Revoke Reject",
		Status:            resourcedomain.StatusActive,
		Grantable:         true,
		GrantStrategy:     resourcedomain.GrantStrategyWalletCredit,
		WalletAssetType:   ledger.AssetCoin,
		WalletAssetAmount: 100,
		OperatorUserID:    90001,
	})
	if err != nil {
		t.Fatalf("create single coin resource failed: %v", err)
	}
	singleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-single-coin-revoke",
		TargetUserID:   42103,
		ResourceID:     coinResource.ResourceID,
		Quantity:       2,
		Reason:         "single coin revoke",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("single coin GrantResource setup failed: %v", err)
	}
	repository.SetBalance(42103, 50)
	coinRevoked, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-single-coin",
		GrantID:        singleGrant.GrantID,
		Reason:         "single coin revoke",
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("single coin RevokeResourceGrant failed: %v", err)
	}
	if coinRevoked.Status != resourcedomain.GrantStatusRevoked {
		t.Fatalf("single coin revoke status mismatch: %+v", coinRevoked)
	}
	assertBalance(t, svc, 42103, ledger.AssetCoin, 0)
	if got := repository.CountRows("wallet_entries", "user_id = ? AND asset_type = ? AND available_delta = ?", int64(42103), ledger.AssetCoin, int64(-50)); got != 1 {
		t.Fatalf("single coin revoke should clamp reverse entry to available balance, got %d", got)
	}

	frameResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "frame_single_revoke_duration",
		ResourceType:   resourcedomain.TypeAvatarFrame,
		Name:           "Frame Single Revoke Duration",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyExtendExpiry,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create frame resource failed: %v", err)
	}
	durationMS := int64((7 * 24 * time.Hour) / time.Millisecond)
	firstFrameGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-frame-revoke-duration-first",
		TargetUserID:   42105,
		ResourceID:     frameResource.ResourceID,
		Quantity:       1,
		DurationMS:     durationMS,
		Reason:         "frame duration first",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("first frame GrantResource setup failed: %v", err)
	}
	firstExpiresAt, firstQuantity, firstRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID)
	if firstQuantity != 1 || firstRemaining != 1 {
		t.Fatalf("first frame entitlement mismatch quantity=%d remaining=%d", firstQuantity, firstRemaining)
	}
	secondFrameGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-frame-revoke-duration-second",
		TargetUserID:   42105,
		ResourceID:     frameResource.ResourceID,
		Quantity:       1,
		DurationMS:     durationMS,
		Reason:         "frame duration second",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("second frame GrantResource setup failed: %v", err)
	}
	secondExpiresAt, secondQuantity, secondRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID)
	if secondQuantity != 2 || secondRemaining != 2 || secondExpiresAt != firstExpiresAt+durationMS {
		t.Fatalf("second frame entitlement should extend by one duration: first=%d second=%d quantity=%d remaining=%d", firstExpiresAt, secondExpiresAt, secondQuantity, secondRemaining)
	}
	if _, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-frame-second",
		GrantID:        secondFrameGrant.GrantID,
		Reason:         "frame duration second revoke",
		OperatorUserID: 90002,
	}); err != nil {
		t.Fatalf("second frame RevokeResourceGrant failed: %v", err)
	}
	afterSecondRevokeExpiresAt, afterSecondQuantity, afterSecondRemaining := repository.ActiveResourceEntitlement(42105, frameResource.ResourceID)
	if afterSecondExpiresAt := afterSecondRevokeExpiresAt; afterSecondExpiresAt != firstExpiresAt || afterSecondQuantity != 1 || afterSecondRemaining != 1 {
		t.Fatalf("second revoke should deduct exactly one duration: expires=%d want=%d quantity=%d remaining=%d", afterSecondExpiresAt, firstExpiresAt, afterSecondQuantity, afterSecondRemaining)
	}
	if _, err := svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-frame-first",
		GrantID:        firstFrameGrant.GrantID,
		Reason:         "frame duration first revoke",
		OperatorUserID: 90002,
	}); err != nil {
		t.Fatalf("first frame RevokeResourceGrant failed: %v", err)
	}
	if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ? AND status = ? AND remaining_quantity = 0 AND expires_at_ms <= ?", int64(42105), frameResource.ResourceID, resourcedomain.GrantStatusRevoked, time.Now().UnixMilli()); got != 1 {
		t.Fatalf("first revoke should clamp material entitlement to zero remaining duration, got %d", got)
	}
}

// TestRevokeResourceGrantRejectsUnsupportedRecords 固定异常状态和不存在记录不能误处理。
func TestRevokeResourceGrantRejectsUnsupportedRecords(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode:      "failed_revoke_reject_pack",
		Name:           "Failed Revoke Reject Pack",
		Status:         resourcedomain.StatusActive,
		Items:          []resourcedomain.ResourceGroupItemInput{{ItemType: resourcedomain.GroupItemTypeWalletAsset, WalletAssetType: ledger.AssetCoin, WalletAssetAmount: 10}},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create failed-status resource group failed: %v", err)
	}
	groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-failed-revoke-reject",
		TargetUserID:   42104,
		GroupID:        group.GroupID,
		Reason:         "failed reject",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("group GrantResourceGroup setup failed: %v", err)
	}
	repository.SetResourceGrantStatus(groupGrant.GrantID, "failed")
	_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-failed-reject",
		GrantID:        groupGrant.GrantID,
		Reason:         "failed reject",
		OperatorUserID: 90002,
	})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("failed grant revoke should conflict, got %v", err)
	}

	_, err = svc.RevokeResourceGrant(ctx, resourcedomain.RevokeResourceGrantCommand{
		RequestID:      "cmd-revoke-missing",
		GrantID:        "rgr_missing_revoke",
		Reason:         "missing reject",
		OperatorUserID: 90002,
	})
	if !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("missing grant revoke should be not found, got %v", err)
	}
}

// TestMicSeatIconAndAnimationUseIndependentEquipmentSlots 验证 Voice Wave 默认佩戴后，
// 再佩戴麦位中心图标不会覆盖声波；两个 resource_type 必须各保留一个当前装备。
func TestMicSeatIconAndAnimationUseIndependentEquipmentSlots(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	firstMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "mic_wave_blue",
		ResourceType:   resourcedomain.TypeMicSeatAnimation,
		Name:           "Blue Mic Wave",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"mic_seat"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create first mic seat animation resource failed: %v", err)
	}
	secondMic, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "mic_wave_gold",
		ResourceType:   resourcedomain.TypeMicSeatAnimation,
		Name:           "Gold Mic Wave",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyIncreaseQuantity,
		UsageScopes:    []string{"mic_seat"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create second mic seat animation resource failed: %v", err)
	}

	firstGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-mic-wave-blue",
		TargetUserID:   42003,
		ResourceID:     firstMic.ResourceID,
		Quantity:       1,
		Reason:         "test mic auto equip",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant first mic seat animation resource failed: %v", err)
	}
	resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
		UserID:       42003,
		ResourceType: resourcedomain.TypeMicSeatAnimation,
		ActiveOnly:   true,
	})
	if err != nil {
		t.Fatalf("list mic seat animation resources failed: %v", err)
	}
	if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != firstGrant.Items[0].EntitlementID {
		t.Fatalf("single resource grant should auto equip mic seat animation: resources=%+v grant=%+v", resources, firstGrant)
	}

	group, err := svc.CreateResourceGroup(ctx, resourcedomain.ResourceGroupCommand{
		GroupCode:      "mic_wave_group",
		Name:           "Mic Wave Group",
		Status:         resourcedomain.StatusActive,
		Items:          []resourcedomain.ResourceGroupItemInput{{ResourceID: secondMic.ResourceID, Quantity: 1, SortOrder: 1}},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create mic seat animation group failed: %v", err)
	}
	groupGrant, err := svc.GrantResourceGroup(ctx, resourcedomain.GrantResourceGroupCommand{
		CommandID:      "cmd-grant-mic-wave-group",
		TargetUserID:   42003,
		GroupID:        group.GroupID,
		Reason:         "test group mic auto equip",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant mic seat animation group failed: %v", err)
	}
	equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs:       []int64{42003},
		ResourceTypes: []string{resourcedomain.TypeMicSeatAnimation},
	})
	if err != nil {
		t.Fatalf("batch get mic seat animation equipment failed: %v", err)
	}
	if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].EntitlementID != groupGrant.Items[0].EntitlementID {
		t.Fatalf("group grant should auto equip the latest mic seat animation: equipped=%+v group_grant=%+v", equippedResources, groupGrant)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourcedomain.TypeMicSeatAnimation); got != 1 {
		t.Fatalf("mic seat animation equipment should keep one active row, got %d", got)
	}

	micIcon, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "mic_icon_gold",
		ResourceType:   resourcedomain.TypeMicSeatIcon,
		Name:           "Gold Mic Icon",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"mic_seat"},
		AssetURL:       "https://cdn.example/mic-icon.png",
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create mic seat icon resource failed: %v", err)
	}
	iconGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-mic-icon-gold",
		TargetUserID:   42003,
		ResourceID:     micIcon.ResourceID,
		Quantity:       1,
		Reason:         "test independent mic icon equipment",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant mic seat icon resource failed: %v", err)
	}
	if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		CommandID:     "cmd-equip-mic-icon-gold",
		UserID:        42003,
		ResourceID:    micIcon.ResourceID,
		EntitlementID: iconGrant.Items[0].EntitlementID,
	}); err != nil {
		t.Fatalf("equip mic seat icon resource failed: %v", err)
	}
	independentEquipment, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs:       []int64{42003},
		ResourceTypes: []string{resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation},
	})
	if err != nil {
		t.Fatalf("batch get independent mic equipment failed: %v", err)
	}
	if len(independentEquipment) != 1 || len(independentEquipment[0].Resources) != 2 {
		t.Fatalf("mic icon equipment must preserve Voice Wave equipment: %+v", independentEquipment)
	}
	for _, resourceType := range []string{resourcedomain.TypeMicSeatIcon, resourcedomain.TypeMicSeatAnimation} {
		if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(42003), resourceType); got != 1 {
			t.Fatalf("%s must keep its own single-select equipment row, got %d", resourceType, got)
		}
	}
}

// TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement 锁定道具商店购买的原子语义。
func TestPurchaseResourceShopItemDebitsCoinAndGrantsEntitlement(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(53001, 1000)
	svc := walletservice.New(repository)
	ctx := context.Background()

	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "shop_badge_gold",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Gold Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategyExtendExpiry,
		PriceType:      resourcedomain.PriceTypeCoin,
		CoinPrice:      100,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create shop resource failed: %v", err)
	}
	shopItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
		Items: []resourcedomain.ResourceShopItemInput{
			{
				ResourceID:   resource.ResourceID,
				Status:       resourcedomain.StatusActive,
				DurationDays: resourcedomain.ShopDurationOneDay,
				SortOrder:    1,
			},
			{
				ResourceID:   resource.ResourceID,
				Status:       resourcedomain.StatusActive,
				DurationDays: resourcedomain.ShopDurationThreeDays,
				CoinPrice:    250,
				SortOrder:    2,
			},
			{
				ResourceID:   resource.ResourceID,
				Status:       resourcedomain.StatusActive,
				DurationDays: resourcedomain.ShopDurationSevenDays,
				CoinPrice:    600,
				SortOrder:    3,
			},
		},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("upsert shop item failed: %v", err)
	}
	if len(shopItems) != 3 {
		t.Fatalf("one resource should keep three sale durations, got %+v", shopItems)
	}
	var purchaseItem resourcedomain.ResourceShopItem
	for _, item := range shopItems {
		if item.DurationDays == resourcedomain.ShopDurationOneDay && item.CoinPrice != resource.CoinPrice {
			t.Fatalf("omitted sale price should copy resource price: %+v", item)
		}
		if item.DurationDays == resourcedomain.ShopDurationThreeDays {
			purchaseItem = item
		}
	}
	if purchaseItem.ShopItemID == 0 || purchaseItem.CoinPrice != 250 {
		t.Fatalf("custom three-day sale price mismatch: %+v", purchaseItem)
	}

	receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
		CommandID:  "cmd-shop-badge",
		UserID:     53001,
		ShopItemID: purchaseItem.ShopItemID,
	})
	if err != nil {
		t.Fatalf("PurchaseResourceShopItem failed: %v", err)
	}
	if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 250 || receipt.Balance.AvailableAmount != 750 {
		t.Fatalf("resource shop purchase receipt mismatch: %+v", receipt)
	}
	if receipt.Resource.EntitlementID == "" || receipt.Resource.ResourceID != resource.ResourceID || receipt.Resource.ExpiresAtMS <= time.Now().UnixMilli() {
		t.Fatalf("resource shop entitlement mismatch: %+v", receipt.Resource)
	}
	if !receipt.Resource.Equipped {
		t.Fatalf("resource shop badge purchase should auto equip receipt resource: %+v", receipt.Resource)
	}
	if _, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
		Items: []resourcedomain.ResourceShopItemInput{{
			ShopItemID:   purchaseItem.ShopItemID,
			ResourceID:   resource.ResourceID,
			Status:       resourcedomain.StatusActive,
			DurationDays: resourcedomain.ShopDurationThreeDays,
			CoinPrice:    275,
			SortOrder:    2,
		}},
		OperatorUserID: 90001,
	}); err != nil {
		t.Fatalf("update shop item price failed: %v", err)
	}
	preservedItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
		Items: []resourcedomain.ResourceShopItemInput{{
			ShopItemID:   purchaseItem.ShopItemID,
			ResourceID:   resource.ResourceID,
			Status:       resourcedomain.StatusActive,
			DurationDays: resourcedomain.ShopDurationThreeDays,
			SortOrder:    4,
		}},
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("update shop item without price failed: %v", err)
	}
	var preservedPrice int64
	for _, item := range preservedItems {
		if item.ShopItemID == purchaseItem.ShopItemID {
			preservedPrice = item.CoinPrice
		}
	}
	if preservedPrice != 275 {
		t.Fatalf("omitted price on an existing item must preserve the custom price, got %d", preservedPrice)
	}
	naturalKeyItems, err := svc.UpsertResourceShopItems(ctx, resourcedomain.ResourceShopItemsCommand{
		Items: []resourcedomain.ResourceShopItemInput{{
			ResourceID:   resource.ResourceID,
			Status:       resourcedomain.StatusActive,
			DurationDays: resourcedomain.ShopDurationThreeDays,
			SortOrder:    5,
		}},
		OperatorUserID: 90003,
	})
	if err != nil {
		t.Fatalf("update shop item by natural key without price failed: %v", err)
	}
	preservedPrice = 0
	for _, item := range naturalKeyItems {
		if item.ShopItemID == purchaseItem.ShopItemID {
			preservedPrice = item.CoinPrice
		}
	}
	if preservedPrice != 275 {
		t.Fatalf("natural-key update without price must preserve the custom price, got %d", preservedPrice)
	}

	again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
		CommandID:  "cmd-shop-badge",
		UserID:     53001,
		ShopItemID: purchaseItem.ShopItemID,
	})
	if err != nil {
		t.Fatalf("PurchaseResourceShopItem retry failed: %v", err)
	}
	if again.OrderID != receipt.OrderID || again.TransactionID != receipt.TransactionID || again.Balance.AvailableAmount != receipt.Balance.AvailableAmount || again.ShopItem.CoinPrice != receipt.CoinSpent {
		t.Fatalf("resource shop idempotency mismatch: first=%+v again=%+v", receipt, again)
	}
	if got := repository.CountRows("resource_shop_purchase_orders", "user_id = ?", int64(53001)); got != 1 {
		t.Fatalf("resource shop order should be persisted once, got %d", got)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-shop-badge", "resource_shop_purchase"); got != 1 {
		t.Fatalf("resource shop purchase should write one wallet transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(-250)); got != 1 {
		t.Fatalf("resource shop purchase should write one debit entry, got %d", got)
	}
	if got := repository.CountRows("resource_grants", "grant_id = ?", receipt.GrantID); got != 1 {
		t.Fatalf("resource shop purchase should write one resource grant, got %d", got)
	}
	if got := repository.CountRows("user_resource_entitlements", "user_id = ? AND resource_id = ?", int64(53001), resource.ResourceID); got != 1 {
		t.Fatalf("resource shop purchase should grant one entitlement, got %d", got)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ? AND resource_id = ?", int64(53001), resourcedomain.TypeBadge, resource.ResourceID); got != 1 {
		t.Fatalf("resource shop badge purchase should write one equipment row, got %d", got)
	}
}

func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	activity := &fakeActivityBadgeClient{status: "consumed"}
	svc := walletservice.New(repository, activity)
	ctx := context.Background()

	badgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:        "badge_projection_tile",
		ResourceType:        resourcedomain.TypeBadge,
		Name:                "Projection Tile Badge",
		Status:              resourcedomain.StatusActive,
		Grantable:           true,
		ManagerGrantEnabled: true,
		GrantStrategy:       resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:         []string{"profile"},
		MetadataJSON:        `{"badge_form":"tile"}`,
		OperatorUserID:      90001,
	})
	if err != nil {
		t.Fatalf("create badge resource failed: %v", err)
	}
	grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-badge-projection",
		TargetUserID:   42002,
		ResourceID:     badgeResource.ResourceID,
		Quantity:       1,
		Reason:         "system badge grant",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("GrantResource failed: %v", err)
	}

	records, err := repository.ClaimPendingWalletOutbox(ctx, "badge-mq-test", 20, time.Now().Add(time.Minute).UnixMilli())
	if err != nil {
		t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
	}
	processed := 0
	for _, record := range records {
		handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute)
		if err != nil {
			t.Fatalf("ProcessWalletProjectionMessage failed: %v", err)
		}
		if handled {
			processed++
		}
	}
	if processed != 1 || len(activity.requests) != 1 {
		t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests))
	}
	req := activity.requests[0]
	if req.GetEventType() != "ResourceGranted" || req.GetSourceService() != "wallet-service" || req.GetMetricType() != "wallet_badge_grant" || req.GetUserId() != 42002 {
		t.Fatalf("activity request mismatch: %+v", req)
	}
	var payload struct {
		GrantID     string `json:"grant_id"`
		GrantSource string `json:"grant_source"`
		Items       []struct {
			ResourceID    int64  `json:"resource_id"`
			ResourceType  string `json:"resource_type"`
			EntitlementID string `json:"entitlement_id"`
			MetadataJSON  string `json:"metadata_json"`
		} `json:"items"`
	}
	if err := json.Unmarshal([]byte(req.GetDimensionsJson()), &payload); err != nil {
		t.Fatalf("decode badge projection payload failed: %v", err)
	}
	if payload.GrantID != grant.GrantID || payload.GrantSource != resourcedomain.GrantSourceAdmin || len(payload.Items) != 1 {
		t.Fatalf("badge projection payload mismatch: %+v", payload)
	}
	if payload.Items[0].ResourceID != badgeResource.ResourceID || payload.Items[0].ResourceType != resourcedomain.TypeBadge || payload.Items[0].EntitlementID == "" {
		t.Fatalf("badge projection item mismatch: %+v", payload.Items[0])
	}
	if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 {
		t.Fatalf("badge projection should mark event done, got %d", got)
	}
	processed = 0
	for _, record := range records {
		handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute)
		if err != nil {
			t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err)
		}
		if handled {
			processed++
		}
	}
	if processed != 0 || len(activity.requests) != 1 {
		t.Fatalf("badge projection must be idempotent: processed=%d requests=%d", processed, len(activity.requests))
	}
}

// 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)
	}
	if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-admin-grant-same-resource",
		TargetUserID:   43002,
		ResourceID:     enabled.ResourceID,
		Quantity:       1,
		Reason:         "main admin",
		OperatorUserID: 92001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	}); err != nil {
		t.Fatalf("admin comparison grant failed: %v", err)
	}
	ownHistory, ownTotal, err := svc.ListResourceGrants(ctx, resourcedomain.ListResourceGrantsQuery{
		GrantSource:     resourcedomain.GrantSourceManagerCenter,
		OperatorUserIDs: []int64{91001, 91001, 0},
		Page:            1,
		PageSize:        20,
	})
	if err != nil {
		t.Fatalf("ListResourceGrants own manager history failed: %v", err)
	}
	if ownTotal != 1 || len(ownHistory) != 1 || ownHistory[0].GrantID != grant.GrantID {
		t.Fatalf("manager history must filter before pagination: total=%d items=%+v", ownTotal, ownHistory)
	}
	allHistory, allTotal, err := svc.ListResourceGrants(ctx, resourcedomain.ListResourceGrantsQuery{
		Page: 1, PageSize: 20,
	})
	if err != nil {
		t.Fatalf("ListResourceGrants unfiltered history failed: %v", err)
	}
	if allTotal != 2 || len(allHistory) != 2 {
		t.Fatalf("omitted grant source must retain main-admin all-source history: total=%d items=%+v", allTotal, allHistory)
	}

	_, 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)
	}
}

// TestManagerCenterVipTrialCardRejectsAboveVipSix locks the wallet-owner ceiling so
// future HTTP routes cannot bypass the external portal's VIP6 UI validation.
func TestManagerCenterVipTrialCardRejectsAboveVipSix(t *testing.T) {
	svc := walletservice.New(mysqltest.NewRepository(t))
	_, err := svc.GrantVipTrialCard(context.Background(), ledger.GrantVipTrialCardCommand{
		CommandID:      "manager-center-vip7-denied",
		TargetUserID:   43003,
		Level:          7,
		DurationMS:     int64(24 * time.Hour / time.Millisecond),
		GrantSource:    ledger.VipGrantSourceManagerCenter,
		OperatorUserID: 91001,
		Reason:         "external trial",
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("manager center VIP7 must be rejected by wallet owner, got %v", err)
	}
}

// TestGameRobotInitCanGrantActiveNonGrantableResource 锁定机器人初始化发放不跟随人工发放开关；
// 只要资源仍处于 active，就允许机器人账号获得装扮，避免运营库没有打开 grantable 时生成失败。
func TestGameRobotInitCanGrantActiveNonGrantableResource(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	resource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:        "robot_init_frame",
		ResourceType:        resourcedomain.TypeAvatarFrame,
		Name:                "Robot Init Frame",
		Status:              resourcedomain.StatusActive,
		Grantable:           false,
		ManagerGrantEnabled: false,
		GrantStrategy:       resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:         []string{"profile"},
		OperatorUserID:      90001,
	})
	if err != nil {
		t.Fatalf("create robot init resource failed: %v", err)
	}

	grant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-game-robot-init-non-grantable",
		TargetUserID:   43009,
		ResourceID:     resource.ResourceID,
		Quantity:       1,
		DurationMS:     int64(24 * time.Hour / time.Millisecond),
		Reason:         "game robot init",
		OperatorUserID: 91001,
		GrantSource:    resourcedomain.GrantSourceGameRobotInit,
	})
	if err != nil {
		t.Fatalf("game robot init should grant active non-grantable resource: %v", err)
	}
	if grant.GrantSource != resourcedomain.GrantSourceGameRobotInit || len(grant.Items) != 1 {
		t.Fatalf("game robot init grant response mismatch: %+v", grant)
	}
}

func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	command := resourcedomain.ResourceCommand{
		ResourceCode:        "duplicate_badge",
		ResourceType:        resourcedomain.TypeBadge,
		Name:                "Duplicate Badge",
		Status:              resourcedomain.StatusActive,
		Grantable:           true,
		ManagerGrantEnabled: true,
		GrantStrategy:       resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:         []string{"profile"},
		OperatorUserID:      90001,
	}
	if _, err := svc.CreateResource(ctx, command); err != nil {
		t.Fatalf("first CreateResource failed: %v", err)
	}
	command.Name = "Duplicate Badge Again"
	if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("duplicate resource_code should be conflict, 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)
	}
	resources, err := svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
		UserID:       43001,
		ResourceType: resourcedomain.TypeBadge,
		ActiveOnly:   true,
	})
	if err != nil {
		t.Fatalf("list auto-equipped badge resources failed: %v", err)
	}
	if len(resources) != 1 || !resources[0].Equipped || resources[0].EntitlementID != grant.Items[0].EntitlementID {
		t.Fatalf("badge grant should auto equip entitlement: resources=%+v grant=%+v", resources, grant)
	}
	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)
	}
	if !equipped.Equipped {
		t.Fatalf("equip response should mark entitlement as equipped: %+v", equipped)
	}
	secondBadgeResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "badge_equippable_short",
		ResourceType:   resourcedomain.TypeBadge,
		Name:           "Second Equippable Badge",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create second badge resource failed: %v", err)
	}
	secondGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-equip-badge-second",
		TargetUserID:   43001,
		ResourceID:     secondBadgeResource.ResourceID,
		Quantity:       1,
		Reason:         "test second badge equip",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant second badge resource failed: %v", err)
	}
	resources, err = svc.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
		UserID:       43001,
		ResourceType: resourcedomain.TypeBadge,
		ActiveOnly:   true,
	})
	if err != nil {
		t.Fatalf("list user resources failed: %v", err)
	}
	if len(resources) != 2 {
		t.Fatalf("badge grants should keep multiple equipped resources: resources=%+v", resources)
	}
	equippedBadgeIDs := map[string]bool{}
	for _, item := range resources {
		if item.Equipped {
			equippedBadgeIDs[item.EntitlementID] = true
		}
	}
	if !equippedBadgeIDs[equipped.EntitlementID] || !equippedBadgeIDs[secondGrant.Items[0].EntitlementID] {
		t.Fatalf("resource list should expose both equipped badges: resources=%+v first=%+v second=%+v", resources, equipped, secondGrant)
	}
	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 != 2 {
		t.Fatalf("repeat equip should not duplicate and should keep both active badges, got %d", got)
	}
	equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs:       []int64{43001},
		ResourceTypes: []string{resourcedomain.TypeBadge},
	})
	if err != nil {
		t.Fatalf("batch get equipped resources failed: %v", err)
	}
	if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 2 {
		t.Fatalf("batch equipped resources mismatch: %+v", equippedResources)
	}
	unequipped, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
		UserID:       43001,
		ResourceType: resourcedomain.TypeBadge,
	})
	if err != nil {
		t.Fatalf("unequip badge resource failed: %v", err)
	}
	if unequipped.Unequipped || unequipped.ResourceType != resourcedomain.TypeBadge {
		t.Fatalf("unequip result mismatch: %+v", unequipped)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 2 {
		t.Fatalf("badge equipment should remain after no-op unequip, got %d", got)
	}
	emptyUnequip, err := svc.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
		UserID:       43001,
		ResourceType: resourcedomain.TypeBadge,
	})
	if err != nil {
		t.Fatalf("repeat unequip badge resource failed: %v", err)
	}
	if emptyUnequip.Unequipped {
		t.Fatalf("repeat unequip should be idempotent false: %+v", emptyUnequip)
	}

	vehicleResource, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "vehicle_expiring",
		ResourceType:   resourcedomain.TypeVehicle,
		Name:           "Expiring Vehicle",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"room_entry"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create vehicle resource failed: %v", err)
	}
	vehicleGrant, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-expiring-vehicle",
		TargetUserID:   43001,
		ResourceID:     vehicleResource.ResourceID,
		Quantity:       1,
		DurationMS:     int64(time.Hour / time.Millisecond),
		Reason:         "test expired equipment",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	})
	if err != nil {
		t.Fatalf("grant vehicle resource failed: %v", err)
	}
	if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:     43001,
		ResourceID: vehicleResource.ResourceID,
	}); err != nil {
		t.Fatalf("equip vehicle resource failed: %v", err)
	}
	repository.ExpireEntitlement(vehicleGrant.Items[0].EntitlementID, time.Now().Add(-time.Second).UnixMilli())
	expiredEquipped, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs:       []int64{43001},
		ResourceTypes: []string{resourcedomain.TypeVehicle},
	})
	if err != nil {
		t.Fatalf("batch get expired equipped resources failed: %v", err)
	}
	if len(expiredEquipped) != 1 || len(expiredEquipped[0].Resources) != 0 {
		t.Fatalf("expired equipment should be filtered: %+v", expiredEquipped)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeVehicle); got != 0 {
		t.Fatalf("expired equipment should be pruned, 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)
	}
}

// TestEquipUserResourceKeepsSingleEquipmentForNonBadgeTypes 验证多徽章 schema 不会改变其它装扮类型的单选语义。
func TestEquipUserResourceKeepsSingleEquipmentForNonBadgeTypes(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	ctx := context.Background()

	firstFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "frame_single_first",
		ResourceType:   resourcedomain.TypeAvatarFrame,
		Name:           "First Frame",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create first frame failed: %v", err)
	}
	secondFrame, err := svc.CreateResource(ctx, resourcedomain.ResourceCommand{
		ResourceCode:   "frame_single_second",
		ResourceType:   resourcedomain.TypeAvatarFrame,
		Name:           "Second Frame",
		Status:         resourcedomain.StatusActive,
		Grantable:      true,
		GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
		UsageScopes:    []string{"profile"},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("create second frame failed: %v", err)
	}
	if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-first-frame",
		TargetUserID:   43002,
		ResourceID:     firstFrame.ResourceID,
		Quantity:       1,
		Reason:         "test first frame",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	}); err != nil {
		t.Fatalf("grant first frame failed: %v", err)
	}
	if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		CommandID:      "cmd-grant-second-frame",
		TargetUserID:   43002,
		ResourceID:     secondFrame.ResourceID,
		Quantity:       1,
		Reason:         "test second frame",
		OperatorUserID: 90001,
		GrantSource:    resourcedomain.GrantSourceAdmin,
	}); err != nil {
		t.Fatalf("grant second frame failed: %v", err)
	}
	if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:     43002,
		ResourceID: firstFrame.ResourceID,
	}); err != nil {
		t.Fatalf("equip first frame failed: %v", err)
	}
	if _, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:     43002,
		ResourceID: secondFrame.ResourceID,
	}); err != nil {
		t.Fatalf("equip second frame failed: %v", err)
	}

	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43002), resourcedomain.TypeAvatarFrame); got != 1 {
		t.Fatalf("avatar_frame equipment should keep one active row, got %d", got)
	}
	equippedResources, err := svc.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
		UserIDs:       []int64{43002},
		ResourceTypes: []string{resourcedomain.TypeAvatarFrame},
	})
	if err != nil {
		t.Fatalf("batch get equipped frames failed: %v", err)
	}
	if len(equippedResources) != 1 || len(equippedResources[0].Resources) != 1 || equippedResources[0].Resources[0].ResourceID != secondFrame.ResourceID {
		t.Fatalf("avatar_frame should expose only the last equipped resource: %+v", equippedResources)
	}
}

// TestPurchaseVipDebitsCoinAndGrantsReward 验证 VIP 购买在同一账务事务中扣金币、更新会员和发权益。
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(51001, 20000)
	repository.SeedVIPRewardGroup(1, 71001, []mysqltest.VipRewardResourceSeed{
		{
			ResourceID:    7100101,
			ResourceCode:  "vip1_avatar_frame",
			ResourceType:  resourcedomain.TypeAvatarFrame,
			Name:          "VIP1 Avatar Frame",
			GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
		},
	})
	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.CoinBalance.AssetType != ledger.AssetCoin || receipt.CoinBalance.AvailableAmount != 19000 || receipt.CoinBalance.FrozenAmount != 0 || receipt.CoinBalance.Version <= 0 {
		t.Fatalf("vip purchase must return versioned coin balance: %+v", receipt.CoinBalance)
	}
	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)
	}
	if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51001), "VipActivated"); got != 1 {
		t.Fatalf("vip purchase should enqueue one activation IM event, 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 || again.CoinBalance.Version != receipt.CoinBalance.Version {
		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)
	}
	_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip-insufficient",
		UserID:    51002,
		Level:     1,
	})
	if !xerr.IsCode(err, xerr.VIPInsufficientCoin) {
		t.Fatalf("expected VIP_INSUFFICIENT_COIN, got %v", err)
	}
}

func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(51004, 20000)
	resources := []mysqltest.VipRewardResourceSeed{
		{
			ResourceID:    7100401,
			ResourceCode:  "vip_renew_frame",
			ResourceType:  resourcedomain.TypeAvatarFrame,
			Name:          "VIP Renew Frame",
			GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
		},
		{
			ResourceID:    7100402,
			ResourceCode:  "vip_renew_mic",
			ResourceType:  resourcedomain.TypeMicSeatAnimation,
			Name:          "VIP Renew Mic",
			GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
		},
		{
			ResourceID:    7100403,
			ResourceCode:  "vip_renew_badge",
			ResourceType:  resourcedomain.TypeBadge,
			Name:          "VIP Renew Badge",
			GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
		},
	}
	repository.SeedVIPRewardGroup(1, 71004, resources)
	svc := walletservice.New(repository)

	first, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip-renew-expiry-first",
		UserID:    51004,
		Level:     1,
	})
	if err != nil {
		t.Fatalf("first VIP purchase failed: %v", err)
	}
	if len(first.RewardItems) != len(resources) {
		t.Fatalf("first reward item count mismatch: %+v", first.RewardItems)
	}
	for _, resource := range resources {
		expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
		if expiresAt != first.Vip.ExpiresAtMS {
			t.Fatalf("first entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, first.Vip.ExpiresAtMS)
		}
	}

	renew, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip-renew-expiry-second",
		UserID:    51004,
		Level:     1,
	})
	if err != nil {
		t.Fatalf("VIP renewal failed: %v", err)
	}
	if renew.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS+604800000 {
		t.Fatalf("renewed VIP expiry mismatch: first=%d renewed=%d", first.Vip.ExpiresAtMS, renew.Vip.ExpiresAtMS)
	}
	if len(renew.RewardItems) != len(resources) {
		t.Fatalf("renew reward item count mismatch: %+v", renew.RewardItems)
	}
	for _, item := range renew.RewardItems {
		if item.ExpiresAtMS != renew.Vip.ExpiresAtMS {
			t.Fatalf("renew reward response expiry mismatch for %d: got %d want %d", item.ResourceID, item.ExpiresAtMS, renew.Vip.ExpiresAtMS)
		}
	}
	for _, resource := range resources {
		expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
		if expiresAt != renew.Vip.ExpiresAtMS {
			t.Fatalf("renew entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, renew.Vip.ExpiresAtMS)
		}
	}
}

// TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。
func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	// initdb 不为 Lalu 猜测默认礼包资源；本用例要验证“发放 VIP 同时发放礼包”，
	// 因此必须显式构造资源组，不依赖历史测试库残留或运营配置。
	repository.SeedVIPRewardGroup(1, 71003, []mysqltest.VipRewardResourceSeed{
		{
			ResourceID:    7100301,
			ResourceCode:  "vip_grant_test_frame",
			ResourceType:  resourcedomain.TypeAvatarFrame,
			Name:          "VIP Grant Test Frame",
			GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
		},
	})
	svc := walletservice.New(repository)

	adminGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
		CommandID:      "cmd-admin-vip1",
		TargetUserID:   51003,
		Level:          1,
		GrantSource:    ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001,
		Reason:         "manual vip grant",
	})
	if err != nil {
		t.Fatalf("GrantVip admin failed: %v", err)
	}
	if adminGrant.TransactionID == "" || !adminGrant.Vip.Active || adminGrant.Vip.Level != 1 || len(adminGrant.RewardItems) != 1 {
		t.Fatalf("admin vip grant receipt mismatch: %+v", adminGrant)
	}
	if got := repository.CountRows("wallet_transactions", "command_id = ? AND biz_type = ?", "cmd-admin-vip1", "vip_grant"); got != 1 {
		t.Fatalf("vip grant should write one wallet transaction, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", adminGrant.TransactionID); got != 0 {
		t.Fatalf("vip grant should not write coin entries, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 1 {
		t.Fatalf("vip grant should enqueue one activation IM event, got %d", got)
	}

	again, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
		CommandID:      "cmd-admin-vip1",
		TargetUserID:   51003,
		Level:          1,
		GrantSource:    ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001,
		Reason:         "manual vip grant",
	})
	if err != nil {
		t.Fatalf("GrantVip retry failed: %v", err)
	}
	if again.TransactionID != adminGrant.TransactionID {
		t.Fatalf("vip grant idempotency mismatch: first=%+v again=%+v", adminGrant, again)
	}

	activityGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
		CommandID:    "cmd-activity-vip3",
		TargetUserID: 51003,
		Level:        3,
		GrantSource:  ledger.VipGrantSourceActivity,
		Reason:       "activity vip reward",
	})
	if err != nil {
		t.Fatalf("GrantVip activity failed: %v", err)
	}
	if activityGrant.Vip.Level != 3 {
		t.Fatalf("activity vip grant should upgrade to level 3: %+v", activityGrant)
	}
	if got := repository.CountRows("user_vip_history", "user_id = ? AND action = ?", int64(51003), ledger.VipGrantSourceActivity); got != 1 {
		t.Fatalf("activity vip grant should write history action, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "user_id = ? AND event_type = ?", int64(51003), "VipActivated"); got != 2 {
		t.Fatalf("both vip grants should enqueue activation IM events, got %d", got)
	}

	_, err = svc.GrantVip(context.Background(), ledger.GrantVipCommand{
		CommandID:      "cmd-manager-vip2",
		TargetUserID:   51004,
		Level:          2,
		GrantSource:    ledger.VipGrantSourceManagerCenter,
		OperatorUserID: 9002,
		Reason:         "manager center vip grant",
	})
	if !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("manager center direct VIP grant must be rejected in favor of trial cards: %v", err)
	}
}

// TestVipLevelSixIgnoresLegacyRechargeThreshold 验证 VIP6 即使存在旧累充字段，也能按金币价格直接购买。
func TestVipLevelSixIgnoresLegacyRechargeThreshold(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(51002, 50000)
	svc := walletservice.New(repository)

	_, currentLevels, err := svc.ListAdminVipLevels(context.Background())
	if err != nil {
		t.Fatalf("ListAdminVipLevels failed: %v", err)
	}
	if len(currentLevels) != 10 {
		t.Fatalf("expected 10 vip levels, got %d", len(currentLevels))
	}
	rewardGroupID := currentLevels[0].RewardResourceGroupID
	commands := make([]ledger.AdminVipLevelCommand, 0, len(currentLevels))
	for _, level := range currentLevels {
		command := ledger.AdminVipLevelCommand{
			Level:                      level.Level,
			Name:                       level.Name,
			Status:                     level.Status,
			PriceCoin:                  level.PriceCoin,
			DurationMS:                 level.DurationMS,
			RewardResourceGroupID:      level.RewardResourceGroupID,
			RequiredRechargeCoinAmount: level.RequiredRechargeCoinAmount,
			SortOrder:                  level.SortOrder,
		}
		if command.Level == 6 {
			command.Status = ledger.VipStatusActive
			command.PriceCoin = 12000
			command.RewardResourceGroupID = rewardGroupID
			command.RequiredRechargeCoinAmount = 5000
		}
		commands = append(commands, command)
	}
	if _, _, err := svc.UpdateAdminVipLevels(context.Background(), commands, 9001); err != nil {
		t.Fatalf("UpdateAdminVipLevels failed: %v", err)
	}
	repository.SetVIPLevelRechargeThreshold(6, 5000)

	_, packages, err := svc.ListVipPackages(context.Background(), 51002)
	if err != nil {
		t.Fatalf("ListVipPackages failed: %v", err)
	}
	level6 := vipLevelByNumber(packages, 6)
	if level6 == nil || !level6.CanPurchase || level6.PurchaseLockedReason != "" || level6.RechargeGateRequired || level6.RequiredRechargeCoinAmount != 0 || level6.UserRechargeCoinAmount != 0 {
		t.Fatalf("vip level 6 should ignore legacy recharge gate: %+v", level6)
	}
	receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip6",
		UserID:    51002,
		Level:     6,
	})
	if err != nil {
		t.Fatalf("PurchaseVip level 6 failed: %v", err)
	}
	if receipt.Vip.Level != 6 || receipt.CoinSpent != 12000 {
		t.Fatalf("vip level 6 receipt mismatch: %+v", receipt)
	}
}

func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel {
	for index := range levels {
		if levels[index].Level == target {
			return &levels[index]
		}
	}
	return nil
}

// TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后不再返回钻石余额或兑换入口，
// POINT 仍作为独立资产保留在概览中。
func TestWalletOverviewDisablesDiamondExchange(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(52001, 700)
	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 {
		t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
	}
	if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetPoint) != 0 {
		t.Fatalf("overview balances mismatch: %+v", overview.Balances)
	}
	for _, balance := range overview.Balances {
		if balance.AssetType == "DIAMOND" {
			t.Fatalf("retired DIAMOND asset must not be returned: %+v", overview.Balances)
		}
	}
	rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001)
	if err != nil {
		t.Fatalf("GetDiamondExchangeConfig failed: %v", err)
	}
	if len(rules) != 0 {
		t.Fatalf("diamond exchange rules must be empty after DIAMOND removal: %+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.ProductName || product.Channel != ledger.RechargeChannelGoogle {
		t.Fatalf("created product identifiers mismatch: %+v", product)
	}
	if !product.AppVisible {
		t.Fatalf("new recharge product should be visible in app by default: %+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, ledger.RechargeAudienceNormal)
	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, ledger.RechargeAudienceNormal)
	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)
	}

	updatedProduct, 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)
	}
	if updatedProduct.ProductCode != updatedProduct.ProductName {
		t.Fatalf("updated product_code should follow product_name: %+v", updatedProduct)
	}

	inactiveProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal)
	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)
	}

	hidden := false
	hiddenProduct, err := svc.UpdateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		ProductID:      product.ProductID,
		AmountMicro:    2500000,
		CoinAmount:     0,
		ProductName:    "2600 Coins",
		Description:    "hidden google shell",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1002},
		Enabled:        true,
		AppVisible:     &hidden,
		OperatorUserID: 90003,
	})
	if err != nil {
		t.Fatalf("UpdateRechargeProduct hidden failed: %v", err)
	}
	if !hiddenProduct.Enabled || hiddenProduct.AppVisible || hiddenProduct.CoinAmount != 0 {
		t.Fatalf("hidden product state mismatch: %+v", hiddenProduct)
	}
	hiddenProducts, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1002, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal)
	if err != nil {
		t.Fatalf("ListRechargeProducts hidden failed: %v", err)
	}
	if len(hiddenProducts) != 0 {
		t.Fatalf("hidden active product must not be exposed to app: %+v", hiddenProducts)
	}
	adminProducts, total, err = svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
		AppCode:  "lalu",
		Status:   ledger.RechargeProductStatusActive,
		RegionID: 1002,
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("ListAdminRechargeProducts hidden failed: %v", err)
	}
	if total != 1 || len(adminProducts) != 1 || adminProducts[0].AppVisible || adminProducts[0].CoinAmount != 0 {
		t.Fatalf("admin hidden 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)
	}
}

// TestListAdminRechargeProductsWithoutAudienceOrPlatformFilterIncludesCoinSellerWeb 锁定后台“全部用户类型/全部平台”的语义。
// App/H5 购买入口可以把空 audience_type 解释成普通用户，但后台配置列表的空值必须表示不筛选，
// 否则运营选择“全部”时只看到 normal/android，刚配置的 coin_seller/web 档位会被后端静默过滤。
func TestListAdminRechargeProductsWithoutAudienceOrPlatformFilterIncludesCoinSellerWeb(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)

	normalProduct, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    1000000,
		CoinAmount:     1000000,
		ProductName:    "Normal Android",
		Description:    "normal android tier",
		Platform:       ledger.RechargeProductPlatformAndroid,
		AudienceType:   ledger.RechargeAudienceNormal,
		RegionIDs:      []int64{1001},
		Enabled:        true,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct normal failed: %v", err)
	}
	coinSellerProduct, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    5000000,
		CoinAmount:     5000000,
		ProductName:    "Coin Seller Web",
		Description:    "coin seller web tier",
		Platform:       ledger.RechargeProductPlatformWeb,
		AudienceType:   ledger.RechargeAudienceCoinSeller,
		RegionIDs:      []int64{1001},
		Enabled:        true,
		OperatorUserID: 90002,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct coin seller failed: %v", err)
	}

	products, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
		AppCode:  "lalu",
		Page:     1,
		PageSize: 10,
	})
	if err != nil {
		t.Fatalf("ListAdminRechargeProducts all filters failed: %v", err)
	}
	if total != 2 || len(products) != 2 {
		t.Fatalf("admin all filters must include normal and coin seller products: total=%d products=%+v", total, products)
	}
	assertRechargeProductListed(t, products, normalProduct.ProductID, ledger.RechargeAudienceNormal, ledger.RechargeProductPlatformAndroid)
	assertRechargeProductListed(t, products, coinSellerProduct.ProductID, ledger.RechargeAudienceCoinSeller, ledger.RechargeProductPlatformWeb)

	coinSellerOnly, total, err := svc.ListAdminRechargeProducts(context.Background(), ledger.ListRechargeProductsQuery{
		AppCode:      "lalu",
		AudienceType: ledger.RechargeAudienceCoinSeller,
		Page:         1,
		PageSize:     10,
	})
	if err != nil {
		t.Fatalf("ListAdminRechargeProducts coin seller failed: %v", err)
	}
	if total != 1 || len(coinSellerOnly) != 1 || coinSellerOnly[0].ProductID != coinSellerProduct.ProductID {
		t.Fatalf("coin seller filter must only include coin seller product: total=%d products=%+v", total, coinSellerOnly)
	}
}

func assertRechargeProductListed(t *testing.T, products []ledger.RechargeProduct, productID int64, audienceType string, platform string) {
	t.Helper()
	for _, product := range products {
		if product.ProductID != productID {
			continue
		}
		if product.AudienceType != audienceType || product.Platform != platform {
			t.Fatalf("product %d metadata mismatch: want audience=%s platform=%s got=%+v", productID, audienceType, platform, product)
		}
		return
	}
	t.Fatalf("product %d not found in admin list: %+v", productID, products)
}

// TestConfirmGooglePaymentUsesProductNameAsGoogleProductID 锁定后台 product_name 作为 Google Play 商品 ID。
func TestConfirmGooglePaymentUsesProductNameAsGoogleProductID(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	const googleProductID = "coins_1500_google"
	product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    1500000,
		CoinAmount:     1500,
		ProductName:    googleProductID,
		Description:    "google coin pack",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1001},
		Enabled:        true,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct failed: %v", err)
	}
	if product.ProductCode != googleProductID {
		t.Fatalf("android product_code should match Google product ID: %+v", product)
	}
	svc.SetGooglePlayClient(&fakeGooglePlayClient{
		purchase: ledger.GooglePlayPurchase{
			ProductID:            googleProductID,
			OrderID:              "GPA.1",
			PurchaseState:        ledger.GooglePurchaseStatePurchased,
			ConsumptionState:     "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
			AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
		},
	})

	receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
		AppCode:       "lalu",
		CommandID:     "google-pay-product-name",
		UserID:        53001,
		RegionID:      1001,
		CountryID:     199,
		ProductID:     product.ProductID,
		ProductCode:   googleProductID,
		PackageName:   "com.org.laluparty",
		PurchaseToken: "purchase-token-product-name",
		OrderID:       "GPA.1",
	})
	if err != nil {
		t.Fatalf("ConfirmGooglePayment failed: %v", err)
	}
	if receipt.Status != ledger.PaymentStatusCredited || receipt.CoinAmount != 1500 || receipt.ProductCode != product.ProductCode {
		t.Fatalf("google payment receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ?", product.ProductID, product.ProductCode); got != 1 {
		t.Fatalf("google payment should write one payment order with internal product_code, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
		t.Fatalf("google payment should write one wallet entry, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ? AND exchange_usd_minor_amount = ?", receipt.TransactionID, int64(1500), int64(150), int64(150)); got != 1 {
		t.Fatalf("google payment should write recharge USD minor from micro amount, got %d", got)
	}
	if got := repository.CountRows("wallet_user_recharge_stats", "user_id = ? AND total_coin_amount = ? AND total_usd_minor_amount = ?", int64(53001), int64(1500), int64(150)); got != 1 {
		t.Fatalf("google payment should aggregate recharge USD minor from micro amount, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload, '$.target_country_id') = ? AND JSON_EXTRACT(payload, '$.country_id') = ? AND JSON_EXTRACT(payload, '$.target_region_id') = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.google_product_id')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.product_name')) = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.product_code')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(199), int64(199), int64(1001), ledger.RechargeChannelGoogle, googleProductID, googleProductID, googleProductID); got != 1 {
		t.Fatalf("google recharge fact should carry target country/region and product identity for BI/activity, got %d", got)
	}
}

func TestConfirmGooglePaymentAcceptsHiddenZeroCoinProduct(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	const googleProductID = "coins_hidden_first_recharge"
	hidden := false
	product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    3990000,
		CoinAmount:     0,
		ProductName:    googleProductID,
		Description:    "hidden first recharge google shell",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1001},
		Enabled:        true,
		AppVisible:     &hidden,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct hidden failed: %v", err)
	}
	products, _, err := svc.ListRechargeProducts(context.Background(), 53001, 1001, ledger.RechargeProductPlatformAndroid, ledger.RechargeAudienceNormal)
	if err != nil {
		t.Fatalf("ListRechargeProducts hidden failed: %v", err)
	}
	if len(products) != 0 {
		t.Fatalf("hidden google shell must not be listed in app products: %+v", products)
	}
	svc.SetGooglePlayClient(&fakeGooglePlayClient{
		purchase: ledger.GooglePlayPurchase{
			ProductID:            googleProductID,
			OrderID:              "GPA.hidden",
			PurchaseState:        ledger.GooglePurchaseStatePurchased,
			ConsumptionState:     "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
			AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
		},
	})

	receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
		AppCode:       "lalu",
		CommandID:     "google-pay-hidden-zero",
		UserID:        53001,
		RegionID:      1001,
		CountryID:     199,
		ProductCode:   googleProductID,
		PackageName:   "com.org.laluparty",
		PurchaseToken: "purchase-token-hidden-zero",
		OrderID:       "GPA.hidden",
	})
	if err != nil {
		t.Fatalf("ConfirmGooglePayment hidden failed: %v", err)
	}
	if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductID != product.ProductID || receipt.CoinAmount != 0 || receipt.Balance.AvailableAmount != 0 {
		t.Fatalf("hidden google receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("payment_orders", "provider_order_id = ? AND product_code = ? AND coin_amount = ?", "GPA.hidden", googleProductID, int64(0)); got != 1 {
		t.Fatalf("hidden google payment should write one zero-coin payment order, got %d", got)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ? AND available_delta = ?", receipt.TransactionID, int64(0)); got != 1 {
		t.Fatalf("hidden google payment should write one zero-delta wallet entry, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ?", receipt.TransactionID, int64(0), int64(399)); got != 1 {
		t.Fatalf("hidden google payment should keep paid USD recharge fact, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND available_delta = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload, '$.google_product_id')) = ?", receipt.TransactionID, "WalletRechargeRecorded", int64(0), googleProductID); got != 1 {
		t.Fatalf("hidden google payment should publish zero-coin recharge fact for activity, got %d", got)
	}
}

func TestConfirmGooglePaymentAcceptsLegacyProductCodeInput(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	const googleProductID = "coins_legacy_first_recharge"
	const legacyProductCode = "iap_android_legacy"
	product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    1990000,
		CoinAmount:     160000,
		ProductName:    googleProductID,
		Description:    "legacy first recharge pack",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1001},
		Enabled:        true,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct failed: %v", err)
	}
	repository.SetRechargeProductCode(product.ProductID, legacyProductCode)
	svc.SetGooglePlayClient(&fakeGooglePlayClient{
		purchase: ledger.GooglePlayPurchase{
			ProductID:            googleProductID,
			OrderID:              "GPA.legacy",
			PurchaseState:        ledger.GooglePurchaseStatePurchased,
			ConsumptionState:     "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
			AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
		},
	})

	receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
		AppCode:       "lalu",
		CommandID:     "google-pay-legacy-product-code",
		UserID:        53002,
		RegionID:      1001,
		ProductID:     product.ProductID,
		ProductCode:   legacyProductCode,
		PackageName:   "com.org.laluparty",
		PurchaseToken: "purchase-token-legacy-product-code",
		OrderID:       "GPA.legacy",
	})
	if err != nil {
		t.Fatalf("ConfirmGooglePayment should accept legacy product_code input: %v", err)
	}
	if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductCode != legacyProductCode || receipt.CoinAmount != 160000 {
		t.Fatalf("legacy google payment receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ? AND product_name = ?", product.ProductID, legacyProductCode, googleProductID); got != 1 {
		t.Fatalf("google payment should keep legacy product_code snapshot while validating product_name, got %d", got)
	}
}

func TestConfirmGooglePaymentFindsGoogleProductWithoutProductID(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	const googleProductID = "first_recharge_google_999"
	product, err := svc.CreateRechargeProduct(context.Background(), ledger.RechargeProductCommand{
		AppCode:        "lalu",
		AmountMicro:    9990000,
		CoinAmount:     90000,
		ProductName:    googleProductID,
		Description:    "first recharge pack",
		Platform:       ledger.RechargeProductPlatformAndroid,
		RegionIDs:      []int64{1001},
		Enabled:        true,
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("CreateRechargeProduct failed: %v", err)
	}
	svc.SetGooglePlayClient(&fakeGooglePlayClient{
		purchase: ledger.GooglePlayPurchase{
			ProductID:            googleProductID,
			OrderID:              "GPA.first",
			PurchaseState:        ledger.GooglePurchaseStatePurchased,
			ConsumptionState:     "CONSUMPTION_STATE_YET_TO_BE_CONSUMED",
			AcknowledgementState: "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
		},
	})

	receipt, err := svc.ConfirmGooglePayment(context.Background(), ledger.GooglePaymentCommand{
		AppCode:       "lalu",
		CommandID:     "google-pay-first-recharge",
		UserID:        53003,
		RegionID:      1001,
		ProductCode:   googleProductID,
		PackageName:   "com.org.laluparty",
		PurchaseToken: "purchase-token-first-recharge",
		OrderID:       "GPA.first",
	})
	if err != nil {
		t.Fatalf("ConfirmGooglePayment should find google product without product_id: %v", err)
	}
	if receipt.Status != ledger.PaymentStatusCredited || receipt.ProductID != product.ProductID || receipt.ProductCode != googleProductID || receipt.CoinAmount != 90000 {
		t.Fatalf("first recharge google payment receipt mismatch: %+v", receipt)
	}
	if got := repository.CountRows("payment_orders", "product_id = ? AND product_code = ? AND product_name = ?", product.ProductID, googleProductID, googleProductID); got != 1 {
		t.Fatalf("google payment should write internal product snapshot, got %d", got)
	}
	if got := repository.CountRows("wallet_recharge_records", "transaction_id = ? AND coin_amount = ? AND usd_minor_amount = ?", receipt.TransactionID, int64(90000), int64(999)); got != 1 {
		t.Fatalf("google payment should write recharge record from matched product, got %d", got)
	}
}

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 entitlementIDFromGrant(t *testing.T, grant resourcedomain.ResourceGrant) string {
	t.Helper()
	for _, item := range grant.Items {
		if item.ResultType == resourcedomain.ResultEntitlement && item.EntitlementID != "" {
			return item.EntitlementID
		}
	}
	t.Fatalf("grant has no entitlement item: %+v", grant)
	return ""
}

func assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) {
	assertBalanceForApp(t, svc, "lalu", userID, assetType, want)
}

func assertBalanceForApp(t *testing.T, svc *walletservice.Service, appCode string, userID int64, assetType string, want int64) {
	t.Helper()

	balances, err := svc.GetBalances(appcode.WithContext(context.Background(), appCode), userID, []string{assetType})
	if err != nil {
		t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err)
	}
	if got := balanceAmount(balances, assetType); got != want {
		t.Fatalf("balance user=%d asset=%s mismatch: got %d want %d", userID, assetType, got, want)
	}
}

func requireWalletBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, wantAvailable int64, wantFrozen int64) {
	requireWalletBalanceForApp(t, svc, "lalu", userID, assetType, wantAvailable, wantFrozen)
}

func requireWalletBalanceForApp(t *testing.T, svc *walletservice.Service, appCode string, userID int64, assetType string, wantAvailable int64, wantFrozen int64) {
	t.Helper()

	balances, err := svc.GetBalances(appcode.WithContext(context.Background(), appCode), userID, []string{assetType})
	if err != nil {
		t.Fatalf("GetBalances user=%d asset=%s failed: %v", userID, assetType, err)
	}
	for _, balance := range balances {
		if balance.AssetType == assetType {
			if balance.AvailableAmount != wantAvailable || balance.FrozenAmount != wantFrozen {
				t.Fatalf("balance user=%d asset=%s mismatch: got available=%d frozen=%d want available=%d frozen=%d", userID, assetType, balance.AvailableAmount, balance.FrozenAmount, wantAvailable, wantFrozen)
			}
			return
		}
	}
	t.Fatalf("balance user=%d asset=%s missing in %+v", userID, assetType, balances)
}

type fakePointGateRepository struct {
	ports.Repository

	creditTaskRewardCalls          int
	lastTaskReward                 ledger.TaskRewardCommand
	lastTaskRewardAppFromContext   string
	freezeSalaryCalls              int
	lastFreezeSalary               ledger.SalaryWithdrawalCommand
	lastFreezeSalaryAppFromContext string
	pointWalletPolicy              ledger.PointWithdrawalRuntimeConfig
}

func (f *fakePointGateRepository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
	f.creditTaskRewardCalls++
	f.lastTaskReward = command
	f.lastTaskRewardAppFromContext = appcode.FromContext(ctx)
	return ledger.TaskRewardReceipt{
		TransactionID: "fake-task-reward-tx",
		Amount:        command.Amount,
		GrantedAtMS:   1700000000000,
		Balance: ledger.AssetBalance{
			AppCode:         appcode.FromContext(ctx),
			UserID:          command.TargetUserID,
			AssetType:       command.AssetType,
			AvailableAmount: command.Amount,
		},
	}, nil
}

func (f *fakePointGateRepository) FreezeSalaryWithdrawal(ctx context.Context, command ledger.SalaryWithdrawalCommand) (ledger.SalaryWithdrawalReceipt, error) {
	f.freezeSalaryCalls++
	if !f.pointWalletPolicy.Found {
		return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.PermissionDenied, "point wallet policy is not configured")
	}
	if command.SalaryUSDMinor < f.pointWalletPolicy.MinimumPoints {
		return ledger.SalaryWithdrawalReceipt{}, xerr.New(xerr.InvalidArgument, "point withdrawal amount is below configured minimum")
	}
	command.PointsPerUSD = f.pointWalletPolicy.PointsPerUSD
	command.PointWithdrawFeeBPS = f.pointWalletPolicy.FeeBPS
	command.PointFeeAmount = command.SalaryUSDMinor * f.pointWalletPolicy.FeeBPS / 10_000
	command.PointNetAmount = command.SalaryUSDMinor - command.PointFeeAmount
	f.lastFreezeSalary = command
	f.lastFreezeSalaryAppFromContext = appcode.FromContext(ctx)
	return ledger.SalaryWithdrawalReceipt{
		TransactionID:           "fake-point-freeze-tx",
		UserID:                  command.UserID,
		SalaryAssetType:         command.SalaryAssetType,
		SalaryUSDMinor:          command.SalaryUSDMinor,
		PointFeeAmount:          command.PointFeeAmount,
		PointNetAmount:          command.PointNetAmount,
		PointsPerUSD:            command.PointsPerUSD,
		PointWithdrawFeeBPS:     command.PointWithdrawFeeBPS,
		PointPolicyInstanceCode: f.pointWalletPolicy.PolicyInstanceCode,
		AvailableAfter:          0,
		FrozenAfter:             command.SalaryUSDMinor,
		Version:                 1,
		CreatedAtMS:             1700000000000,
	}, nil
}

func paymentMethodsContain(methods []ledger.ThirdPartyPaymentMethod, countryCode string, payWay string, payType string) bool {
	for _, method := range methods {
		if method.CountryCode == countryCode && method.PayWay == payWay && method.PayType == payType {
			return true
		}
	}
	return false
}

func thirdPartyPaymentMethodByKey(methods []ledger.ThirdPartyPaymentMethod, countryCode string, payWay string, payType string) (ledger.ThirdPartyPaymentMethod, bool) {
	for _, method := range methods {
		if method.CountryCode == countryCode && method.PayWay == payWay && method.PayType == payType {
			return method, true
		}
	}
	return ledger.ThirdPartyPaymentMethod{}, false
}

func v5PayProductRequestSeen(requests []walletservice.V5PayProductTypesRequest, countryCode string, currencyCode string) bool {
	for _, request := range requests {
		if strings.EqualFold(request.CountryCode, countryCode) && strings.EqualFold(request.CurrencyCode, currencyCode) {
			return true
		}
	}
	return false
}

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

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

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

func int64sEqual(left []int64, right []int64) bool {
	if len(left) != len(right) {
		return false
	}
	for index := range left {
		if left[index] != right[index] {
			return false
		}
	}
	return true
}

func giftTypeCodesContain(items []resourcedomain.GiftTypeConfig, typeCode string) bool {
	for _, item := range items {
		if item.TypeCode == typeCode {
			return true
		}
	}
	return false
}

type fakeActivityBadgeClient struct {
	status   string
	err      error
	requests []*activityv1.ConsumeAchievementEventRequest
}

func (f *fakeActivityBadgeClient) ConsumeAchievementEvent(_ context.Context, req *activityv1.ConsumeAchievementEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error) {
	f.requests = append(f.requests, req)
	if f.err != nil {
		return nil, f.err
	}
	status := f.status
	if status == "" {
		status = "consumed"
	}
	return &activityv1.ConsumeAchievementEventResponse{EventId: req.GetEventId(), Status: status}, nil
}

type fakeGooglePlayClient struct {
	purchase ledger.GooglePlayPurchase
	err      error
	consumed []string
}

func (f *fakeGooglePlayClient) GetProductPurchase(_ context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) {
	if f.err != nil {
		return ledger.GooglePlayPurchase{}, f.err
	}
	purchase := f.purchase
	if purchase.PackageName == "" {
		purchase.PackageName = packageName
	}
	return purchase, nil
}

func (f *fakeGooglePlayClient) ConsumeProduct(_ context.Context, _ string, productID string, _ string) error {
	f.consumed = append(f.consumed, productID)
	return nil
}

func (f *fakeGooglePlayClient) GetOrder(_ context.Context, _ string, _ string) (ledger.GooglePlayOrder, error) {
	// 实付明细刷新是入账后的异步补数动作；fake 直接失败，让确认链路测试不产生额外副作用。
	return ledger.GooglePlayOrder{}, xerr.New(xerr.Unavailable, "orders api is not faked")
}

type fakeMifaPayClient struct {
	createReq  walletservice.MifaPayCreateOrderRequest
	createResp walletservice.MifaPayCreateOrderResponse
	queryReq   walletservice.MifaPayQueryOrderRequest
	queryReqs  []walletservice.MifaPayQueryOrderRequest
	queryResp  walletservice.MifaPayQueryOrderResponse
	querySeq   []fakeMifaPayQueryResult
	queryErr   error
	notifyData walletservice.MifaPayNotifyData
	err        error
}

type fakeMifaPayQueryResult struct {
	resp walletservice.MifaPayQueryOrderResponse
	err  error
}

type fakeMifaPayProviderPayloadError struct {
	cause   error
	payload string
}

func (e fakeMifaPayProviderPayloadError) Error() string {
	return e.cause.Error()
}

func (e fakeMifaPayProviderPayloadError) Unwrap() error {
	return e.cause
}

func (e fakeMifaPayProviderPayloadError) ProviderPayloadJSON() string {
	return e.payload
}

func (f *fakeMifaPayClient) CreateOrder(_ context.Context, req walletservice.MifaPayCreateOrderRequest) (walletservice.MifaPayCreateOrderResponse, error) {
	f.createReq = req
	if f.err != nil {
		return walletservice.MifaPayCreateOrderResponse{}, f.err
	}
	if f.createResp.PayURL == "" {
		return walletservice.MifaPayCreateOrderResponse{OrderID: req.OrderID, ProviderOrderID: "mb_" + req.OrderID, PayURL: "https://pay.example/" + req.OrderID, RawJSON: `{"ok":true}`}, nil
	}
	return f.createResp, nil
}

func (f *fakeMifaPayClient) QueryOrder(_ context.Context, req walletservice.MifaPayQueryOrderRequest) (walletservice.MifaPayQueryOrderResponse, error) {
	f.queryReq = req
	f.queryReqs = append(f.queryReqs, req)
	if index := len(f.queryReqs) - 1; index < len(f.querySeq) {
		result := f.querySeq[index]
		return result.resp, result.err
	}
	if f.queryErr != nil {
		return walletservice.MifaPayQueryOrderResponse{}, f.queryErr
	}
	return f.queryResp, nil
}

func (f *fakeMifaPayClient) ParseNotification(ledger.MifaPayNotification) (walletservice.MifaPayNotifyData, error) {
	if f.err != nil {
		return walletservice.MifaPayNotifyData{}, f.err
	}
	return f.notifyData, nil
}

type fakeV5PayClient struct {
	createReq    walletservice.V5PayCreateOrderRequest
	createResp   walletservice.V5PayCreateOrderResponse
	queryReq     walletservice.V5PayQueryOrderRequest
	queryReqs    []walletservice.V5PayQueryOrderRequest
	queryResp    walletservice.V5PayQueryOrderResponse
	querySeq     []fakeV5PayQueryResult
	notifyData   walletservice.V5PayNotifyData
	productReqs  []walletservice.V5PayProductTypesRequest
	productTypes map[string]walletservice.V5PayProductTypesResponse
	productErrs  map[string]error
	err          error
}

type fakeV5PayQueryResult struct {
	resp walletservice.V5PayQueryOrderResponse
	err  error
}

type fakeProviderPayloadError struct {
	message string
	rawJSON string
}

func (e fakeProviderPayloadError) Error() string {
	return e.message
}

func (e fakeProviderPayloadError) ProviderPayloadJSON() string {
	return e.rawJSON
}

func (f *fakeV5PayClient) CreateOrder(_ context.Context, req walletservice.V5PayCreateOrderRequest) (walletservice.V5PayCreateOrderResponse, error) {
	f.createReq = req
	if f.err != nil {
		return walletservice.V5PayCreateOrderResponse{}, f.err
	}
	if f.createResp.PayURL == "" {
		return walletservice.V5PayCreateOrderResponse{OrderID: req.OrderID, ProviderOrderID: "v5_" + req.OrderID, PayURL: "https://v5pay.example/" + req.OrderID, RawJSON: `{"ok":true}`}, nil
	}
	return f.createResp, nil
}

func (f *fakeV5PayClient) QueryOrder(_ context.Context, req walletservice.V5PayQueryOrderRequest) (walletservice.V5PayQueryOrderResponse, error) {
	f.queryReq = req
	f.queryReqs = append(f.queryReqs, req)
	if index := len(f.queryReqs) - 1; index < len(f.querySeq) {
		result := f.querySeq[index]
		return result.resp, result.err
	}
	if f.err != nil {
		return walletservice.V5PayQueryOrderResponse{}, f.err
	}
	return f.queryResp, nil
}

func (f *fakeV5PayClient) ListProductTypes(_ context.Context, req walletservice.V5PayProductTypesRequest) (walletservice.V5PayProductTypesResponse, error) {
	f.productReqs = append(f.productReqs, req)
	key := strings.ToUpper(strings.TrimSpace(req.CountryCode)) + "|" + strings.ToUpper(strings.TrimSpace(req.CurrencyCode))
	if err := f.productErrs[key]; err != nil {
		return walletservice.V5PayProductTypesResponse{}, err
	}
	return f.productTypes[key], nil
}

func (f *fakeV5PayClient) ParseNotification(ledger.V5PayNotification) (walletservice.V5PayNotifyData, error) {
	if f.err != nil {
		return walletservice.V5PayNotifyData{}, f.err
	}
	return f.notifyData, nil
}

type fakeTronUSDTClient struct {
	lastTxHash      string
	lastToAddress   string
	lastAmountMinor int64
	rawJSON         string
	err             error
}

func (f *fakeTronUSDTClient) VerifyUSDTTransfer(_ context.Context, txHash string, toAddress string, amountMinor int64) (string, error) {
	f.lastTxHash = txHash
	f.lastToAddress = toAddress
	f.lastAmountMinor = amountMinor
	if f.err != nil {
		return f.rawJSON, f.err
	}
	if f.rawJSON != "" {
		return f.rawJSON, nil
	}
	return `{"confirmed":true}`, nil
}
