package wallet_test

import (
	"context"
	"encoding/json"
	"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"
	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,
	}
}

// TestDebitGiftUsesServerPriceWithoutGiftPointCredit 验证送礼只信服务端价格，且礼物积分下线后不再给收礼人入账。
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)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
		t.Fatalf("gift debit should only write sender COIN entry, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "transaction_id = ?", receipt.TransactionID); got != 2 {
		t.Fatalf("gift debit should write sender balance and gift outbox events, 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)
	}
}

// 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(), 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 != 2 {
		t.Fatalf("batch gift should only write sender COIN 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)
	}
}

// TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost 验证只有 gateway 确认的 active 主播会增加工资周期钻石。
func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	repository.SetGiftPrice("rose", "v9", 7, 3, 11)
	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 TestDebitGiftCreditsHostPeriodDiamondsByGlobalDefaultAndGiftType(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	svc := walletservice.New(repository)
	repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00")
	repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeLucky, "40.00")
	repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeSuperLucky, "50.00")

	cases := []struct {
		giftID      string
		giftType    string
		commandID   string
		senderID    int64
		coinPrice   int64
		wantDiamond int64
	}{
		{giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, coinPrice: 100, wantDiamond: 100},
		{giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, coinPrice: 100, wantDiamond: 10},
		{giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, coinPrice: 99, wantDiamond: 0},
	}

	for _, tc := range cases {
		repository.SetBalance(tc.senderID, 100)
		repository.SetGiftPrice(tc.giftID, "v1", tc.coinPrice, 100, tc.coinPrice)
		repository.SetGiftType(tc.giftID, tc.giftType)
		receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
			CommandID:          tc.commandID,
			RoomID:             "room-ratio",
			SenderUserID:       tc.senderID,
			SenderRegionID:     1001,
			TargetUserID:       12001,
			GiftID:             tc.giftID,
			GiftCount:          1,
			PriceVersion:       "v1",
			TargetIsHost:       true,
			TargetHostRegionID: 8801,
		})
		if err != nil {
			t.Fatalf("DebitGift %s failed: %v", tc.giftType, err)
		}
		if receipt.HostPeriodDiamondAdded != tc.wantDiamond {
			t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond)
		}
	}
	if got := repository.HostPeriodGiftDiamondTotal(12001); got != 110 {
		t.Fatalf("host period account total 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
		coinPrice int64
		heatUnit  int64
		wantHeat  int64
	}{
		{giftID: "normal-room-ratio-gift", giftType: resourcedomain.GiftTypeNormal, command: "cmd-normal-room-ratio", senderID: 13001, coinPrice: 100, heatUnit: 999, wantHeat: 100},
		{giftID: "lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeLucky, command: "cmd-lucky-room-ratio", senderID: 13002, coinPrice: 100, heatUnit: 999, wantHeat: 10},
		{giftID: "super-lucky-room-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, command: "cmd-super-lucky-room-ratio", senderID: 13003, coinPrice: 200, heatUnit: 500, wantHeat: 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:   14001,
			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)
		}
	}
}

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

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

	policy, found, err := svc.GetActiveHostSalaryPolicy(context.Background(), "lalu", 8801, "", "", nowMs)
	if err != nil {
		t.Fatalf("GetActiveHostSalaryPolicy failed: %v", err)
	}
	if !found {
		t.Fatal("expected active host salary policy")
	}
	if policy.PolicyID != 901 || 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, 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", 200, 10, 10)
	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,
		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)
	assertBalance(t, svc, 10002, ledger.AssetCoin, 90)
	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)
	assertBalance(t, svc, 10002, ledger.AssetCoin, 198)
	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)
}

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

// TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle 走通“已发布政策 -> 主播收礼 -> 多次增量结算 -> 月底清算 -> 下周期重新起算”的真实账务链路。
func TestHostSalaryFullLifecycleFromPublishedPolicyToNextCycle(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 2000)
	repository.SetGiftPrice("rose", "salary-full-lifecycle", 120, 10, 10)
	// 管理后台发布后 wallet-service 消费的是 host_agency_salary_policies 运行时快照；这里直接 seed 该表来锁定送礼和结算链路。
	repository.SetHostSalaryPolicy(ledger.HostSalaryPolicy{
		PolicyID:                 301,
		Name:                     "published daily salary",
		RegionID:                 8801,
		Status:                   "active",
		SettlementMode:           ledger.HostSalarySettlementModeDaily,
		ResidualDiamondToUSDRate: "0.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},
		},
	})
	svc := walletservice.New(repository)
	ctx := context.Background()
	previousCycle := time.Now().UTC().AddDate(0, -1, 0).Format("2006-01")

	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)
	assertBalance(t, svc, 10002, ledger.AssetCoin, 90)
	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)
	assertBalance(t, svc, 10002, ledger.AssetCoin, 198)
	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)
	assertBalance(t, svc, 10002, ledger.AssetCoin, 288)
	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,
		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)
	assertBalance(t, svc, 10003, ledger.AssetCoin, 90)
	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，不再回加 GIFT_POINT。
