package wallet_test

import (
	"context"
	"strings"
	"testing"
	"time"

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

// TestFamiVIPReplaceAndTrialCardSwitch 使用真实 MySQL repository 验证 P1 最容易回归的三条状态边界：
// 高级购买丢弃剩余时间、体验卡不能降低当前等级且旧卡不改期、体验卡排除金币返现。
func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")
	_, levels, err := service.ListAdminVipLevels(ctx)
	if err != nil {
		t.Fatalf("list Fami VIP levels failed: %v", err)
	}
	if len(levels) != 9 {
		t.Fatalf("Fami must expose 9 configurable levels, got %d", len(levels))
	}
	durationMS := int64(30 * 24 * time.Hour / time.Millisecond)
	commands := make([]ledger.AdminVipLevelCommand, 0, len(levels))
	for _, level := range levels {
		for index := range level.Benefits {
			if level.Benefits[index].BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
				// 模拟后台误把体验返现打开；保存与运行时都必须把这项硬规则收敛为 false。
				level.Benefits[index].TrialEnabled = true
				level.Benefits[index].Status = ledger.VipStatusActive
				level.Benefits[index].MetadataJSON = `{"coin_amount":100}`
			}
		}
		commands = append(commands, ledger.AdminVipLevelCommand{
			Level:                 level.Level,
			Name:                  level.Name,
			Status:                ledger.VipStatusActive,
			PriceCoin:             100,
			DurationMS:            durationMS,
			RewardResourceGroupID: 0,
			SortOrder:             level.SortOrder,
			Benefits:              level.Benefits,
		})
	}
	if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
		t.Fatalf("enable Fami VIP levels failed: %v", err)
	}
	vipResources, _, err := service.ListResources(ctx, resourcedomain.ListResourcesQuery{
		AppCode: "fami", ResourceType: resourcedomain.TypeVIPTrialCard,
		ActiveOnly: true, ManagerGrantOnly: true, Page: 1, PageSize: 20,
	})
	if err != nil {
		t.Fatalf("list Fami VIP trial resources failed: %v", err)
	}
	var vip7ResourceID int64
	for _, resource := range vipResources {
		if resource.ResourceCode == "vip_trial_card_7" {
			vip7ResourceID = resource.ResourceID
			break
		}
	}
	if vip7ResourceID <= 0 {
		t.Fatal("Fami VIP7 trial resource is missing")
	}
	if _, err := service.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		AppCode: "fami", CommandID: "manager-center-generic-vip7-denied",
		TargetUserID: 880002, ResourceID: vip7ResourceID, Quantity: 1,
		DurationMS: int64(24 * time.Hour / time.Millisecond), Reason: "external trial",
		OperatorUserID: 9001, GrantSource: resourcedomain.GrantSourceManagerCenter,
	}); !xerr.IsCode(err, xerr.PermissionDenied) {
		t.Fatalf("generic manager-center VIP7 must be rejected by wallet owner, got %v", err)
	}

	const userID int64 = 880001
	repository.SetBalanceForApp("fami", userID, 10_000)
	first, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-3", UserID: userID, Level: 3})
	if err != nil {
		t.Fatalf("purchase Fami VIP3 failed: %v", err)
	}
	// command_id 与 user_id 只在 app_code 内幂等；Lalu 使用相同值必须形成独立订单和状态。
	laluCtx := appcode.WithContext(context.Background(), "lalu")
	repository.SetBalanceForApp("lalu", userID, 10_000)
	laluPurchase, err := service.PurchaseVip(laluCtx, ledger.PurchaseVipCommand{AppCode: "lalu", CommandID: "fami-vip-3", UserID: userID, Level: 3})
	if err != nil {
		t.Fatalf("same command in Lalu app scope failed: %v", err)
	}
	if laluPurchase.State.ProgramConfig.ProgramType != ledger.VipProgramTypeLegacyTimed || laluPurchase.OrderID == first.OrderID {
		t.Fatalf("VIP purchase must stay app-scoped: fami=%+v lalu=%+v", first, laluPurchase)
	}
	upgrade, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-4", UserID: userID, Level: 4})
	if err != nil {
		t.Fatalf("upgrade Fami VIP4 failed: %v", err)
	}
	if upgrade.Vip.ExpiresAtMS >= first.Vip.ExpiresAtMS+durationMS/2 {
		t.Fatalf("Fami upgrade incorrectly retained VIP3 remainder: first=%d upgrade=%d", first.Vip.ExpiresAtMS, upgrade.Vip.ExpiresAtMS)
	}
	if got := upgrade.Vip.ExpiresAtMS - upgrade.Vip.UpdatedAtMS; got < durationMS-2_000 || got > durationMS+2_000 {
		t.Fatalf("Fami upgrade must reset to one configured duration, got %d want %d", got, durationMS)
	}
	replayedFirst, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-3", UserID: userID, Level: 3})
	if err != nil {
		t.Fatalf("replay original VIP3 purchase failed: %v", err)
	}
	if replayedFirst.Vip.Level != 3 || replayedFirst.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS || replayedFirst.State.PaidVip.Level != 4 {
		t.Fatalf("idempotent receipt must keep original order snapshot and current state separately: first=%+v replay=%+v", first, replayedFirst)
	}
	paid, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "fami", CommandID: "fami-vip-6", UserID: userID, Level: 6})
	if err != nil {
		t.Fatalf("upgrade Fami VIP6 failed: %v", err)
	}
	if paid.State.PaidVip.Level != 6 {
		t.Fatalf("paid VIP state mismatch: %+v", paid.State)
	}

	card3, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
		AppCode: "fami", CommandID: "trial-vip-3", TargetUserID: userID, Level: 3,
		DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001, Reason: "test trial 3",
	})
	if err != nil {
		t.Fatalf("grant VIP3 trial card failed: %v", err)
	}
	card6, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
		AppCode: "fami", CommandID: "trial-vip-6", TargetUserID: userID, Level: 6,
		DurationMS: int64(20 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001, Reason: "test trial 6",
	})
	if err != nil {
		t.Fatalf("grant VIP6 trial card failed: %v", err)
	}
	if _, err := service.GrantResource(ctx, resourcedomain.GrantResourceCommand{
		AppCode: "fami", CommandID: "generic-trial-bypass", TargetUserID: userID,
		ResourceID: card6.TrialCard.ResourceID, Quantity: 1, DurationMS: durationMS,
		Reason: "must use dedicated vip trial grant", OperatorUserID: 9001, GrantSource: resourcedomain.GrantSourceAdmin,
	}); !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("generic resource grant must not bypass VIP trial metadata, got %v", err)
	}
	if card3.State.EffectiveSource != ledger.VipEffectiveSourcePaid || card6.State.EffectiveSource != ledger.VipEffectiveSourcePaid {
		t.Fatalf("granting a card must not auto-equip it: card3=%+v card6=%+v", card3.State, card6.State)
	}

	// repository 仍保留通用 equipment 能力供专用状态机内部复用，但必须拒绝外层直接调用，避免绕过 program 与 VIP outbox。
	if _, err := repository.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		AppCode: "fami", RequestID: "raw-equip-trial-3", UserID: userID,
		ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
	}); !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "VIP trial-card state machine") {
		t.Fatalf("raw repository equip must reject vip_trial_card bypass, got %v", err)
	}
	if _, err := repository.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
		AppCode: "fami", RequestID: "raw-unequip-trial", UserID: userID, ResourceType: resourcedomain.TypeVIPTrialCard,
	}); !xerr.IsCode(err, xerr.InvalidArgument) || !strings.Contains(err.Error(), "VIP trial-card state machine") {
		t.Fatalf("raw repository unequip must reject vip_trial_card bypass, got %v", err)
	}

	// 通用背包入口必须委托专用状态机，因此体验卡开关关闭时不能偷偷写入 equipment。
	disabledProgram := card3.State.ProgramConfig
	disabledProgram.GrantMode = ledger.VipGrantModeDirectMembership
	disabledProgram.TrialCardEnabled = false
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: disabledProgram, OperatorUserID: 9001}); err != nil {
		t.Fatalf("disable trial cards before generic equip failed: %v", err)
	}
	if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		AppCode: "fami", RequestID: "generic-equip-disabled", UserID: userID,
		ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
	}); !xerr.IsCode(err, xerr.VIPProgramInactive) {
		t.Fatalf("generic backpack equip must preserve trial_card_enabled validation, got %v", err)
	}
	if got := repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ? AND resource_type = ?", "fami", userID, resourcedomain.TypeVIPTrialCard); got != 0 {
		t.Fatalf("disabled generic trial equip must not mutate equipment, got %d rows", got)
	}
	disabledProgram.GrantMode = ledger.VipGrantModeTrialCard
	disabledProgram.TrialCardEnabled = true
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: disabledProgram, OperatorUserID: 9001}); err != nil {
		t.Fatalf("restore trial cards before generic equip failed: %v", err)
	}

	resourceEventsBefore := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged")
	vipEventsBefore := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged")
	_, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		AppCode: "fami", RequestID: "generic-equip-trial-3", UserID: userID,
		ResourceID: card3.TrialCard.ResourceID, EntitlementID: card3.TrialCard.EntitlementID,
	})
	if !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
		t.Fatalf("generic backpack must reject VIP3 trial card below paid VIP6, got %v", err)
	}
	if metadata := xerr.MetadataOf(err); metadata["current_effective_level"] != "6" || metadata["required_level"] != "6" {
		t.Fatalf("lower trial rejection must expose current level metadata, got %+v", metadata)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged"); got != resourceEventsBefore {
		t.Fatalf("rejected lower trial must not publish UserResourceChanged: before=%d after=%d", resourceEventsBefore, got)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged"); got != vipEventsBefore {
		t.Fatalf("rejected lower trial must not publish VipEffectiveChanged: before=%d after=%d", vipEventsBefore, got)
	}
	state3, err := service.GetVipState(ctx, userID)
	if err != nil {
		t.Fatalf("get state after rejected VIP3 trial equip failed: %v", err)
	}
	if state3.EffectiveSource != ledger.VipEffectiveSourcePaid || state3.EffectiveVip.Level != 6 || state3.EquippedTrialCard != nil {
		t.Fatalf("rejected lower trial card must preserve paid VIP6 without equipment: %+v", state3)
	}
	_, err = service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		AppCode: "fami", RequestID: "generic-equip-trial-6", UserID: userID,
		ResourceID: card6.TrialCard.ResourceID, EntitlementID: card6.TrialCard.EntitlementID,
	})
	if err != nil {
		t.Fatalf("generic backpack switch to VIP6 trial card failed: %v", err)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "UserResourceChanged"); got != resourceEventsBefore+1 {
		t.Fatalf("accepted equal-level trial must publish UserResourceChanged exactly once: before=%d after=%d", resourceEventsBefore, got)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND user_id = ? AND event_type = ?", "fami", userID, "VipEffectiveChanged"); got != vipEventsBefore+1 {
		t.Fatalf("accepted equal-level trial must publish VipEffectiveChanged exactly once: before=%d after=%d", vipEventsBefore, got)
	}
	state6, err := service.GetVipState(ctx, userID)
	if err != nil {
		t.Fatalf("get state after generic VIP6 trial equip failed: %v", err)
	}
	if state6.EffectiveVip.Level != 6 || state6.EquippedTrialCard == nil || state6.EquippedTrialCard.EntitlementID != card6.TrialCard.EntitlementID {
		t.Fatalf("trial card switch mismatch: %+v", state6)
	}
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-trial-3-again", UserID: userID, EntitlementID: card3.TrialCard.EntitlementID,
	}); !xerr.IsCode(err, xerr.VIPDowngradeNotAllowed) {
		t.Fatalf("VIP3 trial must not replace currently equipped VIP6 trial, got %v", err)
	}
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-trial-6-again", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID,
	}); err != nil {
		t.Fatalf("re-equip VIP6 trial card failed: %v", err)
	}
	trialRebate, err := service.CheckVipBenefit(ctx, userID, ledger.VipBenefitCodeDailyCoinRebate)
	if err != nil {
		t.Fatalf("check trial rebate failed: %v", err)
	}
	if trialRebate.Allowed {
		t.Fatalf("trial card must not receive daily coin rebate: %+v", trialRebate)
	}
	// App 级体验卡开关只控制是否参与 effective 合并，不能删除用户背包事实；关闭后应立即回落付费 VIP。
	trialProgram := trialRebate.State.ProgramConfig
	trialProgram.GrantMode = ledger.VipGrantModeDirectMembership
	trialProgram.TrialCardEnabled = false
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: trialProgram, OperatorUserID: 9001}); err != nil {
		t.Fatalf("disable Fami trial-card program failed: %v", err)
	}
	trialOffState, err := service.GetVipState(ctx, userID)
	if err != nil {
		t.Fatalf("get VIP state with trial cards disabled failed: %v", err)
	}
	if trialOffState.EquippedTrialCard == nil || trialOffState.EffectiveSource != ledger.VipEffectiveSourcePaid || trialOffState.EffectiveVip.Level != 6 {
		t.Fatalf("disabled trial cards must remain raw inventory and fall back to paid VIP: %+v", trialOffState)
	}
	trialProgram.GrantMode = ledger.VipGrantModeTrialCard
	trialProgram.TrialCardEnabled = true
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: trialProgram, OperatorUserID: 9001}); err != nil {
		t.Fatalf("restore Fami trial-card program failed: %v", err)
	}
	restoredTrialState, err := service.GetVipState(ctx, userID)
	if err != nil || restoredTrialState.EffectiveSource != ledger.VipEffectiveSourceTrial || restoredTrialState.EquippedTrialCard == nil {
		t.Fatalf("restored trial-card switch must reuse the original equipped card: state=%+v err=%v", restoredTrialState, err)
	}
	programOff := restoredTrialState.ProgramConfig
	programOff.Status = ledger.VipStatusDisabled
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: programOff, OperatorUserID: 9001}); err != nil {
		t.Fatalf("disable Fami VIP program failed: %v", err)
	}
	disabledState, err := service.GetVipState(ctx, userID)
	if err != nil || disabledState.PaidVip.Level != 6 || disabledState.EquippedTrialCard == nil || disabledState.EffectiveSource != ledger.VipEffectiveSourceNone || disabledState.EffectiveVip.Active {
		t.Fatalf("disabled program must expose raw facts without authorizing VIP: state=%+v err=%v", disabledState, err)
	}
	programOff.Status = ledger.VipStatusActive
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: programOff, OperatorUserID: 9001}); err != nil {
		t.Fatalf("restore Fami VIP program failed: %v", err)
	}
	unequipResult, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
		AppCode: "fami", RequestID: "generic-unequip-trial", UserID: userID, ResourceType: resourcedomain.TypeVIPTrialCard,
	})
	unequipped := unequipResult.Unequipped
	if err != nil || !unequipped {
		t.Fatalf("generic backpack unequip trial card failed: result=%+v err=%v", unequipResult, err)
	}
	paidState, err := service.GetVipState(ctx, userID)
	if err != nil {
		t.Fatalf("get state after generic trial unequip failed: %v", err)
	}
	if paidState.EffectiveSource != ledger.VipEffectiveSourcePaid || paidState.EffectiveVip.Level != 6 {
		t.Fatalf("unequip must fall back to paid VIP6: %+v", paidState)
	}
	paidRebate, err := service.CheckVipBenefit(ctx, userID, ledger.VipBenefitCodeDailyCoinRebate)
	if err != nil || !paidRebate.Allowed {
		t.Fatalf("paid VIP6 must retain configured rebate benefit: allowed=%v err=%v result=%+v", paidRebate.Allowed, err, paidRebate)
	}

	// 体验卡错误必须稳定区分“实例不存在”和“绝对时间已过期”，Flutter 不解析 message 猜分支。
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-missing-trial", UserID: userID, EntitlementID: "missing-entitlement",
	}); !xerr.IsCode(err, xerr.VIPTrialCardNotFound) {
		t.Fatalf("missing trial card must return VIP_TRIAL_CARD_NOT_FOUND, got %v", err)
	}
	repository.ExpireEntitlement(card6.TrialCard.EntitlementID, time.Now().Add(-time.Minute).UnixMilli())
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-expired-trial", UserID: userID, EntitlementID: card6.TrialCard.EntitlementID,
	}); !xerr.IsCode(err, xerr.VIPTrialCardExpired) {
		t.Fatalf("expired trial card must return VIP_TRIAL_CARD_EXPIRED, got %v", err)
	}
}

