package wallet_test

import (
	"context"
	"encoding/json"
	"strings"
	"testing"
	"time"

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

func TestVipDailyCoinRebateMissingProgramIsNoOp(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	taskDay := time.Now().UTC().Format("2006-01-02")

	result, err := service.ProcessVipDailyCoinRebateBatch(
		appcode.WithContext(context.Background(), "huwaa"),
		ledger.ProcessVipDailyCoinRebateBatchCommand{AppCode: "huwaa", TaskDay: taskDay, BatchSize: 100},
	)
	if err != nil || result.HasMore || result.CreatedCount != 0 || result.ScannedCount != 0 {
		t.Fatalf("missing VIP program must be an empty successful batch: result=%+v err=%v", result, err)
	}
}

// TestVipDailyCoinRebateUTCWindowSnapshotAndClaim 覆盖返现最关键的不可变边界：
// UTC 日切资格、run 配置快照、分页重放、App 隔离、available outbox 和并发领取仅入账一次。
func TestVipDailyCoinRebateUTCWindowSnapshotAndClaim(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	now := time.Now().UTC()
	dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	dayStartMS := dayStart.UnixMilli()
	dayEndMS := dayStart.Add(24 * time.Hour).UnixMilli()
	taskDay := dayStart.Format("2006-01-02")
	famiCtx := appcode.WithContext(context.Background(), "fami")

	repository.SetVIPDailyCoinRebateAmount("fami", 6, 60, ledger.VipStatusActive)
	repository.SetVIPDailyCoinRebateAmount("fami", 7, 70, ledger.VipStatusActive)
	// started==day_start 与 expires>day_start 均为有效；开始晚 1ms、到期恰好落在 day_start 均无资格。
	repository.SeedPaidVIPAtWindow("fami", 910001, 6, dayStartMS, dayEndMS)
	repository.SeedPaidVIPAtWindow("fami", 910002, 6, dayStartMS+1, dayEndMS)
	repository.SeedPaidVIPAtWindow("fami", 910003, 6, dayStartMS-1000, dayStartMS)
	repository.SeedPaidVIPAtWindow("fami", 910004, 7, dayStartMS-1000, dayStartMS+1)

	firstPage, err := service.ProcessVipDailyCoinRebateBatch(famiCtx, ledger.ProcessVipDailyCoinRebateBatchCommand{
		AppCode: "fami", TaskDay: taskDay, BatchSize: 1,
	})
	if err != nil {
		t.Fatalf("process first VIP rebate page failed: %v", err)
	}
	if firstPage.CreatedCount != 1 || !firstPage.HasMore || firstPage.Run.LevelAmounts[6] != 60 || firstPage.Run.LevelAmounts[7] != 70 {
		t.Fatalf("first VIP rebate page snapshot mismatch: %+v", firstPage)
	}
	initialConfigVersion := firstPage.Run.ConfigVersion
	// run 创建后改价不能污染剩余页；VIP7 仍必须按 70 生成，而不是新值 700。
	repository.SetVIPDailyCoinRebateAmount("fami", 7, 700, ledger.VipStatusActive)
	var completed ledger.ProcessVipDailyCoinRebateBatchResult
	createdAfterConfigChange := 0
	for page := 0; page < 10; page++ {
		current, pageErr := service.ProcessVipDailyCoinRebateBatch(famiCtx, ledger.ProcessVipDailyCoinRebateBatchCommand{
			AppCode: "fami", TaskDay: taskDay, BatchSize: 1,
		})
		if pageErr != nil {
			t.Fatalf("process remaining VIP rebate page failed: %v", pageErr)
		}
		if current.Run.ConfigVersion != initialConfigVersion || current.Run.LevelAmounts[7] != 70 {
			t.Fatalf("existing run must keep original config snapshot: first=%+v current=%+v", firstPage.Run, current.Run)
		}
		createdAfterConfigChange += current.CreatedCount
		completed = current
		if !current.HasMore {
			break
		}
	}
	if createdAfterConfigChange != 1 || completed.HasMore || completed.Run.Status != ledger.VipDailyCoinRebateRunStatusCompleted {
		t.Fatalf("complete VIP rebate run failed: created=%d result=%+v", createdAfterConfigChange, completed)
	}
	// 完成后任意重放都只返回已有 run，不重复返现或 outbox。
	replayed, err := service.ProcessVipDailyCoinRebateBatch(famiCtx, ledger.ProcessVipDailyCoinRebateBatchCommand{
		AppCode: "fami", TaskDay: taskDay, BatchSize: 500,
	})
	if err != nil || replayed.CreatedCount != 0 || replayed.HasMore {
		t.Fatalf("completed VIP rebate replay must be a no-op: result=%+v err=%v", replayed, err)
	}
	if got := repository.CountRows("vip_daily_coin_rebates", "app_code = ? AND task_day = ?", "fami", taskDay); got != 2 {
		t.Fatalf("UTC boundary must create exactly two Fami rebates, got %d", got)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND event_type = ?", "fami", "VipDailyCoinRebateAvailable"); got != 2 {
		t.Fatalf("available outbox must be one-to-one with rebates, got %d", got)
	}

	rebate6, found, err := service.GetMyCurrentVipDailyCoinRebate(famiCtx, 910001)
	if err != nil || !found || rebate6.CoinAmount != 60 || rebate6.AvailableAtMS != dayStartMS || rebate6.ExpiresAtMS != dayEndMS {
		t.Fatalf("current VIP6 rebate mismatch: found=%v rebate=%+v err=%v", found, rebate6, err)
	}
	rebate7, found, err := service.GetMyCurrentVipDailyCoinRebate(famiCtx, 910004)
	if err != nil || !found || rebate7.CoinAmount != 70 {
		t.Fatalf("current VIP7 rebate must use run snapshot: found=%v rebate=%+v err=%v", found, rebate7, err)
	}
	if _, found, err := service.GetMyCurrentVipDailyCoinRebate(famiCtx, 910002); err != nil || found {
		t.Fatalf("membership starting after day_start must not receive rebate: found=%v err=%v", found, err)
	}

	var availablePayload map[string]any
	if err := json.Unmarshal([]byte(repository.WalletOutboxPayload("fami", "VipDailyCoinRebateAvailable", 910001)), &availablePayload); err != nil {
		t.Fatalf("decode available outbox payload failed: %v", err)
	}
	for _, key := range []string{"rebate_id", "user_id", "task_day", "vip_level", "vip_name", "coin_amount", "available_at_ms", "expires_at_ms", "config_version", "created_at_ms"} {
		if _, exists := availablePayload[key]; !exists {
			t.Fatalf("available outbox payload missing %s: %+v", key, availablePayload)
		}
	}

	statuses, total, err := service.ListMyVipDailyCoinRebateStatuses(famiCtx, ledger.ListVipDailyCoinRebateStatusesQuery{
		AppCode: "fami", UserID: 910001, RebateIDs: []string{rebate6.RebateID, rebate7.RebateID}, Page: 1, PageSize: 20,
	})
	if err != nil || total != 1 || len(statuses) != 1 || statuses[0].RebateID != rebate6.RebateID {
		t.Fatalf("rebate_ids status lookup must remain user scoped: items=%+v total=%d err=%v", statuses, total, err)
	}

	type claimResult struct {
		receipt ledger.ClaimVipDailyCoinRebateReceipt
		err     error
	}
	start := make(chan struct{})
	results := make(chan claimResult, 2)
	for range 2 {
		go func() {
			<-start
			receipt, claimErr := service.ClaimVipDailyCoinRebate(famiCtx, ledger.ClaimVipDailyCoinRebateCommand{
				AppCode: "fami", CommandID: "claim-vip-rebate-concurrent", UserID: 910001, RebateID: rebate6.RebateID,
			})
			results <- claimResult{receipt: receipt, err: claimErr}
		}()
	}
	close(start)
	left, right := <-results, <-results
	if left.err != nil || right.err != nil || left.receipt.TransactionID == "" || left.receipt.TransactionID != right.receipt.TransactionID {
		t.Fatalf("concurrent claim replay must return same success: left=%+v right=%+v", left, right)
	}
	if left.receipt.CoinBalance.AvailableAmount != 60 || right.receipt.CoinBalance.AvailableAmount != 60 {
		t.Fatalf("concurrent claim must credit once: left=%+v right=%+v", left.receipt.CoinBalance, right.receipt.CoinBalance)
	}
	if got := repository.CountRows("wallet_transactions", "app_code = ? AND biz_type = ? AND external_ref = ?", "fami", "vip_daily_coin_rebate", rebate6.RebateID); got != 1 {
		t.Fatalf("concurrent claim created %d wallet transactions, want 1", got)
	}
	if got := repository.CountRows("wallet_entries", "app_code = ? AND transaction_id = ?", "fami", left.receipt.TransactionID); got != 1 {
		t.Fatalf("concurrent claim created %d wallet entries, want 1", got)
	}
	// 不同 command 的重复点击仍返回原交易；但已使用 command 改投另一 rebate 必须是稳定幂等冲突。
	retry, err := service.ClaimVipDailyCoinRebate(famiCtx, ledger.ClaimVipDailyCoinRebateCommand{
		AppCode: "fami", CommandID: "claim-vip-rebate-second-click", UserID: 910001, RebateID: rebate6.RebateID,
	})
	if err != nil || retry.TransactionID != left.receipt.TransactionID || retry.CoinBalance.AvailableAmount != 60 {
		t.Fatalf("different-command repeat click must return original receipt: receipt=%+v err=%v", retry, err)
	}
	_, err = service.ClaimVipDailyCoinRebate(famiCtx, ledger.ClaimVipDailyCoinRebateCommand{
		AppCode: "fami", CommandID: "claim-vip-rebate-second-click", UserID: 910004, RebateID: rebate7.RebateID,
	})
	if !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("successful repeat-click command must be consumed for its original rebate, got %v", err)
	}
	_, err = service.ClaimVipDailyCoinRebate(famiCtx, ledger.ClaimVipDailyCoinRebateCommand{
		AppCode: "fami", CommandID: "claim-vip-rebate-concurrent", UserID: 910004, RebateID: rebate7.RebateID,
	})
	if !xerr.IsCode(err, xerr.IdempotencyConflict) {
		t.Fatalf("same command with another rebate must return IDEMPOTENCY_CONFLICT, got %v", err)
	}

	// 相同 user/task_day 在另一个 App 形成独立 run、rebate 和账户，不共享任何幂等事实。
	laluCtx := appcode.WithContext(context.Background(), "lalu")
	repository.SetVIPDailyCoinRebateAmount("lalu", 1, 11, ledger.VipStatusActive)
	repository.SeedPaidVIPAtWindow("lalu", 910001, 1, dayStartMS, dayEndMS)
	laluRun, err := service.ProcessVipDailyCoinRebateBatch(laluCtx, ledger.ProcessVipDailyCoinRebateBatchCommand{
		AppCode: "lalu", TaskDay: taskDay, BatchSize: 100,
	})
	if err != nil || laluRun.CreatedCount != 1 {
		t.Fatalf("process Lalu isolated rebate failed: result=%+v err=%v", laluRun, err)
	}
	laluRebate, found, err := service.GetMyCurrentVipDailyCoinRebate(laluCtx, 910001)
	if err != nil || !found || laluRebate.CoinAmount != 11 || laluRebate.RebateID == rebate6.RebateID {
		t.Fatalf("VIP rebate must stay App isolated: fami=%+v lalu=%+v found=%v err=%v", rebate6, laluRebate, found, err)
	}
}