func TestDebitGiftAllowsSelfGift(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-self-gift",
		RoomID:       "room-1",
		SenderUserID: 10001,
		TargetUserID: 10001,
		GiftID:       "rose",
		GiftCount:    2,
		PriceVersion: "v9",
	})
	if err != nil {
		t.Fatalf("self DebitGift failed: %v", err)
	}

	if receipt.CoinSpent != 14 || receipt.GiftPointAdded != 0 || receipt.BalanceAfter != 86 {
		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) != 86 || balanceAmount(balances, ledger.AssetGiftPoint) != 0 {
		t.Fatalf("self gift balances mismatch: %+v", balances)
	}
	if got := repository.CountRows("wallet_entries", "transaction_id = ?", receipt.TransactionID); got != 1 {
		t.Fatalf("self gift should only write debit entry, 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.TotalGiftPoint != 65 || 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)
	}
	second, err := svc.DebitGift(context.Background(), command)
	if err != nil {
		t.Fatalf("second debit failed: %v", err)
	}

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

// TestDebitGiftRejectsCommandPayloadConflict 锁定同 command_id 不同业务 payload 的冲突语义。
func TestDebitGiftRejectsCommandPayloadConflict(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	repository.SetBalance(10001, 100)
	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)
	}
}

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

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

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

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

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

// 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)
	}
	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.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)
	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 = ?", 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)
	}
}

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

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

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

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

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

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

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

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

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

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

// 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_frame_gold",
		ResourceType:   resourcedomain.TypeAvatarFrame,
		Name:           "Gold Frame",
		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.ShopDurationThreeDays,
			SortOrder:    1,
		}},
		OperatorUserID: 90001,
	})
	if err != nil {
		t.Fatalf("upsert shop item failed: %v", err)
	}

	receipt, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
		CommandID:  "cmd-shop-frame",
		UserID:     53001,
		ShopItemID: shopItems[0].ShopItemID,
	})
	if err != nil {
		t.Fatalf("PurchaseResourceShopItem failed: %v", err)
	}
	if receipt.OrderID == "" || receipt.TransactionID == "" || receipt.GrantID == "" || receipt.CoinSpent != 300 || receipt.Balance.AvailableAmount != 700 {
		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)
	}

	again, err := svc.PurchaseResourceShopItem(ctx, resourcedomain.ResourceShopPurchaseCommand{
		CommandID:  "cmd-shop-frame",
		UserID:     53001,
		ShopItemID: shopItems[0].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 {
		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-frame", "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(-300)); 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)
	}
}

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

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

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)
	}
	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)
	}
	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) != 1 || !resources[0].Equipped || resources[0].EntitlementID != equipped.EntitlementID {
		t.Fatalf("resource list should expose equipped entitlement: resources=%+v equipped=%+v", resources, equipped)
	}
	repeated, err := svc.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		UserID:        43001,
		ResourceID:    badgeResource.ResourceID,
		EntitlementID: equipped.EntitlementID,
	})
	if err != nil {
		t.Fatalf("repeat equip badge resource failed: %v", err)
	}
	if repeated.EntitlementID != equipped.EntitlementID {
		t.Fatalf("repeat equip should keep entitlement: repeated=%+v equipped=%+v", repeated, equipped)
	}
	if got := repository.CountRows("user_resource_equipment", "user_id = ? AND resource_type = ?", int64(43001), resourcedomain.TypeBadge); got != 1 {
		t.Fatalf("equipment should keep one active badge, got %d", got)
	}
	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) != 1 || equippedResources[0].Resources[0].EntitlementID != equipped.EntitlementID {
		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 != 0 {
		t.Fatalf("equipment should be removed after 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)
	}
}

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

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

	managerGrant, 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 err != nil {
		t.Fatalf("GrantVip manager center failed: %v", err)
	}
	if gotDuration := managerGrant.Vip.ExpiresAtMS - managerGrant.Vip.StartedAtMS; gotDuration != int64(30*24*time.Hour/time.Millisecond) {
		t.Fatalf("manager center VIP grant duration mismatch: got %d receipt=%+v", gotDuration, managerGrant)
	}
}

// TestVipLevelSixRequiresRechargeThreshold 验证 6-10 级 VIP 必须先达到累计充值门槛。
func TestVipLevelSixRequiresRechargeThreshold(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)
	}

	_, 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 != "recharge_required" || level6.RequiredRechargeCoinAmount != 5000 {
		t.Fatalf("vip level 6 should be locked by recharge gate: %+v", level6)
	}
	_, err = svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
		CommandID: "cmd-vip6-locked",
		UserID:    51002,
		Level:     6,
	})
	if !xerr.IsCode(err, xerr.VIPRechargeRequired) {
		t.Fatalf("expected VIP_RECHARGE_REQUIRED, got %v", err)
	}

	repository.SetRechargeStats(51002, 5000)
	_, packages, err = svc.ListVipPackages(context.Background(), 51002)
	if err != nil {
		t.Fatalf("ListVipPackages after recharge failed: %v", err)
	}
	level6 = vipLevelByNumber(packages, 6)
	if level6 == nil || !level6.CanPurchase || level6.UserRechargeCoinAmount != 5000 {
		t.Fatalf("vip level 6 should be purchasable after recharge: %+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 钱包停用后钱包概览不再返回钻石余额或兑换入口。
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, "DIAMOND") != 0 {
		t.Fatalf("overview balances mismatch: %+v", overview.Balances)
	}
	rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001)
	if err != nil {
		t.Fatalf("GetDiamondExchangeConfig failed: %v", err)
	}
	if len(rules) != 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.Channel != ledger.RechargeChannelGoogle {
		t.Fatalf("created product identifiers mismatch: %+v", product)
	}
	if len(product.RegionIDs) != 2 || product.RegionIDs[0] != 1001 || product.RegionIDs[1] != 1002 {
		t.Fatalf("created product regions mismatch: %+v", product.RegionIDs)
	}

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

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

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

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

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

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

// 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("test requires internal product_code to differ from 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,
		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)
	}
}

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 assertBalance(t *testing.T, svc *walletservice.Service, userID int64, assetType string, want int64) {
	t.Helper()

	balances, err := svc.GetBalances(context.Background(), 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 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 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
}