// TestFamiVIPPurchaseReplacesCoveredTrialCard 锁定购买与体验卡的三条产品边界：
// 购买等级高于或等于体验等级时回收卡片及全部体验资源；低于体验等级时保留体验态，
// 付费会员独立计时并在体验卡自然失效后作为回落身份。
func TestFamiVIPPurchaseReplacesCoveredTrialCard(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")

	createBadge := func(code string) resourcedomain.Resource {
		t.Helper()
		resource, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{
			AppCode:        "fami",
			ResourceCode:   code,
			ResourceType:   resourcedomain.TypeBadge,
			Name:           code,
			Status:         resourcedomain.StatusActive,
			Grantable:      true,
			GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
			UsageScopes:    []string{"profile"},
			AssetURL:       "https://cdn.example/" + code + ".png",
			MetadataJSON:   `{"badge_form":"strip","badge_kind":"normal"}`,
			OperatorUserID: 9001,
		})
		if err != nil {
			t.Fatalf("create %s failed: %v", code, err)
		}
		return resource
	}
	vip2Badge := createBadge("vip2_trial_replace_badge")
	vip3Badge := createBadge("vip3_paid_replace_badge")
	vip5Badge := createBadge("vip5_trial_keep_badge")
	for _, binding := range []struct {
		level    int32
		resource resourcedomain.Resource
	}{
		{level: 2, resource: vip2Badge},
		{level: 3, resource: vip3Badge},
		{level: 5, resource: vip5Badge},
	} {
		repository.SetVIPLevelStatusForApp("fami", binding.level, ledger.VipStatusActive, 100)
		repository.BindVIPBenefitResourceForApp(
			"fami",
			binding.level,
			ledger.VipBenefitCodeMedal,
			binding.resource.ResourceID,
			resourcedomain.TypeBadge,
			true,
		)
		if got := repository.CountRows(
			"vip_level_benefits",
			"app_code = ? AND level = ? AND benefit_code = ? AND resource_id = ? AND resource_type = ?",
			"fami", binding.level, ledger.VipBenefitCodeMedal, binding.resource.ResourceID, resourcedomain.TypeBadge,
		); got != 1 {
			t.Fatalf("VIP%d badge binding was not persisted: resource=%+v rows=%d", binding.level, binding.resource, got)
		}
		if got := repository.CountRows(
			"resources",
			"app_code = ? AND resource_id = ? AND resource_type = ? AND status = ?",
			"fami", binding.resource.ResourceID, resourcedomain.TypeBadge, resourcedomain.StatusActive,
		); got != 1 {
			t.Fatalf("VIP%d badge resource was not active: resource=%+v rows=%d", binding.level, binding.resource, got)
		}
	}
	_, levels, err := service.ListAdminVipLevels(ctx)
	if err != nil {
		t.Fatalf("list Fami VIP levels failed: %v", err)
	}
	var vip3DurationMS int64
	for _, level := range levels {
		if level.Level == 3 {
			vip3DurationMS = level.DurationMS
			break
		}
	}
	if vip3DurationMS <= 0 {
		t.Fatalf("VIP3 duration is not configured: %+v", levels)
	}

	equippedBadges := func(userID int64) []resourcedomain.UserResourceEntitlement {
		t.Helper()
		items, err := service.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
			AppCode: "fami", UserIDs: []int64{userID}, ResourceTypes: []string{resourcedomain.TypeBadge},
		})
		if err != nil {
			t.Fatalf("list user %d equipped badges failed: %v", userID, err)
		}
		if len(items) != 1 {
			t.Fatalf("user %d equipped badge projection mismatch: %+v", userID, items)
		}
		return items[0].Resources
	}

	const upgradedUserID int64 = 880011
	repository.SetBalanceForApp("fami", upgradedUserID, 1_000)
	vip2Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
		AppCode: "fami", CommandID: "replace-trial-vip2", TargetUserID: upgradedUserID, Level: 2,
		DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001, Reason: "purchase replacement test",
	})
	if err != nil {
		t.Fatalf("grant VIP2 trial card failed: %v", err)
	}
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-replaced-vip2", UserID: upgradedUserID,
		EntitlementID: vip2Card.TrialCard.EntitlementID,
	}); err != nil {
		t.Fatalf("equip VIP2 trial card failed: %v", err)
	}
	beforeReplace := equippedBadges(upgradedUserID)
	if len(beforeReplace) != 1 || beforeReplace[0].ResourceID != vip2Badge.ResourceID {
		t.Fatalf(
			"VIP2 trial resource was not equipped before purchase: equipped=%+v trial_grants=%d active_entitlements=%d equipment=%d",
			beforeReplace,
			repository.CountRows("resource_grants", "app_code = ? AND target_user_id = ? AND grant_source = ?", "fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial),
			repository.CountRows("user_resource_entitlements", "app_code = ? AND user_id = ? AND status = 'active'", "fami", upgradedUserID),
			repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ?", "fami", upgradedUserID),
		)
	}
	if got := repository.CountRows(
		"resource_grants",
		"app_code = ? AND target_user_id = ? AND grant_source = ? AND status = ?",
		"fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial, resourcedomain.GrantStatusDone,
	); got == 0 {
		t.Fatal("equipped VIP2 trial card did not create a trial benefit grant")
	}

	purchasedAt := time.Now().UnixMilli()
	upgrade, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
		AppCode: "fami", CommandID: "buy-vip3-over-trial-vip2", UserID: upgradedUserID, Level: 3,
	})
	if err != nil {
		t.Fatalf("purchase VIP3 over VIP2 trial failed: %v", err)
	}
	if upgrade.State.PaidVip.Level != 3 || upgrade.State.EffectiveSource != ledger.VipEffectiveSourcePaid ||
		upgrade.State.EffectiveVip.Level != 3 || upgrade.State.EquippedTrialCard != nil {
		t.Fatalf("VIP3 purchase must atomically replace VIP2 trial identity: %+v", upgrade.State)
	}
	if upgrade.Vip.StartedAtMS < purchasedAt-2_000 ||
		upgrade.Vip.ExpiresAtMS-upgrade.Vip.StartedAtMS != vip3DurationMS {
		t.Fatalf("paid VIP3 must start a fresh configured window without trial remainder: vip=%+v duration=%d", upgrade.Vip, vip3DurationMS)
	}
	if got := repository.CountRows(
		"user_vip_trial_cards",
		"app_code = ? AND trial_card_id = ? AND status = ?",
		"fami", vip2Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusRevoked,
	); got != 1 {
		t.Fatalf("replaced VIP2 trial card must be revoked, got %d", got)
	}
	if got := repository.CountRows(
		"user_resource_entitlements",
		"app_code = ? AND entitlement_id = ? AND status = ? AND remaining_quantity = 0",
		"fami", vip2Card.TrialCard.EntitlementID, resourcedomain.GrantStatusRevoked,
	); got != 1 {
		t.Fatalf("replaced VIP2 card entitlement must be recovered, got %d", got)
	}
	if got := repository.CountRows(
		"user_resource_entitlements",
		`app_code = ? AND user_id = ? AND status = 'active' AND source_grant_id IN (
		  SELECT grant_id FROM resource_grants
		  WHERE app_code = ? AND target_user_id = ? AND grant_source = ?
		)`,
		"fami", upgradedUserID, "fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial,
	); got != 0 {
		t.Fatalf("replaced VIP2 trial resources must not remain active, got %d", got)
	}
	if got := repository.CountRows(
		"user_resource_equipment",
		`app_code = ? AND user_id = ? AND entitlement_id IN (
		  SELECT entitlement_id FROM user_resource_entitlements
		  WHERE app_code = ? AND user_id = ? AND source_grant_id IN (
		    SELECT grant_id FROM resource_grants
		    WHERE app_code = ? AND target_user_id = ? AND grant_source = ?
		  )
		)`,
		"fami", upgradedUserID, "fami", upgradedUserID,
		"fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial,
	); got != 0 {
		t.Fatalf("replaced VIP2 trial equipment must be removed, got %d", got)
	}
	afterReplace := equippedBadges(upgradedUserID)
	if len(afterReplace) != 1 || afterReplace[0].ResourceID != vip3Badge.ResourceID {
		t.Fatalf("paid VIP3 resource must replace the worn VIP2 trial resource: %+v", afterReplace)
	}

	const equalUserID int64 = 880012
	repository.SetBalanceForApp("fami", equalUserID, 1_000)
	vip3Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
		AppCode: "fami", CommandID: "replace-trial-vip3", TargetUserID: equalUserID, Level: 3,
		DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001, Reason: "equal purchase replacement test",
	})
	if err != nil {
		t.Fatalf("grant equal VIP3 trial card failed: %v", err)
	}
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-replaced-vip3", UserID: equalUserID,
		EntitlementID: vip3Card.TrialCard.EntitlementID,
	}); err != nil {
		t.Fatalf("equip equal VIP3 trial card failed: %v", err)
	}
	equalPurchase, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
		AppCode: "fami", CommandID: "buy-vip3-over-trial-vip3", UserID: equalUserID, Level: 3,
	})
	if err != nil {
		t.Fatalf("purchase VIP3 over equal trial failed: %v", err)
	}
	if equalPurchase.State.EffectiveSource != ledger.VipEffectiveSourcePaid ||
		equalPurchase.State.EquippedTrialCard != nil ||
		repository.CountRows(
			"user_vip_trial_cards",
			"app_code = ? AND trial_card_id = ? AND status = ?",
			"fami", vip3Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusRevoked,
		) != 1 {
		t.Fatalf("equal paid level must replace and recover the trial card: %+v", equalPurchase.State)
	}

	const lowerPaidUserID int64 = 880013
	repository.SetBalanceForApp("fami", lowerPaidUserID, 1_000)
	vip5Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
		AppCode: "fami", CommandID: "keep-trial-vip5", TargetUserID: lowerPaidUserID, Level: 5,
		DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
		OperatorUserID: 9001, Reason: "lower paid purchase test",
	})
	if err != nil {
		t.Fatalf("grant VIP5 trial card failed: %v", err)
	}
	if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
		AppCode: "fami", RequestID: "equip-kept-vip5", UserID: lowerPaidUserID,
		EntitlementID: vip5Card.TrialCard.EntitlementID,
	}); err != nil {
		t.Fatalf("equip VIP5 trial card failed: %v", err)
	}
	lowerPurchase, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
		AppCode: "fami", CommandID: "buy-vip3-under-trial-vip5", UserID: lowerPaidUserID, Level: 3,
	})
	if err != nil {
		t.Fatalf("purchase VIP3 under VIP5 trial failed: %v", err)
	}
	if lowerPurchase.State.PaidVip.Level != 3 ||
		lowerPurchase.State.EffectiveSource != ledger.VipEffectiveSourceTrial ||
		lowerPurchase.State.EffectiveVip.Level != 5 ||
		lowerPurchase.State.EquippedTrialCard == nil ||
		lowerPurchase.State.EquippedTrialCard.EntitlementID != vip5Card.TrialCard.EntitlementID {
		t.Fatalf("lower paid VIP must remain behind the higher trial identity: %+v", lowerPurchase.State)
	}
	if got := repository.CountRows(
		"user_vip_trial_cards",
		"app_code = ? AND trial_card_id = ? AND status = ?",
		"fami", vip5Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusActive,
	); got != 1 {
		t.Fatalf("higher VIP5 trial card must remain active, got %d", got)
	}
	keptTrialBadges := equippedBadges(lowerPaidUserID)
	if len(keptTrialBadges) != 1 || keptTrialBadges[0].ResourceID != vip5Badge.ResourceID {
		t.Fatalf("higher VIP5 trial resource must remain equipped: %+v", keptTrialBadges)
	}
}