// TestVipDailyCoinRebateLegacyZeroExpiryHistoryUsesMigrationBaseline 固定 060 老库兼容边界：
// ALTER 产生的 0 有效期 history 不能覆盖会员主表事实；基线只补无完整快照用户且可重入。
func TestVipDailyCoinRebateLegacyZeroExpiryHistoryUsesMigrationBaseline(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")
	now := time.Now().UTC()
	dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	dayStartMS := dayStart.UnixMilli()
	dayEndMS := dayStart.Add(24 * time.Hour).UnixMilli()

	repository.SetVIPDailyCoinRebateAmount("fami", 6, 606, ledger.VipStatusActive)
	// 940101 只有旧 0 有效期 history，应被基线补齐；940102 已有新格式快照，迁移不得再插行。
	repository.SeedLegacyPaidVIPAtWindow("fami", 940101, 6, dayStartMS-1_000, dayEndMS)
	repository.SeedPaidVIPAtWindow("fami", 940102, 6, dayStartMS-1_000, dayEndMS)
	repository.ApplyVIPHistoryMigrationBaseline(dayStartMS - 500)
	repository.ApplyVIPHistoryMigrationBaseline(dayStartMS - 500)
	if got := repository.CountRows("user_vip_history", "app_code = ? AND order_id = ?", "fami", "vip_configurable_060_baseline"); got != 1 {
		t.Fatalf("migration baseline must be idempotent and skip complete histories, got %d rows", got)
	}
	if got := repository.CountRows("user_vip_history", "app_code = ? AND user_id = ?", "fami", 940102); got != 1 {
		t.Fatalf("complete new-format history must remain untouched, got %d rows", got)
	}
	if got := repository.CountRows("user_vip_history", "app_code = ? AND user_id = ? AND new_expires_at_ms = 0", "fami", 940101); got != 1 {
		t.Fatalf("migration must not rewrite legacy zero-expiry history, got %d rows", got)
	}

	// 基线执行后再造一条旧数据，单独验证 repository 忽略 0 快照并回退 current membership。
	repository.SeedLegacyPaidVIPAtWindow("fami", 940103, 6, dayStartMS-1_000, dayEndMS)
	result, err := service.ProcessVipDailyCoinRebateBatch(ctx, ledger.ProcessVipDailyCoinRebateBatchCommand{
		AppCode: "fami", TaskDay: dayStart.Format("2006-01-02"), BatchSize: 100,
	})
	if err != nil || result.CreatedCount != 3 || result.HasMore {
		t.Fatalf("legacy VIP rebate recovery mismatch: result=%+v err=%v", result, err)
	}
	for _, userID := range []int64{940101, 940102, 940103} {
		rebate, found, queryErr := service.GetMyCurrentVipDailyCoinRebate(ctx, userID)
		if queryErr != nil || !found || rebate.VipLevel != 6 || rebate.CoinAmount != 606 {
			t.Fatalf("recovered VIP rebate mismatch for user %d: found=%v rebate=%+v err=%v", userID, found, rebate, queryErr)
		}
	}
}

