package mysql

import (
	"context"
	"database/sql"
	"fmt"
	"sync"
	"testing"
	"time"

	"hyapp/internal/testutil/mysqlschema"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)

func TestDynamicLuckyGiftMySQLBatchAndExternalPaths(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		EnvVar:         "LUCKY_GIFT_SERVICE_MYSQL_TEST_DSN",
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "..", "..", "..", "deploy", "mysql", "initdb", "001_lucky_gift_service.sql"),
		DatabasePrefix: "hy_lucky_dynamic",
	})
	repo := &Repository{db: schema.DB}
	ctx := appcode.WithContext(context.Background(), "lalu")
	now := time.Date(2026, 7, 12, 12, 30, 0, 0, time.UTC).UnixMilli()

	internalRule := dynamicMySQLTestRule("internal-dynamic")
	internalRule.SettlementWindowWager = 99
	// 参考价故意设为 10：旧算法会把 99 流水错误折成 10 抽；本用例预置 10 抽/98 流水，证明 V3 不会因抽数已满而提前滚窗。
	internalRule.GiftPriceReference = 10
	if _, err := repo.PublishLuckyGiftRuleConfig(ctx, internalRule, now-1_000); err != nil {
		t.Fatalf("publish internal dynamic rule: %v", err)
	}
	// 同名 fixed_v2 旧账本模拟策略切换前历史水位；dynamic draw 必须只碰 pool_dynamic_v3。
	if _, err := schema.DB.Exec(`
		INSERT INTO lucky_pools (
		  app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
		  profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms
		) VALUES ('lalu', 'pool', ?, 4321, 321, 4000, 100, 0, 0, 4321, ?, ?)`, internalRule.PoolID, now-2_000, now-2_000); err != nil {
		t.Fatalf("seed fixed strategy ledger: %v", err)
	}
	checked, err := repo.CheckLuckyGift(ctx, domain.CheckCommand{PoolID: internalRule.PoolID, GiftID: "super-lucky", UserID: 1001, RoomID: "room-1"})
	if err != nil || checked.StrategyVersion != domain.StrategyDynamicV3 {
		t.Fatalf("check did not expose immutable strategy version: result=%+v err=%v", checked, err)
	}
	seedDynamicRTPWindowsBelowWagerThreshold(t, schema.DB, internalRule.PoolID, now-900)
	command := domain.DrawCommand{
		CommandID: "dynamic-99", PoolID: internalRule.PoolID, UserID: 1001, TargetUserID: 2001,
		DeviceID: "device-1001", RoomID: "room-1", AnchorID: "anchor-1", GiftID: "super-lucky",
		GiftCount: 99, CoinSpent: 100, HostPeriodDiamondAdded: 1, PaidAtMS: now,
	}
	missingWalletPaidAt := command
	missingWalletPaidAt.CommandID = "dynamic-missing-wallet-paid-at"
	missingWalletPaidAt.PaidAtMS = 0
	if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingWalletPaidAt, now+123_456); !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
	}
	var missingPaidAtDraws int64
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=?`, missingWalletPaidAt.CommandID).Scan(&missingPaidAtDraws); err != nil || missingPaidAtDraws != 0 {
		t.Fatalf("missing wallet paid_at_ms draw facts=%d err=%v", missingPaidAtDraws, err)
	}
	missingDevice := command
	missingDevice.CommandID = "dynamic-missing-device"
	missingDevice.DeviceID = ""
	missingDevice.PoolID = "internal-dynamic-no-device"
	missingDevice.GiftCount = 1
	if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamicMySQLTestRule(missingDevice.PoolID), now-700); err != nil {
		t.Fatalf("publish internal no-device dynamic rule: %v", err)
	}
	if _, err := repo.ExecuteLuckyGiftDraw(ctx, missingDevice, now); err != nil {
		t.Fatalf("dynamic_v3 missing signed device_id should skip only device cap: %v", err)
	}
	var missingDeviceDraws, missingDeviceCounters, remainingRiskCounters int64
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id=? AND device_id=''`, missingDevice.CommandID).Scan(&missingDeviceDraws); err != nil || missingDeviceDraws != 1 {
		t.Fatalf("missing device_id draw facts=%d err=%v, want one completed draw", missingDeviceDraws, err)
	}
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_risk_counters WHERE app_code='lalu' AND scope_type='device' AND scope_id LIKE ?`, missingDevice.PoolID+":%").Scan(&missingDeviceCounters); err != nil || missingDeviceCounters != 0 {
		t.Fatalf("missing device_id counters=%d err=%v, want device dimension skipped", missingDeviceCounters, err)
	}
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM lucky_risk_counters WHERE app_code='lalu' AND scope_type<>'device' AND scope_id LIKE ?`, missingDevice.PoolID+":%").Scan(&remainingRiskCounters); err != nil || remainingRiskCounters != 4 {
		t.Fatalf("remaining no-device risk counters=%d err=%v, want user hour/day, room and anchor", remainingRiskCounters, err)
	}
	// 两个相同 command_id 的首次请求同时进入：两者都必须成功，后到者等待 command lock 后
	// 从首次事务回读同一批 99 条事实，不能以唯一键冲突失败，更不能额外推进奖池/RTP/风控。
	var internalResults [2]domain.DrawResult
	var internalErrors [2]error
	internalStart := make(chan struct{})
	var internalWorkers sync.WaitGroup
	for index := range internalResults {
		internalWorkers.Add(1)
		go func(index int) {
			defer internalWorkers.Done()
			<-internalStart
			internalResults[index], internalErrors[index] = repo.ExecuteLuckyGiftDraw(ctx, command, now)
		}(index)
	}
	close(internalStart)
	internalWorkers.Wait()
	for index, workerErr := range internalErrors {
		if workerErr != nil {
			t.Fatalf("internal concurrent worker %d: %v", index, workerErr)
		}
	}
	first := internalResults[0]
	if len(first.DrawIDs) != 99 {
		t.Fatalf("aggregate draw ids=%d want=99", len(first.DrawIDs))
	}
	if internalResults[1].DrawID != first.DrawID ||
		internalResults[1].EffectiveRewardCoins != first.EffectiveRewardCoins ||
		internalResults[1].MultiplierPPM != first.MultiplierPPM ||
		internalResults[1].RewardStatus != first.RewardStatus ||
		fmt.Sprint(internalResults[1].DrawIDs) != fmt.Sprint(first.DrawIDs) ||
		fmt.Sprint(internalResults[1].Hits) != fmt.Sprint(first.Hits) {
		t.Fatalf("internal concurrent aggregate mismatch: %+v / %+v", first, internalResults[1])
	}
	var internalLockCount int64
	if err := schema.DB.QueryRow(`
		SELECT COUNT(*) FROM lucky_gift_command_locks
		WHERE app_code='lalu' AND command_id=?`, command.CommandID,
	).Scan(&internalLockCount); err != nil || internalLockCount != 1 {
		t.Fatalf("internal command lock count=%d err=%v", internalLockCount, err)
	}
	assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
	var fixedBalance, fixedTotalIn, fixedTotalOut int64
	if err := schema.DB.QueryRow(`
		SELECT balance, total_in, total_out FROM lucky_pools
		WHERE app_code='lalu' AND scope_type='pool' AND scope_id=?`, internalRule.PoolID,
	).Scan(&fixedBalance, &fixedTotalIn, &fixedTotalOut); err != nil || fixedBalance != 4321 || fixedTotalIn != 4000 || fixedTotalOut != 100 {
		t.Fatalf("dynamic draw changed fixed ledger balance/in/out=%d/%d/%d err=%v", fixedBalance, fixedTotalIn, fixedTotalOut, err)
	}
	assertDynamicRTPWindowRollover(t, schema.DB, internalRule.PoolID)
	assertExactRolling72HourBoundary(t, repo, schema.DB, ctx, internalRule.PoolID, now)

	// 顺序重放必须只读回首次 99 条事实，不能再次入池、推进风控或生成钱包返奖。
	replayed, err := repo.ExecuteLuckyGiftDraw(ctx, command, now+1)
	if err != nil {
		t.Fatalf("replay dynamic 99 draw: %v", err)
	}
	if replayed.DrawID != first.DrawID || replayed.EffectiveRewardCoins != first.EffectiveRewardCoins {
		t.Fatalf("replay changed aggregate: first=%+v replay=%+v", first, replayed)
	}
	assertDynamicMySQLBatchFacts(t, schema.DB, "lalu", internalRule.PoolID, command.UserID, command.CommandID, 99, 100, 98, 1, 1)
	assertDynamicTokenSurvivesUTCDayBoundary(t, repo, schema.DB, ctx, now)
	assertDynamicSpendBatchRollbackAndRestart(t, repo, schema.DB, ctx, now+10_000)
	assertExternalNewUserSpendBatch(t, repo, schema.DB, ctx, now+20_000)

	externalRule := dynamicMySQLTestRule("external-dynamic")
	if _, err := repo.PublishLuckyGiftRuleConfig(ctx, externalRule, now-500); err != nil {
		t.Fatalf("publish external dynamic rule: %v", err)
	}
	missingExternalPaidAt := domain.ExternalDrawCommand{
		AppCode: "lalu", ExternalUserID: "external-missing-paid-at", DeviceID: "trusted-device-missing-paid-at", RequestID: "external-dynamic-missing-paid-at",
		GiftCount: 1, UnitAmount: 10, TotalAmount: 10, Currency: "COIN", PoolID: externalRule.PoolID,
	}
	if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalPaidAt, now+234_567); !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("external dynamic_v3 missing paid_at_ms should fail closed, got %v", err)
	}
	var missingExternalDraws int64
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalPaidAt.RequestID).Scan(&missingExternalDraws); err != nil || missingExternalDraws != 0 {
		t.Fatalf("missing external paid_at_ms draw facts=%d err=%v", missingExternalDraws, err)
	}
	external := domain.ExternalDrawCommand{
		AppCode: "lalu", ExternalUserID: "external-42", DeviceID: "external-device-attested-42", RequestID: "external-dynamic-99",
		GiftCount: 99, UnitAmount: 10, TotalAmount: 990, Currency: "COIN", PaidAtMS: now, PoolID: externalRule.PoolID,
	}
	missingExternalDevice := external
	missingExternalDevice.RequestID = "external-dynamic-missing-device"
	missingExternalDevice.DeviceID = ""
	missingExternalDevice.PoolID = "external-dynamic-no-device"
	missingExternalDevice.GiftCount = 1
	missingExternalDevice.TotalAmount = missingExternalDevice.UnitAmount
	if _, err := repo.PublishLuckyGiftRuleConfig(ctx, dynamicMySQLTestRule(missingExternalDevice.PoolID), now-400); err != nil {
		t.Fatalf("publish external no-device dynamic rule: %v", err)
	}
	if _, err := repo.ExecuteExternalGiftDraw(ctx, missingExternalDevice, now); err != nil {
		t.Fatalf("external dynamic_v3 missing caller-bound device_id should skip only device cap: %v", err)
	}
	var missingExternalDeviceDraws int64
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, missingExternalDevice.RequestID).Scan(&missingExternalDeviceDraws); err != nil || missingExternalDeviceDraws != 1 {
		t.Fatalf("missing external device draw facts=%d err=%v, want one completed aggregate", missingExternalDeviceDraws, err)
	}
	var walletSettlementEventsBefore int64
	if err := schema.DB.QueryRow(`
		SELECT COUNT(*) FROM lucky_gift_outbox
		WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
	).Scan(&walletSettlementEventsBefore); err != nil {
		t.Fatalf("query wallet settlement outbox before external: %v", err)
	}
	// 两个同 request_id 的首次请求同时进入时，request lock 必须让后到者等待并读回同一结果。
	var results [2]domain.ExternalDrawResult
	var errorsByWorker [2]error
	start := make(chan struct{})
	var workers sync.WaitGroup
	for index := range results {
		workers.Add(1)
		go func(index int) {
			defer workers.Done()
			<-start
			results[index], errorsByWorker[index] = repo.ExecuteExternalGiftDraw(ctx, external, now)
		}(index)
	}
	close(start)
	workers.Wait()
	for index, workerErr := range errorsByWorker {
		if workerErr != nil {
			t.Fatalf("external concurrent worker %d: %v", index, workerErr)
		}
	}
	if results[0].DrawID == "" || results[0].DrawID != results[1].DrawID || results[0].RewardAmount != results[1].RewardAmount {
		t.Fatalf("external concurrent idempotency mismatch: %+v / %+v", results[0], results[1])
	}
	var aggregateCount, itemCount, itemSpend, itemReward int64
	if err := schema.DB.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, external.RequestID).Scan(&aggregateCount); err != nil {
		t.Fatalf("query external aggregate: %v", err)
	}
	if err := schema.DB.QueryRow(`
		SELECT COUNT(*), COALESCE(SUM(unit_amount),0), COALESCE(SUM(reward_amount),0)
		FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, external.RequestID,
	).Scan(&itemCount, &itemSpend, &itemReward); err != nil {
		t.Fatalf("query external items: %v", err)
	}
	if aggregateCount != 1 || itemCount != 99 || itemSpend != 990 || itemReward != results[0].RewardAmount {
		t.Fatalf("external facts aggregate=%d items=%d spend=%d reward=%d result=%d", aggregateCount, itemCount, itemSpend, itemReward, results[0].RewardAmount)
	}
	expectedExternalDeviceScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.DeviceID))
	legacyExternalUserScope := luckyPoolScopedID(externalRule.PoolID, externalLuckyDeviceID(external.AppCode, external.ExternalUserID))
	var expectedDeviceCounters, legacyUserCounters int64
	if err := schema.DB.QueryRow(`
		SELECT COUNT(*) FROM lucky_risk_counters
		WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, expectedExternalDeviceScope,
	).Scan(&expectedDeviceCounters); err != nil {
		t.Fatalf("query external device counter: %v", err)
	}
	if err := schema.DB.QueryRow(`
		SELECT COUNT(*) FROM lucky_risk_counters
		WHERE app_code='lalu' AND scope_type='device' AND scope_id=?`, legacyExternalUserScope,
	).Scan(&legacyUserCounters); err != nil {
		t.Fatalf("query legacy external-user device counter: %v", err)
	}
	if expectedDeviceCounters != 1 || legacyUserCounters != 0 {
		t.Fatalf("external dynamic device scope counters trusted=%d legacy_user=%d", expectedDeviceCounters, legacyUserCounters)
	}
	if _, err := repo.ExecuteExternalGiftDraw(ctx, external, now+1); err != nil {
		t.Fatalf("replay external dynamic result: %v", err)
	}
	var walletSettlementEventsAfter int64
	if err := schema.DB.QueryRow(`
		SELECT COUNT(*) FROM lucky_gift_outbox
		WHERE app_code='lalu' AND event_type=?`, domain.EventTypeLuckyGiftRewardSettlement,
	).Scan(&walletSettlementEventsAfter); err != nil {
		t.Fatalf("query wallet settlement outbox after external: %v", err)
	}
	if walletSettlementEventsAfter != walletSettlementEventsBefore {
		t.Fatalf("external dynamic draw created HyApp wallet settlements: before=%d after=%d", walletSettlementEventsBefore, walletSettlementEventsAfter)
	}
}

func seedDynamicRTPWindowsBelowWagerThreshold(t *testing.T, db *sql.DB, poolID string, nowMS int64) {
	t.Helper()
	scopeID := poolID + ":v1"
	for _, scopeType := range []string{"pool", "pool_gift"} {
		if _, err := db.Exec(`
			INSERT INTO lucky_rtp_windows (
			 app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
			 paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
			 started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
			) VALUES ('lalu',?,?,1,980000,10,10,98,96,0,40000,'open',?,?,0,?,?)`,
			scopeType, scopeID, nowMS-1_000, nowMS, nowMS, nowMS,
		); err != nil {
			t.Fatalf("seed %s RTP window: %v", scopeType, err)
		}
	}
}

func assertDynamicRTPWindowRollover(t *testing.T, db *sql.DB, poolID string) {
	t.Helper()
	scopeID := poolID + ":v1"
	for _, scopeType := range []string{"pool", "pool_gift"} {
		rows, err := db.Query(`
			SELECT window_index, paid_draws, wager_coins, status FROM lucky_rtp_windows
			WHERE app_code='lalu' AND scope_type=? AND scope_id=? ORDER BY window_index`, scopeType, scopeID)
		if err != nil {
			t.Fatalf("query %s rollover: %v", scopeType, err)
		}
		var indexes, paid, wagers []int64
		var statuses []string
		for rows.Next() {
			var index, drawCount, wager int64
			var status string
			if err := rows.Scan(&index, &drawCount, &wager, &status); err != nil {
				_ = rows.Close()
				t.Fatalf("scan %s rollover: %v", scopeType, err)
			}
			indexes = append(indexes, index)
			paid = append(paid, drawCount)
			wagers = append(wagers, wager)
			statuses = append(statuses, status)
		}
		if err := rows.Close(); err != nil {
			t.Fatalf("close %s rollover rows: %v", scopeType, err)
		}
		if fmt.Sprint(indexes) != "[1 2]" || fmt.Sprint(paid) != "[11 98]" || fmt.Sprint(wagers) != "[100 98]" || fmt.Sprint(statuses) != "[closed open]" {
			t.Fatalf("%s wager rollover indexes/paid/wager/status=%v/%v/%v/%v", scopeType, indexes, paid, wagers, statuses)
		}
	}
	var firstWindow, secondWindow, firstGiftWindow, secondGiftWindow int64
	if err := db.QueryRow(`
		SELECT
		 COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=1 THEN 1 ELSE 0 END),0),
		 COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND rtp_window_index=2 THEN 1 ELSE 0 END),0),
		 COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=1 THEN 1 ELSE 0 END),0),
		 COALESCE(SUM(CASE WHEN command_id LIKE 'dynamic-99#%' AND gift_rtp_window_index=2 THEN 1 ELSE 0 END),0)
		FROM lucky_draw_records WHERE app_code='lalu' AND pool_id=?`, poolID,
	).Scan(&firstWindow, &secondWindow, &firstGiftWindow, &secondGiftWindow); err != nil {
		t.Fatalf("query draw rollover indexes: %v", err)
	}
	if firstWindow != 1 || secondWindow != 98 || firstGiftWindow != 1 || secondGiftWindow != 98 {
		t.Fatalf("draw rollover global/gift=%d/%d %d/%d want=1/98 1/98", firstWindow, secondWindow, firstGiftWindow, secondGiftWindow)
	}
}

func assertDynamicTokenSurvivesUTCDayBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, dayOneMS int64) {
	t.Helper()
	rule := dynamicMySQLTestRule("token-cross-day")
	if _, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, dayOneMS-100); err != nil {
		t.Fatalf("publish token rule: %v", err)
	}
	userID := int64(3030)
	earn := domain.DrawCommand{
		CommandID: "token-earn", PoolID: rule.PoolID, UserID: userID, TargetUserID: 1,
		DeviceID: "device-token", RoomID: "room-token", AnchorID: "anchor-token", GiftID: "super-lucky",
		GiftCount: 1, CoinSpent: 5_000, HostPeriodDiamondAdded: 50, PaidAtMS: dayOneMS,
	}
	if _, err := repo.ExecuteLuckyGiftDraw(ctx, earn, dayOneMS); err != nil {
		t.Fatalf("earn milestone token: %v", err)
	}
	assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
	// A retired token is deliberately planted after the new token was earned. The next transaction
	// must clear it without copying or adding it to the new durable balance.
	if _, err := db.Exec(`UPDATE lucky_user_states SET pending_jackpot_tokens=7 WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID); err != nil {
		t.Fatalf("seed retired pending token: %v", err)
	}
	if result, err := db.Exec(`UPDATE lucky_pools SET balance=0 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil {
		t.Fatalf("drain token test pool: %v", err)
	} else if rows, _ := result.RowsAffected(); rows != 1 {
		t.Fatalf("drain token test pool affected=%d, want 1", rows)
	}
	dayTwoMS := time.UnixMilli(dayOneMS).UTC().Add(24 * time.Hour).UnixMilli()
	retain := earn
	retain.CommandID = "token-retain-next-day"
	retain.CoinSpent = 1
	retain.HostPeriodDiamondAdded = 0
	retain.PaidAtMS = dayTwoMS
	if _, err := repo.ExecuteLuckyGiftDraw(ctx, retain, dayTwoMS); err != nil {
		t.Fatalf("retain token across UTC day: %v", err)
	}
	assertPendingTokenCount(t, db, rule.PoolID, userID, 1)
	var retired int64
	if err := db.QueryRow(`SELECT pending_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, rule.PoolID).Scan(&retired); err != nil || retired != 0 {
		t.Fatalf("retired token was not lazily cleared: got=%d err=%v", retired, err)
	}
	if result, err := db.Exec(`UPDATE lucky_pools SET balance=1000000 WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, rule.PoolID); err != nil {
		t.Fatalf("refill token test pool: %v", err)
	} else if rows, _ := result.RowsAffected(); rows != 1 {
		t.Fatalf("refill token test pool affected=%d, want 1", rows)
	}
	// Freeze the current platform window as a valid <=98% sample. Until this close, the spend token
	// correctly remains blocked because an open/current RTP can never authorize a spend jackpot.
	if result, err := db.Exec(`
		UPDATE lucky_rtp_windows
		SET wager_coins=1000000, actual_base_payout_coins=980000, status='closed',
		    started_at_ms=?, last_paid_at_ms=?, closed_at_ms=?
		WHERE app_code='lalu' AND scope_type='pool' AND scope_id LIKE CONCAT(?, ':v%') AND status='open'`,
		dayOneMS, dayTwoMS, dayTwoMS, rule.PoolID); err != nil {
		t.Fatalf("close platform RTP sample: %v", err)
	} else if rows, _ := result.RowsAffected(); rows != 1 {
		t.Fatalf("closed platform RTP windows=%d want=1", rows)
	}
	consume := retain
	consume.CommandID = "token-consume-next-day"
	consume.PaidAtMS++
	result, err := repo.ExecuteLuckyGiftDraw(ctx, consume, dayTwoMS+1)
	if err != nil {
		t.Fatalf("consume retained token: %v", err)
	}
	if !result.HighMultiplier || result.EffectiveRewardCoins <= 0 {
		t.Fatalf("retained token did not produce payable jackpot: %+v", result)
	}
	assertPendingTokenCount(t, db, rule.PoolID, userID, 0)
}

// assertDynamicSpendBatchRollbackAndRestart exercises the production transaction rather than only
// the pure kernel: child 2 earns the token after selection and child 3 consumes it. A trigger fails
// the first attempt at the final fact insert, proving all earlier in-transaction state transitions
// roll back; rebuilding Repository and retrying the same command must then apply the sequence once.
func assertDynamicSpendBatchRollbackAndRestart(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) {
	t.Helper()
	rule := dynamicMySQLTestRule("dual-spend-batch")
	rule.JackpotSpendThresholdCoins = 20
	published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000)
	if err != nil {
		t.Fatalf("publish dual spend batch rule: %v", err)
	}
	fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-batch-credit", nowMS-1_900)
	seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500)

	command := domain.DrawCommand{
		CommandID: "dual-spend-batch-command", PoolID: published.PoolID, UserID: 4_040, TargetUserID: 1,
		DeviceID: "dual-spend-batch-device", RoomID: "dual-spend-batch-room", AnchorID: "dual-spend-batch-anchor", GiftID: "super-lucky",
		GiftCount: 3, CoinSpent: 30, HostPeriodDiamondAdded: 0, PaidAtMS: nowMS,
	}
	const rollbackTrigger = "trg_lucky_dual_spend_rollback"
	if _, err := db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger); err != nil {
		t.Fatalf("drop stale dual spend rollback trigger: %v", err)
	}
	t.Cleanup(func() { _, _ = db.Exec("DROP TRIGGER IF EXISTS " + rollbackTrigger) })
	if _, err := db.Exec(`
		CREATE TRIGGER trg_lucky_dual_spend_rollback
		BEFORE INSERT ON lucky_draw_records
		FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'forced dual spend rollback'`); err != nil {
		t.Fatalf("create dual spend rollback trigger: %v", err)
	}
	if _, err := repo.ExecuteLuckyGiftDraw(ctx, command, nowMS); err == nil {
		t.Fatal("forced persistence failure unexpectedly committed dual spend batch")
	}
	if _, err := db.Exec("DROP TRIGGER " + rollbackTrigger); err != nil {
		t.Fatalf("drop dual spend rollback trigger: %v", err)
	}

	var stateRows, drawRows, dayRows, balanceAfterRollback int64
	if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&stateRows); err != nil {
		t.Fatalf("query rolled-back spend state: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&drawRows); err != nil {
		t.Fatalf("query rolled-back spend draws: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=?`, published.PoolID, command.UserID).Scan(&dayRows); err != nil {
		t.Fatalf("query rolled-back spend day: %v", err)
	}
	if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterRollback); err != nil {
		t.Fatalf("query rolled-back spend pool: %v", err)
	}
	if stateRows != 0 || drawRows != 0 || dayRows != 0 || balanceAfterRollback != 1_000_000 {
		t.Fatalf("failed spend batch leaked state/draw/day/pool=%d/%d/%d/%d", stateRows, drawRows, dayRows, balanceAfterRollback)
	}

	restarted := &Repository{db: db}
	first, err := restarted.ExecuteLuckyGiftDraw(ctx, command, nowMS+1)
	if err != nil {
		t.Fatalf("retry dual spend batch after repository restart: %v", err)
	}
	assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 1, 3), 0, 0, 0, "")
	assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 2, 3), 0, 1, 1, "")
	assertSpendTraceSnapshot(t, db, "lucky_draw_records", "command_id", luckyDrawSubCommandID(command.CommandID, 3, 3), 1, 0, 0, domain.StrategyJackpotMechanismMilestone)

	var paidDraws, pendingTokens, daySpend, committedDraws, balanceBeforeReplay int64
	if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil {
		t.Fatalf("query committed dual spend state: %v", err)
	}
	if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&daySpend); err != nil {
		t.Fatalf("query committed dual spend day: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&committedDraws); err != nil {
		t.Fatalf("query committed dual spend draws: %v", err)
	}
	if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceBeforeReplay); err != nil {
		t.Fatalf("query committed dual spend pool: %v", err)
	}
	if paidDraws != 3 || pendingTokens != 0 || daySpend != 30 || committedDraws != 3 {
		t.Fatalf("committed spend batch state paid/token/day/draws=%d/%d/%d/%d", paidDraws, pendingTokens, daySpend, committedDraws)
	}

	replayed, err := (&Repository{db: db}).ExecuteLuckyGiftDraw(ctx, command, nowMS+2)
	if err != nil {
		t.Fatalf("replay committed dual spend batch: %v", err)
	}
	var replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay int64
	if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, command.UserID, published.PoolID).Scan(&replayPaidDraws, &replayPendingTokens); err != nil {
		t.Fatalf("query replayed dual spend state: %v", err)
	}
	if err := db.QueryRow(`SELECT spend_coins FROM lucky_user_strategy_days WHERE app_code='lalu' AND pool_id=? AND user_id=? AND stat_day=?`, published.PoolID, command.UserID, luckyPaidTime(command, nowMS).Format("2006-01-02")).Scan(&replayDaySpend); err != nil {
		t.Fatalf("query replayed dual spend day: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM lucky_draw_records WHERE app_code='lalu' AND command_id LIKE 'dual-spend-batch-command#%'`).Scan(&replayDraws); err != nil {
		t.Fatalf("query replayed dual spend draws: %v", err)
	}
	if err := db.QueryRow(`SELECT balance FROM lucky_pools WHERE app_code='lalu' AND scope_type=? AND scope_id=?`, luckyDynamicPoolScopeType, published.PoolID).Scan(&balanceAfterReplay); err != nil {
		t.Fatalf("query replayed dual spend pool: %v", err)
	}
	if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayPendingTokens != 0 || replayDaySpend != 30 || replayDraws != 3 || balanceAfterReplay != balanceBeforeReplay {
		t.Fatalf("replay duplicated spend batch: first/replay=%s/%s paid/token/day/draws/pool=%d/%d/%d/%d/%d want pool=%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayPendingTokens, replayDaySpend, replayDraws, balanceAfterReplay, balanceBeforeReplay)
	}
}

// assertExternalNewUserSpendBatch proves the external path needs neither recharge facts nor a
// 48-hour-old account: a one-hour-old authenticated user earns at child 2 and spends at child 3.
func assertExternalNewUserSpendBatch(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, nowMS int64) {
	t.Helper()
	rule := dynamicMySQLTestRule("dual-spend-external-new-user")
	rule.JackpotSpendThresholdCoins = 20
	published, err := repo.PublishLuckyGiftRuleConfig(ctx, rule, nowMS-2_000)
	if err != nil {
		t.Fatalf("publish external new-user spend rule: %v", err)
	}
	fundDynamicSpendTestPool(t, repo, ctx, published.PoolID, "dual-spend-external-credit", nowMS-1_900)
	seedClosedDynamicSpendGlobalWindow(t, db, published, nowMS-1_500)

	command := domain.ExternalDrawCommand{
		AppCode: "lalu", ExternalUserID: "dual-spend-new-external-user", DeviceID: "dual-spend-new-external-device",
		RequestID: "dual-spend-new-external-request", GiftCount: 3, UnitAmount: 10, TotalAmount: 30,
		Currency: "COIN", PaidAtMS: nowMS, UserRegisteredAtMS: nowMS - int64(time.Hour/time.Millisecond), PoolID: published.PoolID,
	}
	first, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS)
	if err != nil {
		t.Fatalf("execute external new-user spend batch: %v", err)
	}
	assertSpendTraceSnapshot(t, db, "external_lucky_gift_draw_items", "request_id", command.RequestID, 1, 0, 0, domain.StrategyJackpotMechanismMilestone)

	externalUserID := externalLuckyUserID(command.AppCode, command.ExternalUserID)
	var paidDraws, pendingTokens, itemCount, aggregateCount int64
	if err := db.QueryRow(`SELECT paid_draws, pending_spend_jackpot_tokens FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&paidDraws, &pendingTokens); err != nil {
		t.Fatalf("query external new-user spend state: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&itemCount); err != nil {
		t.Fatalf("query external new-user spend items: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draws WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&aggregateCount); err != nil {
		t.Fatalf("query external new-user spend aggregate: %v", err)
	}
	if first.RewardAmount <= 0 || paidDraws != 3 || pendingTokens != 0 || itemCount != 3 || aggregateCount != 1 {
		t.Fatalf("external new-user spend result reward/paid/token/items/aggregate=%d/%d/%d/%d/%d", first.RewardAmount, paidDraws, pendingTokens, itemCount, aggregateCount)
	}

	replayed, err := (&Repository{db: db}).ExecuteExternalGiftDraw(ctx, command, nowMS+1)
	if err != nil {
		t.Fatalf("replay external new-user spend batch: %v", err)
	}
	var replayPaidDraws, replayItems int64
	if err := db.QueryRow(`SELECT paid_draws FROM lucky_user_states WHERE app_code='lalu' AND user_id=? AND gift_id=?`, externalUserID, published.PoolID).Scan(&replayPaidDraws); err != nil {
		t.Fatalf("query replayed external new-user state: %v", err)
	}
	if err := db.QueryRow(`SELECT COUNT(*) FROM external_lucky_gift_draw_items WHERE app_code='lalu' AND request_id=?`, command.RequestID).Scan(&replayItems); err != nil {
		t.Fatalf("query replayed external new-user items: %v", err)
	}
	if replayed.DrawID != first.DrawID || replayPaidDraws != 3 || replayItems != 3 {
		t.Fatalf("external retry duplicated spend batch: first/replay=%s/%s paid/items=%d/%d", first.DrawID, replayed.DrawID, replayPaidDraws, replayItems)
	}
}

func fundDynamicSpendTestPool(t *testing.T, repo *Repository, ctx context.Context, poolID, adjustmentID string, nowMS int64) {
	t.Helper()
	result, err := repo.AdjustLuckyGiftPoolBalance(ctx, domain.PoolAdjustmentCommand{
		AppCode: "lalu", PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3,
		AdjustmentID: adjustmentID, Direction: domain.PoolAdjustmentIn, AmountCoins: 1_000_000,
		Reason: "dual spend integration fixture", OperatorAdminID: 1,
	}, nowMS)
	if err != nil {
		t.Fatalf("fund dynamic spend test pool: %v", err)
	}
	if result.Pool.Balance != 1_000_000 {
		t.Fatalf("funded dynamic spend pool balance=%d want=1000000", result.Pool.Balance)
	}
}

func seedClosedDynamicSpendGlobalWindow(t *testing.T, db *sql.DB, rule domain.RuleConfig, nowMS int64) {
	t.Helper()
	runtimeConfig, err := luckyRuntimeConfigFromRuleConfig(rule)
	if err != nil {
		t.Fatalf("build dynamic spend runtime config: %v", err)
	}
	scopeID := luckyRuleWindowScopeID(runtimeConfig)
	if _, err := db.Exec(`
		INSERT INTO lucky_rtp_windows (
		 app_code,scope_type,scope_id,window_index,target_rtp_ppm,control_window_draws,
		 paid_draws,wager_coins,target_payout_coins,actual_base_payout_coins,carry_ppm,status,
		 started_at_ms,last_paid_at_ms,closed_at_ms,created_at_ms,updated_at_ms
		) VALUES ('lalu','pool',?,1,980000,1000,100,1000,980,980,0,'closed',?,?,?,?,?)`,
		scopeID, nowMS-1_000, nowMS, nowMS, nowMS-1_000, nowMS,
	); err != nil {
		t.Fatalf("seed closed dynamic spend global window: %v", err)
	}
}

func assertSpendTraceSnapshot(t *testing.T, db *sql.DB, table, keyColumn, key string, wantBefore, wantAfter, wantEarned int64, wantMechanism string) {
	t.Helper()
	jsonColumn := "candidate_tiers_json"
	itemPredicate := ""
	if table == "external_lucky_gift_draw_items" {
		jsonColumn = "candidate_snapshot_json"
		itemPredicate = " AND item_index = 3"
	}
	query := fmt.Sprintf(`
		SELECT
		 CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_before') AS SIGNED),
		 CAST(JSON_EXTRACT(%s, '$.dynamic_v3.pending_spend_jackpot_tokens_after') AS SIGNED),
		 CAST(JSON_EXTRACT(%s, '$.dynamic_v3.spend_milestone_tokens_earned') AS SIGNED),
		 COALESCE(JSON_UNQUOTE(JSON_EXTRACT(%s, '$.dynamic_v3.jackpot_mechanism')), '')
		FROM %s WHERE app_code='lalu' AND %s=?%s`, jsonColumn, jsonColumn, jsonColumn, jsonColumn, table, keyColumn, itemPredicate)
	var before, after, earned int64
	var mechanism string
	if err := db.QueryRow(query, key).Scan(&before, &after, &earned, &mechanism); err != nil {
		t.Fatalf("query %s spend trace for %s: %v", table, key, err)
	}
	if before != wantBefore || after != wantAfter || earned != wantEarned || mechanism != wantMechanism {
		t.Fatalf("%s spend trace for %s before/after/earned/mechanism=%d/%d/%d/%q want=%d/%d/%d/%q", table, key, before, after, earned, mechanism, wantBefore, wantAfter, wantEarned, wantMechanism)
	}
}

func assertPendingTokenCount(t *testing.T, db *sql.DB, poolID string, userID, want int64) {
	t.Helper()
	var got int64
	if err := db.QueryRow(`
		SELECT pending_spend_jackpot_tokens FROM lucky_user_states
		WHERE app_code='lalu' AND user_id=? AND gift_id=?`, userID, poolID,
	).Scan(&got); err != nil {
		t.Fatalf("query pending token: %v", err)
	}
	if got != want {
		t.Fatalf("pending token=%d want=%d", got, want)
	}
}

func assertExactRolling72HourBoundary(t *testing.T, repo *Repository, db *sql.DB, ctx context.Context, poolID string, endMS int64) {
	t.Helper()
	const hourMS = int64(time.Hour / time.Millisecond)
	startMS := endMS - 72*hourMS
	fullStartMS := startMS + (hourMS - startMS%hourMS)
	fullEndMS := endMS - endMS%hourMS
	userID := int64(9090)
	if _, err := db.Exec(`
		INSERT INTO lucky_user_rtp_hour_buckets (
		 app_code,pool_id,user_id,bucket_hour_ms,wager_coins,payout_coins,created_at_ms,updated_at_ms
		) VALUES
		 ('lalu',?,?,?,10,1,1,1),
		 ('lalu',?,?,?,999,999,1,1)`,
		poolID, userID, fullStartMS,
		poolID, userID, fullEndMS,
	); err != nil {
		t.Fatalf("seed exact 72h buckets: %v", err)
	}
	events := []struct {
		id     string
		atMS   int64
		wager  int64
		payout int64
	}{
		{"before-start", startMS - 1, 100, 100},
		{"at-start", startMS, 2, 1},
		{"lower-end", fullStartMS - 1, 3, 1},
		{"inside-full-hour", fullStartMS, 100, 100},
		{"upper-start", fullEndMS, 4, 1},
		{"before-end", endMS - 1, 5, 1},
		{"at-end", endMS, 100, 100},
	}
	for _, event := range events {
		if _, err := db.Exec(`
			INSERT INTO lucky_user_rtp_boundary_events (
			 app_code,pool_id,user_id,draw_id,occurred_at_ms,wager_coins,payout_coins,created_at_ms
			) VALUES ('lalu',?,?,?,?,?,?,1)`,
			poolID, userID, event.id, event.atMS, event.wager, event.payout,
		); err != nil {
			t.Fatalf("seed exact 72h event %s: %v", event.id, err)
		}
	}
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		t.Fatalf("begin exact 72h query: %v", err)
	}
	defer func() { _ = tx.Rollback() }()
	rtp, err := repo.getLuckyUserRolling72HourRTP(ctx, tx, "lalu", poolID, userID, endMS)
	if err != nil {
		t.Fatalf("query exact 72h: %v", err)
	}
	// 完整小时只取 bucket=10/1；边界严格包含 start、排除 end，并且不重复计算 fullStart 事件。
	if rtp != (domain.StrategyRTP{WagerCoins: 24, PayoutCoins: 5}) {
		t.Fatalf("exact 72h RTP=%+v want wager=24 payout=5", rtp)
	}
}

func assertDynamicMySQLBatchFacts(t *testing.T, db queryRower, appCode, poolID string, userID int64, commandPrefix string, draws, spent, public, profit, anchor int64) {
	t.Helper()
	var count, spentSum, publicSum, profitSum, anchorSum, invalidUnits int64
	err := db.QueryRow(`
		SELECT COUNT(*), COALESCE(SUM(coin_spent),0),
		       COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED)),0),
		       COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED)),0),
		       COALESCE(SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)),0),
		       COALESCE(SUM(CASE WHEN coin_spent <>
		         CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.public_pool_coins')) AS SIGNED) +
		         CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.profit_pool_coins')) AS SIGNED) +
		         CAST(JSON_UNQUOTE(JSON_EXTRACT(pool_snapshot_json,'$.fund_split.anchor_return_coins')) AS SIGNED)
		       THEN 1 ELSE 0 END),0)
		FROM lucky_draw_records
		WHERE app_code=? AND pool_id=? AND command_id LIKE ?`, appCode, poolID, commandPrefix+"#%",
	).Scan(&count, &spentSum, &publicSum, &profitSum, &anchorSum, &invalidUnits)
	if err != nil {
		t.Fatalf("query dynamic batch facts: %v", err)
	}
	if count != draws || spentSum != spent || publicSum != public || profitSum != profit || anchorSum != anchor || invalidUnits != 0 {
		t.Fatalf("batch facts count/spent/split/invalid=%d/%d/%d/%d/%d/%d want=%d/%d/%d/%d/%d/0",
			count, spentSum, publicSum, profitSum, anchorSum, invalidUnits, draws, spent, public, profit, anchor)
	}
	var eventCount, eventWager int64
	if err := db.QueryRow(`
		SELECT COUNT(*), COALESCE(SUM(wager_coins),0) FROM lucky_user_rtp_boundary_events
		WHERE app_code=? AND pool_id=? AND user_id=?`, appCode, poolID, userID,
	).Scan(&eventCount, &eventWager); err != nil {
		t.Fatalf("query exact 72h events: %v", err)
	}
	if eventCount != draws || eventWager != spent {
		t.Fatalf("72h events count/wager=%d/%d want=%d/%d", eventCount, eventWager, draws, spent)
	}
}

type queryRower interface {
	QueryRow(query string, args ...any) *sql.Row
}

func dynamicMySQLTestRule(poolID string) domain.RuleConfig {
	stages := make([]domain.RuleStage, 0, 3)
	for index, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
		stage := domain.RuleStage{Stage: stageName}
		if index >= 1 {
			stage.MinRecharge30DCoins = 1
		}
		if index >= 2 {
			stage.MinRecharge7DCoins = 1
		}
		for _, tier := range []struct {
			id         string
			multiplier int64
			weight     int64
		}{{"0x", 0, 50_000}, {"0_5x", 500_000, 40_000}, {"1x", 1_000_000, 860_000}, {"2x", 2_000_000, 50_000}} {
			stage.Tiers = append(stage.Tiers, domain.RuleTier{
				Stage: stageName, TierID: fmt.Sprintf("%s_%s", stageName, tier.id),
				MultiplierPPM: tier.multiplier, BaseWeightPPM: tier.weight, Enabled: true,
			})
		}
		stages = append(stages, stage)
	}
	return domain.RuleConfig{
		PoolID: poolID, StrategyVersion: domain.StrategyDynamicV3, Enabled: true,
		TargetRTPPPM: 980_000, PoolRatePPM: 980_000, ProfitRatePPM: 10_000, AnchorRatePPM: 10_000,
		SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 1,
		InitialPoolCoins: 0, LossStreakGuarantee: 5,
		LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000,
		HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000,
		RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000,
		JackpotMultiplierPPMs:  []int64{200_000_000, 500_000_000, 1_000_000_000},
		JackpotGlobalRTPMaxPPM: 980_000, JackpotUserDayRTPMaxPPM: 960_000, JackpotUser72hRTPMaxPPM: 960_000,
		JackpotSpendThresholdCoins: 5_000, MaxJackpotHitsPerUserDay: 5,
		MaxSinglePayout: 1_000_000, UserHourlyPayoutCap: 2_000_000, UserDailyPayoutCap: 3_000_000,
		DeviceDailyPayoutCap: 4_000_000, RoomHourlyPayoutCap: 5_000_000, AnchorDailyPayoutCap: 6_000_000,
		Stages: stages,
	}
}

func dualSourceV5ShapeRule(poolID string) domain.RuleConfig {
	rule := dynamicMySQLTestRule(poolID)
	// 运行参数与测试服 lalu/lucky/v5 对齐；阶段基础表仍沿用轻量测试表，下面只把
	// v5 的真实重叠档追加进去，避免这个 repository 单测复制整份 35 档 fixture。
	rule.TargetRTPPPM, rule.PoolRatePPM, rule.ProfitRatePPM, rule.AnchorRatePPM = 890_000, 890_000, 10_000, 100_000
	rule.GiftPriceReference = 100
	rule.JackpotMultiplierPPMs = []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000}
	rule.JackpotGlobalRTPMaxPPM, rule.JackpotUserRoundRTPMaxPPM, rule.JackpotUser48hRTPMaxPPM = 980_000, 950_000, 950_000
	rule.MaxJackpotHitsPerUserDay = 10
	rule.MaxSinglePayout, rule.UserHourlyPayoutCap, rule.UserDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000
	rule.DeviceDailyPayoutCap, rule.RoomHourlyPayoutCap, rule.AnchorDailyPayoutCap = 4_000_000_000, 4_000_000_000, 4_000_000_000
	// 回归测试服 lalu/lucky v5 的真实重叠形状：normal 重叠 100/200x，advanced
	// 重叠 100/200/500/1000x。这里仅验证运行适配，不依赖停用规则的空奖池开奖。
	overlaps := map[string][]struct {
		multiplier int64
		weight     int64
	}{
		domain.StageNormal: {
			{multiplier: 100_000_000, weight: 810},
			{multiplier: 200_000_000, weight: 405},
		},
		domain.StageAdvanced: {
			{multiplier: 100_000_000, weight: 634},
			{multiplier: 200_000_000, weight: 317},
			{multiplier: 500_000_000, weight: 127},
			{multiplier: 1_000_000_000, weight: 64},
		},
	}
	for stageIndex := range rule.Stages {
		stage := &rule.Stages[stageIndex]
		var overlapWeight int64
		for _, overlap := range overlaps[stage.Stage] {
			overlapWeight += overlap.weight
		}
		for tierIndex := range stage.Tiers {
			if stage.Tiers[tierIndex].MultiplierPPM == 1_000_000 {
				stage.Tiers[tierIndex].BaseWeightPPM -= overlapWeight
			}
		}
		for _, overlap := range overlaps[stage.Stage] {
			stage.Tiers = append(stage.Tiers, domain.RuleTier{
				Stage: stage.Stage, TierID: fmt.Sprintf("%s_ordinary_%d", stage.Stage, overlap.multiplier),
				MultiplierPPM: overlap.multiplier, BaseWeightPPM: overlap.weight, HighWaterOnly: true, Enabled: true,
			})
		}
	}
	return rule
}

func TestLuckyDynamicStrategyConfigSeparatesOrdinaryAndJackpotSources(t *testing.T) {
	rule := dualSourceV5ShapeRule("dual-source")
	runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule)
	if err != nil {
		t.Fatal(err)
	}
	if runtimeConfig.TargetRTPPPM != 890_000 || strategy.DailyJackpotLimit != 10 {
		t.Fatalf("v5 runtime target/cap=%d/%d want=890000/10", runtimeConfig.TargetRTPPPM, strategy.DailyJackpotLimit)
	}
	if !strategy.JackpotMechanism2Enabled || strategy.MilestoneSpendCoins != rule.JackpotSpendThresholdCoins {
		t.Fatalf("runtime spend strategy enabled/threshold=%v/%d want=true/%d", strategy.JackpotMechanism2Enabled, strategy.MilestoneSpendCoins, rule.JackpotSpendThresholdCoins)
	}
	ordinary := make(map[int64]*domain.StrategyTier)
	jackpot := make(map[int64]*domain.StrategyTier)
	for index := range strategy.Tiers {
		tier := &strategy.Tiers[index]
		if tier.Jackpot {
			jackpot[tier.MultiplierPPM] = tier
		} else if tier.MultiplierPPM >= 100_000_000 {
			ordinary[tier.MultiplierPPM] = tier
		}
	}
	wantWeights := map[int64]map[string]int64{
		100_000_000:   {domain.StageNormal: 810, domain.StageAdvanced: 634},
		200_000_000:   {domain.StageNormal: 405, domain.StageAdvanced: 317},
		500_000_000:   {domain.StageAdvanced: 127},
		1_000_000_000: {domain.StageAdvanced: 64},
	}
	for multiplier, stageWeights := range wantWeights {
		ordinaryTier, jackpotTier := ordinary[multiplier], jackpot[multiplier]
		if ordinaryTier == nil || ordinaryTier.Jackpot || ordinaryTier.ID != fmt.Sprintf("multiplier_%d", multiplier) {
			t.Fatalf("ordinary tier multiplier=%d = %+v", multiplier, ordinaryTier)
		}
		if jackpotTier == nil || !jackpotTier.Jackpot || jackpotTier.ID != fmt.Sprintf("jackpot_%d", multiplier) || jackpotTier.JackpotWeight != 1 {
			t.Fatalf("jackpot tier multiplier=%d = %+v", multiplier, jackpotTier)
		}
		for _, stageName := range []string{domain.StageNovice, domain.StageNormal, domain.StageAdvanced} {
			if ordinaryTier.StageWeightPPM[stageName] != stageWeights[stageName] || jackpotTier.StageWeightPPM[stageName] != 0 {
				t.Fatalf("multiplier=%d stage=%s ordinary/jackpot weights=%d/%d want=%d/0", multiplier, stageName, ordinaryTier.StageWeightPPM[stageName], jackpotTier.StageWeightPPM[stageName], stageWeights[stageName])
			}
		}
	}
}

func TestLuckyDynamicDualSourcesSharePoolRTPRiskAndAuditAccounting(t *testing.T) {
	rule := dualSourceV5ShapeRule("dual-source-accounting")
	runtimeConfig, strategy, err := BuildLuckyDynamicStrategyForRule(rule)
	if err != nil {
		t.Fatal(err)
	}
	const (
		poolBefore = int64(15_000_000)
		paidAtMS   = int64(1_784_000_000_000)
	)
	fullRisk := domain.StrategyRiskCapacity{
		Enabled: true, SingleDrawCoins: 4_000_000_000, UserHourCoins: 4_000_000_000, UserDayCoins: 4_000_000_000,
		DeviceDayCoins: 4_000_000_000, RoomHourCoins: 4_000_000_000, AnchorDayCoins: 4_000_000_000,
	}
	input := domain.StrategyInput{
		GiftPriceCoins: runtimeConfig.GiftPrice, PoolContributionCoins: 89,
		Recharge30DCoins: 1, RiskCapacity: fullRisk,
	}
	ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore}
	_, preview, err := domain.PreviewLuckyGiftStrategyWeights(strategy, ordinaryState, input)
	if err != nil {
		t.Fatal(err)
	}
	ordinaryIndex, ordinaryBound, ordinaryWeight := int64(0), int64(0), int64(0)
	for _, item := range preview {
		if item.TierID == "multiplier_100000000" {
			ordinaryIndex, ordinaryWeight = ordinaryBound, item.AdjustedWeightPPM
		}
		ordinaryBound += item.AdjustedWeightPPM
	}
	if ordinaryWeight <= 0 {
		t.Fatal("normal ordinary 100x is not selectable")
	}
	ordinaryRandomCalls := 0
	ordinary, err := domain.DecideLuckyGiftStrategy(strategy, ordinaryState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) {
		ordinaryRandomCalls++
		if bound != ordinaryBound {
			t.Fatalf("ordinary random bound=%d want=%d", bound, ordinaryBound)
		}
		return ordinaryIndex, nil
	}))
	if err != nil {
		t.Fatal(err)
	}

	const closedAtMS = int64(100 * 60 * 60 * 1000)
	jackpotState := domain.StrategyState{
		PoolBalanceCoins: poolBefore, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS,
		UserRegisteredAtMS: closedAtMS - 49*60*60*1000,
		GlobalRTP:          domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900},
		UserRoundRTP:       domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400},
		User48HourRTP:      domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400},
	}
	jackpotRandomCalls := 0
	jackpot, err := domain.DecideLuckyGiftStrategy(strategy, jackpotState, input, domain.StrategyRandomFunc(func(bound int64) (int64, error) {
		jackpotRandomCalls++
		if bound != 4 {
			t.Fatalf("jackpot random bound=%d want=4", bound)
		}
		return 0, nil
	}))
	if err != nil {
		t.Fatal(err)
	}
	if ordinaryRandomCalls != 1 || jackpotRandomCalls != 1 || ordinary.SelectedTier.ID != "multiplier_100000000" || ordinary.Jackpot ||
		jackpot.SelectedTier.ID != "jackpot_100000000" || !jackpot.Jackpot || ordinary.PayoutCoins != jackpot.PayoutCoins {
		t.Fatalf("ordinary/jackpot decisions not source-isolated: ordinary=%+v jackpot=%+v calls=%d/%d", ordinary, jackpot, ordinaryRandomCalls, jackpotRandomCalls)
	}

	// stageLuckyDynamicDecision is the production transaction accumulator. Running both
	// sources through it proves source identity changes only jackpot_hits/audit weight;
	// public pool, every RTP numerator and all six risk counters still receive the same payout.
	assertStaged := func(label string, before domain.StrategyState, decision domain.StrategyDecision, wantJackpotHits, wantWeight int64) {
		t.Helper()
		riskCounters := make(map[string]luckyDynamicRiskCounter, 6)
		for _, key := range []string{"single", "user_hour", "user_day", "device_day", "room_hour", "anchor_day"} {
			riskCounters[key] = luckyDynamicRiskCounter{ScopeType: key}
		}
		state := luckyDynamicBatchState{
			BasePool:     luckyPool{Balance: before.PoolBalanceCoins},
			GlobalWindow: luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"},
			GiftWindow:   luckyRTPWindow{TargetRTPPPM: runtimeConfig.TargetRTPPPM, Status: "open"},
			UserWindow:   luckyUserRTPWindow{PoolID: runtimeConfig.PoolID, RuleVersion: runtimeConfig.RuleVersion},
			RiskCounters: riskCounters,
			Strategy:     before,
		}
		command := domain.DrawCommand{
			CommandID: label, PoolID: runtimeConfig.PoolID, GiftID: runtimeConfig.GiftID, GiftCount: 1,
			UserID: 1001, TargetUserID: 2001, RoomID: "room-v5", AnchorID: "anchor-v5",
			CoinSpent: 100, HostPeriodDiamondAdded: 10, PaidAtMS: paidAtMS,
		}
		split := domain.StrategyFundSplit{PublicPoolCoins: 89, ProfitPoolCoins: 1, AnchorReturnCoins: 10}
		if err := (&Repository{}).stageLuckyDynamicDecision("lalu", runtimeConfig, runtimeConfig.GiftPrice, command, split, decision, paidAtMS, &state); err != nil {
			t.Fatalf("%s stage decision: %v", label, err)
		}
		if state.PublicIn != 89 || state.ProfitIn != 1 || state.AnchorIn != 10 || state.Payout != decision.PayoutCoins ||
			state.BasePool.Balance != decision.PoolAfterCoins || state.BasePool.TotalIn != 89 || state.BasePool.TotalOut != decision.PayoutCoins ||
			state.GlobalWindow.WagerCoins != 100 || state.GlobalWindow.ActualPayoutCoins != decision.PayoutCoins ||
			state.GiftWindow.WagerCoins != 100 || state.GiftWindow.ActualPayoutCoins != decision.PayoutCoins ||
			state.UserWindow.WagerCoins != 100 || state.UserWindow.PayoutCoins != decision.PayoutCoins ||
			state.DayState.WagerCoins != 100 || state.DayState.PayoutCoins != decision.PayoutCoins || state.DayState.JackpotHits != wantJackpotHits ||
			state.HourState.WagerCoins != 100 || state.HourState.PayoutCoins != decision.PayoutCoins {
			t.Fatalf("%s shared accounting mismatch: %+v", label, state)
		}
		for key, counter := range state.RiskCounters {
			if counter.Payout != decision.PayoutCoins {
				t.Fatalf("%s risk %s payout=%d want=%d", label, key, counter.Payout, decision.PayoutCoins)
			}
		}
		if len(state.Records) != 1 || state.Records[0].Candidate.TierID != decision.SelectedTier.ID || state.Records[0].Candidate.Weight != wantWeight ||
			state.Records[0].StrategyDecision == nil || state.Records[0].StrategyDecision.Jackpot != decision.Jackpot {
			t.Fatalf("%s audit record mismatch: %+v", label, state.Records)
		}
	}
	assertStaged("ordinary-v5-100x", ordinaryState, ordinary, 0, ordinaryWeight)
	assertStaged("jackpot-v5-100x", jackpotState, jackpot, 1, 1)
}

func TestLuckyDynamicSelectedWeightUsesWinningSource(t *testing.T) {
	ordinary := domain.StrategyDecision{
		SelectedTier: domain.StrategyTier{ID: "multiplier_200000000", MultiplierPPM: 200_000_000},
		Trace:        domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "multiplier_200000000", AdjustedWeightPPM: 1_000}}},
	}
	if got := luckyDynamicSelectedWeight(ordinary); got != 1_000 {
		t.Fatalf("ordinary audit weight=%d want=1000", got)
	}
	jackpot := domain.StrategyDecision{
		SelectedTier: domain.StrategyTier{ID: "jackpot_200000000", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 1},
		Jackpot:      true,
		// 普通权重预览会保留 jackpot tier 的零权重事实；大奖审计必须使用独立选择权重 1。
		Trace: domain.DecisionTrace{Weights: []domain.StrategyWeightTrace{{TierID: "jackpot_200000000", AdjustedWeightPPM: 0}}},
	}
	if got := luckyDynamicSelectedWeight(jackpot); got != 1 {
		t.Fatalf("jackpot audit weight=%d want=1", got)
	}
}