// TestVipReadSyncPreservesLowerLevelEquipmentAndReplaysOutbox 验证 VIP8 手动佩戴 VIP6
// 麦位皮肤后，两个读取入口都不会恢复 VIP8 默认值；槽位确实丢失时仍会懒恢复，
// 且相同 vip_state_sync 事实再次生成只重放既有 outbox 行，不向接口泄漏 1062。
func TestVipReadSyncPreservesLowerLevelEquipmentAndReplaysOutbox(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")

	createMicSkin := func(code string) resourcedomain.Resource {
		t.Helper()
		resource, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{
			AppCode:        "fami",
			ResourceCode:   code,
			ResourceType:   resourcedomain.TypeMicSeatIcon,
			Name:           code,
			Status:         resourcedomain.StatusActive,
			Grantable:      true,
			GrantStrategy:  resourcedomain.GrantStrategySetActiveFlag,
			UsageScopes:    []string{"mic_seat"},
			AssetURL:       "https://cdn.example/" + code + ".png",
			OperatorUserID: 9001,
		})
		if err != nil {
			t.Fatalf("create %s failed: %v", code, err)
		}
		return resource
	}
	vip6Skin := createMicSkin("vip6_read_sync_mic_skin")
	vip8Skin := createMicSkin("vip8_read_sync_mic_skin")
	repository.BindVIPBenefitResourceForApp(
		"fami", 6, ledger.VipBenefitCodeMicSkin,
		vip6Skin.ResourceID, resourcedomain.TypeMicSeatIcon, true,
	)
	repository.BindVIPBenefitResourceForApp(
		"fami", 8, ledger.VipBenefitCodeMicSkin,
		vip8Skin.ResourceID, resourcedomain.TypeMicSeatIcon, true,
	)
	repository.SetVIPLevelStatusForApp("fami", 8, ledger.VipStatusActive, 100)

	const userID int64 = 880014
	repository.SetBalanceForApp("fami", userID, 1_000)
	if _, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
		AppCode: "fami", CommandID: "buy-vip8-read-sync", UserID: userID, Level: 8,
	}); err != nil {
		t.Fatalf("purchase VIP8 failed: %v", err)
	}
	resources, err := service.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
		AppCode: "fami", UserID: userID, ResourceType: resourcedomain.TypeMicSeatIcon, ActiveOnly: true,
	})
	if err != nil {
		t.Fatalf("list VIP mic skins failed: %v", err)
	}
	var vip6EntitlementID string
	for _, item := range resources {
		if item.ResourceID == vip6Skin.ResourceID {
			vip6EntitlementID = item.EntitlementID
			break
		}
	}
	if vip6EntitlementID == "" {
		t.Fatalf("VIP8 purchase did not grant inherited VIP6 mic skin: %+v", resources)
	}
	if _, err := service.EquipUserResource(ctx, resourcedomain.EquipUserResourceCommand{
		AppCode: "fami", CommandID: "equip-vip6-read-sync", UserID: userID,
		ResourceID: vip6Skin.ResourceID, EntitlementID: vip6EntitlementID,
	}); err != nil {
		t.Fatalf("VIP8 user equip VIP6 mic skin failed: %v", err)
	}

	assertEquippedSkin := func(wantResourceID int64) {
		t.Helper()
		items, err := service.ListUserResources(ctx, resourcedomain.ListUserResourcesQuery{
			AppCode: "fami", UserID: userID, ResourceType: resourcedomain.TypeMicSeatIcon, ActiveOnly: true,
		})
		if err != nil {
			t.Fatalf("list equipped VIP mic skin failed: %v", err)
		}
		var equippedResourceID int64
		for _, item := range items {
			if item.Equipped {
				if equippedResourceID != 0 {
					t.Fatalf("mic skin slot must remain single-select: %+v", items)
				}
				equippedResourceID = item.ResourceID
			}
		}
		if equippedResourceID != wantResourceID {
			t.Fatalf("equipped mic skin mismatch: got=%d want=%d resources=%+v", equippedResourceID, wantResourceID, items)
		}
	}

	if _, err := service.GetVipState(ctx, userID); err != nil {
		t.Fatalf("GetVipState after VIP6 selection failed: %v", err)
	}
	assertEquippedSkin(vip6Skin.ResourceID)
	if _, _, err := service.ListVipPackages(ctx, userID); err != nil {
		t.Fatalf("ListVipPackages after VIP6 selection failed: %v", err)
	}
	assertEquippedSkin(vip6Skin.ResourceID)
	if got := repository.CountRows(
		"wallet_outbox",
		"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
		"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
	); got != 0 {
		t.Fatalf("read sync must not publish a default-equip event for a valid lower-level selection, got %d", got)
	}

	if _, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
		AppCode: "fami", RequestID: "clear-vip-mic-before-first-sync", UserID: userID,
		ResourceType: resourcedomain.TypeMicSeatIcon,
	}); err != nil {
		t.Fatalf("clear mic skin before first lazy recovery failed: %v", err)
	}
	if _, err := service.GetVipState(ctx, userID); err != nil {
		t.Fatalf("first missing-slot lazy recovery failed: %v", err)
	}
	assertEquippedSkin(vip8Skin.ResourceID)
	if got := repository.CountRows(
		"wallet_outbox",
		"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
		"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
	); got != 1 {
		t.Fatalf("first missing-slot recovery must publish exactly one sync fact, got %d", got)
	}

	if _, err := service.UnequipUserResource(ctx, resourcedomain.UnequipUserResourceCommand{
		AppCode: "fami", RequestID: "clear-vip-mic-before-replay", UserID: userID,
		ResourceType: resourcedomain.TypeMicSeatIcon,
	}); err != nil {
		t.Fatalf("clear mic skin before lazy replay failed: %v", err)
	}
	if _, _, err := service.ListVipPackages(ctx, userID); err != nil {
		t.Fatalf("replayed missing-slot lazy recovery must not return duplicate-key error: %v", err)
	}
	assertEquippedSkin(vip8Skin.ResourceID)
	if got := repository.CountRows(
		"wallet_outbox",
		"app_code = ? AND user_id = ? AND event_type = ? AND command_id LIKE ?",
		"fami", userID, "UserResourceChanged", "vip_reconcile_equip:vip_state_sync:%",
	); got != 1 {
		t.Fatalf("replayed sync fact must retain one immutable outbox row, got %d", got)
	}
}

// TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前：
// 两个并发首发即使余额只够一单，也都返回同一成功回执，而不是让后到请求误报余额不足。
func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "lalu")
	const userID int64 = 880003
	const commandID = "vip-concurrent-exact-balance"
	repository.SetBalanceForApp("lalu", userID, 1_000)

	type purchaseResult struct {
		receipt ledger.PurchaseVipReceipt
		err     error
	}
	start := make(chan struct{})
	results := make(chan purchaseResult, 2)
	for range 2 {
		go func() {
			<-start
			receipt, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
				AppCode: "lalu", CommandID: commandID, UserID: userID, Level: 1,
			})
			results <- purchaseResult{receipt: receipt, err: err}
		}()
	}
	close(start)
	first := <-results
	second := <-results
	if first.err != nil || second.err != nil {
		t.Fatalf("concurrent VIP replay must return two successes: first=%v second=%v", first.err, second.err)
	}
	if first.receipt.OrderID == "" || first.receipt.OrderID != second.receipt.OrderID || first.receipt.TransactionID != second.receipt.TransactionID {
		t.Fatalf("concurrent VIP replay returned different receipts: first=%+v second=%+v", first.receipt, second.receipt)
	}
	if first.receipt.CoinBalanceAfter != 0 || second.receipt.CoinBalanceAfter != 0 {
		t.Fatalf("idempotent purchase must debit exact balance once: first=%d second=%d", first.receipt.CoinBalanceAfter, second.receipt.CoinBalanceAfter)
	}
	if first.receipt.CoinBalance.Version <= 0 || first.receipt.CoinBalance.Version != second.receipt.CoinBalance.Version {
		t.Fatalf("idempotent purchase must preserve original balance version: first=%+v second=%+v", first.receipt.CoinBalance, second.receipt.CoinBalance)
	}
	if got := repository.CountRows("vip_purchase_orders", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 {
		t.Fatalf("concurrent replay persisted %d VIP orders, want 1", got)
	}
	if got := repository.CountRows("vip_command_locks", "app_code = ? AND command_id = ?", "lalu", commandID); got != 1 {
		t.Fatalf("concurrent replay persisted %d VIP command guards, want 1", got)
	}
}