// TestVipDailyCoinRebateLatestLegacyHistoryDoesNotReviveOlderSnapshot 验证排序与有效性判定的先后顺序：
// 边界前最新行是迁移零值时，不得越过它选中更旧但完整的 VIP 快照。
func TestVipDailyCoinRebateLatestLegacyHistoryDoesNotReviveOlderSnapshot(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")
	now := time.Now().UTC()
	dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	dayStartMS := dayStart.UnixMilli()
	dayEndMS := dayStart.Add(24 * time.Hour).UnixMilli()

	repository.SetVIPDailyCoinRebateAmount("fami", 6, 606, ledger.VipStatusActive)
	// 较旧完整行看似有效到日终；较新旧格式行的有效期未知，当前会员于日切恰好到期。
	repository.SeedPaidVIPAtWindow("fami", 940201, 6, dayStartMS-2_000, dayEndMS)
	repository.SeedLegacyPaidVIPAtWindow("fami", 940201, 6, dayStartMS-1_000, dayStartMS)
	result, err := service.ProcessVipDailyCoinRebateBatch(ctx, ledger.ProcessVipDailyCoinRebateBatchCommand{
		AppCode: "fami", TaskDay: dayStart.Format("2006-01-02"), BatchSize: 100,
	})
	if err != nil || result.CreatedCount != 0 || result.HasMore {
		t.Fatalf("newer incomplete history must block older VIP snapshot revival: result=%+v err=%v", result, err)
	}
	if _, found, queryErr := service.GetMyCurrentVipDailyCoinRebate(ctx, 940201); queryErr != nil || found {
		t.Fatalf("stale older VIP snapshot unexpectedly created rebate: found=%v err=%v", found, queryErr)
	}
}