// TestVipConfigurationListsDoNotExhaustPool 覆盖曾经的连接池自锁：主 levels rows 未关闭时，
// 每个请求再从 r.db 借连接查权益，20 个并发会占满池后相互等待。当前实现先关闭游标并在原事务内装配。
func TestVipConfigurationListsDoNotExhaustPool(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	base := appcode.WithContext(context.Background(), "fami")
	ctx, cancel := context.WithTimeout(base, 5*time.Second)
	defer cancel()

	start := make(chan struct{})
	results := make(chan error, 24)
	for index := range 24 {
		go func(index int) {
			<-start
			if index%2 == 0 {
				_, _, err := service.ListAdminVipLevels(ctx)
				results <- err
				return
			}
			_, _, err := service.ListVipPackages(ctx, int64(990000+index))
			results <- err
		}(index)
	}
	close(start)
	for range 24 {
		if err := <-results; err != nil {
			t.Fatalf("concurrent VIP list failed before pool drain: %v", err)
		}
	}
}

func TestVipProgramCanCreateAndResizeAppLevels(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "future_app")
	base := ledger.VipProgramConfig{
		AppCode:                  "future_app",
		ProgramType:              ledger.VipProgramTypeTieredPrivilegeV1,
		LevelCount:               2,
		SameLevelExpiryPolicy:    ledger.VipExpiryPolicyExtendRemaining,
		UpgradeExpiryPolicy:      ledger.VipExpiryPolicyReplaceFromNow,
		DowngradePurchasePolicy:  ledger.VipDowngradePurchasePolicyReject,
		BenefitInheritancePolicy: ledger.VipBenefitInheritanceTargetOnly,
		GrantMode:                ledger.VipGrantModeTrialCard,
		TrialCardEnabled:         true,
		Status:                   ledger.VipStatusActive,
	}
	created, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001})
	if err != nil {
		t.Fatalf("create VIP program for a new app failed: %v", err)
	}
	if created.ConfigVersion != 1 {
		t.Fatalf("new VIP program version mismatch: %+v", created)
	}
	_, levels, err := service.ListAdminVipLevels(ctx)
	if err != nil || len(levels) != 2 || levels[0].Status != ledger.VipStatusDisabled || levels[1].DurationMS <= 0 {
		t.Fatalf("new program must materialize disabled structural levels: levels=%+v err=%v", levels, err)
	}
	base.LevelCount = 3
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}); err != nil {
		t.Fatalf("grow VIP program level_count failed: %v", err)
	}
	_, levels, err = service.ListAdminVipLevels(ctx)
	if err != nil || len(levels) != 3 || levels[2].Level != 3 || levels[2].Status != ledger.VipStatusDisabled {
		t.Fatalf("grown program missing disabled placeholder: levels=%+v err=%v", levels, err)
	}
	commands := make([]ledger.AdminVipLevelCommand, 0, 3)
	for _, level := range levels {
		commands = append(commands, ledger.AdminVipLevelCommand{
			Level: level.Level, Name: level.Name, Status: ledger.VipStatusActive,
			PriceCoin: 100, DurationMS: level.DurationMS, SortOrder: level.SortOrder,
		})
	}
	if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
		t.Fatalf("activate grown VIP levels failed: %v", err)
	}
	base.LevelCount = 2
	if _, err := service.UpdateAdminVipProgramConfig(ctx, ledger.AdminVipProgramConfigCommand{Config: base, OperatorUserID: 9001}); err != nil {
		t.Fatalf("shrink VIP program level_count failed: %v", err)
	}
	repository.SetBalanceForApp("future_app", 880002, 1_000)
	_, err = service.PurchaseVip(ctx, ledger.PurchaseVipCommand{AppCode: "future_app", CommandID: "buy-hidden-level", UserID: 880002, Level: 3})
	if !xerr.IsCode(err, xerr.VIPPackageNotPurchasable) {
		t.Fatalf("level above current level_count must be unreachable, got %v", err)
	}
}