// TestVipDailyCoinRebateExpiredAndNotFoundCodes 固定客户端依赖的 404/409 原因码。
func TestVipDailyCoinRebateExpiredAndNotFoundCodes(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")
	now := time.Now().UTC()
	todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	yesterdayStart := todayStart.Add(-24 * time.Hour)
	repository.SetVIPDailyCoinRebateAmount("fami", 6, 66, ledger.VipStatusActive)
	repository.SeedPaidVIPAtWindow("fami", 920001, 6, yesterdayStart.UnixMilli(), todayStart.UnixMilli())
	result, err := service.ProcessVipDailyCoinRebateBatch(ctx, ledger.ProcessVipDailyCoinRebateBatchCommand{
		AppCode: "fami", TaskDay: yesterdayStart.Format("2006-01-02"), BatchSize: 100,
	})
	if err != nil || result.CreatedCount != 1 {
		t.Fatalf("create expired rebate fixture failed: result=%+v err=%v", result, err)
	}
	items, _, err := service.ListMyVipDailyCoinRebateStatuses(ctx, ledger.ListVipDailyCoinRebateStatusesQuery{
		AppCode: "fami", UserID: 920001, Page: 1, PageSize: 20,
	})
	if err != nil || len(items) != 1 || items[0].Status != ledger.VipDailyCoinRebateStatusExpired {
		t.Fatalf("expired status projection mismatch: items=%+v err=%v", items, err)
	}
	_, err = service.ClaimVipDailyCoinRebate(ctx, ledger.ClaimVipDailyCoinRebateCommand{
		AppCode: "fami", CommandID: "claim-expired-vip-rebate", UserID: 920001, RebateID: items[0].RebateID,
	})
	if !xerr.IsCode(err, xerr.VIPCoinRebateExpired) {
		t.Fatalf("expired claim must return VIP_COIN_REBATE_EXPIRED, got %v", err)
	}
	_, err = service.ClaimVipDailyCoinRebate(ctx, ledger.ClaimVipDailyCoinRebateCommand{
		AppCode: "fami", CommandID: "claim-missing-vip-rebate", UserID: 920001, RebateID: "vip_rebate_missing",
	})
	if !xerr.IsCode(err, xerr.VIPCoinRebateNotFound) {
		t.Fatalf("missing claim must return VIP_COIN_REBATE_NOT_FOUND, got %v", err)
	}
}

// TestVipDailyCoinRebateConcurrentRunCreation 覆盖两个 cron worker 同时首次触发同一天：
// 等待 program 锁的后到事务必须 current-read 复用已提交 run，不能在旧 RR 快照上撞唯一键。
func TestVipDailyCoinRebateConcurrentRunCreation(t *testing.T) {
	repository := mysqltest.NewRepository(t)
	service := walletservice.New(repository)
	ctx := appcode.WithContext(context.Background(), "fami")
	now := time.Now().UTC()
	dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	taskDay := dayStart.Format("2006-01-02")
	repository.SetVIPDailyCoinRebateAmount("fami", 6, 88, ledger.VipStatusActive)
	repository.SeedPaidVIPAtWindow("fami", 930001, 6, dayStart.UnixMilli(), dayStart.Add(24*time.Hour).UnixMilli())

	type batchResult struct {
		result ledger.ProcessVipDailyCoinRebateBatchResult
		err    error
	}
	start := make(chan struct{})
	results := make(chan batchResult, 2)
	for range 2 {
		go func() {
			<-start
			result, err := service.ProcessVipDailyCoinRebateBatch(ctx, ledger.ProcessVipDailyCoinRebateBatchCommand{
				AppCode: "fami", TaskDay: taskDay, BatchSize: 100,
			})
			results <- batchResult{result: result, err: err}
		}()
	}
	close(start)
	left, right := <-results, <-results
	if left.err != nil || right.err != nil || left.result.Run.RunID == "" || left.result.Run.RunID != right.result.Run.RunID {
		t.Fatalf("concurrent run creation must return one stable run: left=%+v right=%+v", left, right)
	}
	if got := repository.CountRows("vip_daily_coin_rebate_runs", "app_code = ? AND task_day = ?", "fami", taskDay); got != 1 {
		t.Fatalf("concurrent run creation persisted %d runs, want 1", got)
	}
	if got := repository.CountRows("vip_daily_coin_rebates", "app_code = ? AND task_day = ?", "fami", taskDay); got != 1 {
		t.Fatalf("concurrent run creation persisted %d rebates, want 1", got)
	}
	if got := repository.CountRows("wallet_outbox", "app_code = ? AND event_type = ?", "fami", "VipDailyCoinRebateAvailable"); got != 1 {
		t.Fatalf("concurrent run creation persisted %d available events, want 1", got)
	}
}

// TestVipDailyCoinRebateDisabledEmptyMetadataCanBeSaved 确保后台可先关闭权益并提交空对象，
// 但一旦启用就必须配置正 int64 coin_amount。
func TestVipDailyCoinRebateDisabledEmptyMetadataCanBeSaved(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 VIP levels failed: %v", err)
	}
	commands := make([]ledger.AdminVipLevelCommand, 0, len(levels))
	for _, level := range levels {
		for index := range level.Benefits {
			if level.Benefits[index].BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
				level.Benefits[index].Status = ledger.VipStatusDisabled
				level.Benefits[index].MetadataJSON = `{}`
			}
		}
		commands = append(commands, ledger.AdminVipLevelCommand{
			Level: level.Level, Name: level.Name, Status: level.Status, PriceCoin: level.PriceCoin,
			DurationMS: level.DurationMS, RewardResourceGroupID: level.RewardResourceGroupID,
			SortOrder: level.SortOrder, Benefits: level.Benefits,
		})
	}
	if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
		t.Fatalf("disabled daily rebate with empty metadata object must save: %v", err)
	}
	for commandIndex := range commands {
		for benefitIndex := range commands[commandIndex].Benefits {
			benefit := &commands[commandIndex].Benefits[benefitIndex]
			if benefit.BenefitCode == ledger.VipBenefitCodeDailyCoinRebate {
				benefit.Status = ledger.VipStatusActive
				benefit.MetadataJSON = `{}`
				_, _, err = service.UpdateAdminVipLevels(ctx, commands, 9001)
				if !xerr.IsCode(err, xerr.InvalidArgument) {
					t.Fatalf("active daily rebate without coin_amount must be invalid, got %v", err)
				}
				return
			}
		}
	}
	t.Fatal("Fami VIP config missing daily_coin_rebate fixture")
}

// TestVipOnlineGlobalNoticeMetadataValidation 固定 notice owner 契约：message 可省略，存在时必须
// 是 1..256 个非空字符，且 execution_scope 不能被后台改到 room/user。
func TestVipOnlineGlobalNoticeMetadataValidation(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 VIP levels failed: %v", err)
	}
	commands := make([]ledger.AdminVipLevelCommand, 0, len(levels))
	for _, level := range levels {
		for index := range level.Benefits {
			benefit := &level.Benefits[index]
			if benefit.BenefitCode == ledger.VipBenefitCodeOnlineGlobalNotice {
				benefit.MetadataJSON = `{}`
			}
		}
		commands = append(commands, ledger.AdminVipLevelCommand{
			Level: level.Level, Name: level.Name, Status: level.Status, PriceCoin: level.PriceCoin,
			DurationMS: level.DurationMS, RewardResourceGroupID: level.RewardResourceGroupID,
			SortOrder: level.SortOrder, Benefits: level.Benefits,
		})
	}
	if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
		t.Fatalf("online notice metadata without message must save: %v", err)
	}
	for commandIndex := range commands {
		for benefitIndex := range commands[commandIndex].Benefits {
			benefit := &commands[commandIndex].Benefits[benefitIndex]
			if benefit.BenefitCode != ledger.VipBenefitCodeOnlineGlobalNotice {
				continue
			}
			benefit.MetadataJSON = `{"message":" "}`
			if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); !xerr.IsCode(err, xerr.InvalidArgument) {
				t.Fatalf("blank online notice message must be invalid, got %v", err)
			}
			payload, _ := json.Marshal(map[string]string{"message": strings.Repeat("好", 257)})
			benefit.MetadataJSON = string(payload)
			if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); !xerr.IsCode(err, xerr.InvalidArgument) {
				t.Fatalf("257-character online notice message must be invalid, got %v", err)
			}
			benefit.MetadataJSON = `{"message":"hello"}`
			benefit.ExecutionScope = ledger.VipExecutionScopeRoom
			if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); !xerr.IsCode(err, xerr.InvalidArgument) {
				t.Fatalf("online notice non-notice scope must be invalid, got %v", err)
			}
			return
		}
	}
	t.Fatal("Fami VIP config missing online_global_notice fixture")
}
