package main

import (
	"fmt"
	"math"
	"sort"
	"sync"

	domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
	mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql"
)

type scriptedRandom struct {
	indexes  []int64
	bounds   []int64
	position int
}

// imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights
// have 26.5x EV and therefore must never be exposed as the domain's runtime default.
func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
	// 模拟显式给出金币分层边界，避免把生产 disabled 草稿的 0/0 sentinel 当成可运行默认值。
	config.RechargeStages = []domain.StrategyRechargeStage{
		{Name: domain.StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
		{Name: domain.StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
		{Name: domain.StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
	}
	config.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
		{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
		{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
		{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true},
		{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true},
		{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true},
		{ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
		{ID: "jackpot_500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true},
		{ID: "jackpot_1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
	}
	// Existing scenarios isolate their original product axis. The dedicated 24h scenario below
	// enables the new control explicitly so a high payout in one legacy scenario cannot silently
	// change every subsequent expected probability.
	config.User24HourRTPThresholdPPM = 0
	config.User24HourOrdinaryWinFactorPPM = domain.StrategyPPMScale
	return config
}

func (r *scriptedRandom) Int63n(n int64) (int64, error) {
	if r.position >= len(r.indexes) {
		return 0, fmt.Errorf("scripted random exhausted at bound %d", n)
	}
	if len(r.bounds) > r.position && r.bounds[r.position] != n {
		return 0, fmt.Errorf("scripted random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
	}
	value := r.indexes[r.position]
	r.position++
	return value, nil
}

// baseProbabilityConfig isolates the image's ordinary probability table. Each
// dynamic/special rule has a separate scenario, so scripted intervals remain exact.
func baseProbabilityConfig(config domain.StrategyConfig) domain.StrategyConfig {
	config.LowWaterThresholdCoins = 0
	config.HighWaterThresholdCoins = math.MaxInt64
	config.LowWaterFactorPPM = domain.StrategyPPMScale
	config.HighWaterFactorPPM = domain.StrategyPPMScale
	config.RechargeFactorPPM = domain.StrategyPPMScale
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	config.DailyJackpotLimit = math.MaxInt64
	config.MilestoneSpendCoins = 0
	return config
}

func failedScenario(name string, err error) scenarioResult {
	return scenarioResult{Name: name, Passed: false, Summary: err.Error()}
}

func simulateImage800Redraw(config domain.StrategyConfig) scenarioResult {
	const name = "图示800重抽序列"
	config = baseProbabilityConfig(config)
	random := &scriptedRandom{indexes: []int64{950_000, 359_999, 0}, bounds: []int64{1_000_000, 360_000, 350_000}}
	decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 800}, domain.StrategyInput{GiftPriceCoins: 10}, random)
	if err != nil {
		return failedScenario(name, err)
	}
	sequence := make([]string, 0, len(decision.Trace.Draws))
	for _, draw := range decision.Trace.Draws {
		sequence = append(sequence, draw.TierID)
	}
	passed := decision.PayoutCoins == 50 && decision.PoolAfterCoins == 750 && fmt.Sprint(sequence) == "[200x 1000x 5x]"
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("P=800，固定脚本命中%s，最终赔付=%d，池后=%d", sequence, decision.PayoutCoins, decision.PoolAfterCoins),
		Data:    map[string]any{"sequence": sequence, "trace": decision.Trace},
	}
}

func simulatePWBoundaries(config domain.StrategyConfig) scenarioResult {
	const name = "P=W边界/候选穷尽"
	config = baseProbabilityConfig(config)
	equal, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 2_000}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	exhausted, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{999_999, 350_000, 349_999, 0}, bounds: []int64{1_000_000, 390_000, 350_000, 200_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	passed := equal.PayoutCoins == 2_000 && equal.PoolAfterCoins == 0 && exhausted.PayoutCoins == 0 && exhausted.Trace.FinalReason == domain.StrategyReasonNoPayableTier && exhausted.PoolAfterCoins == 49
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("W=P赔付%d且池归零；P=49时正奖候选穷尽安全回0，池仍%d", equal.PayoutCoins, exhausted.PoolAfterCoins),
		Data:    map[string]any{"equality": equal.Trace, "exhaustion": exhausted.Trace},
	}
}

func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult {
	const name = "拆账与冷启动"
	split, err := domain.SplitLuckyGiftStrategyFunds(100, config)
	if err != nil {
		return failedScenario(name, err)
	}
	initial := domain.InitialLuckyGiftStrategyState(config)
	small, err := domain.SplitLuckyGiftStrategyFunds(10, config)
	if err != nil {
		return failedScenario(name, err)
	}
	conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100
	passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 0
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("100金币拆为%d/%d/%d；10金币按公共池floor、余数归盈利拆为%d/%d/%d；动态池冷启动=%d，启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins),
		Data: map[string]any{
			"split_100": split, "split_10_rounding": small,
			"rounding_policy": "public=floor, anchor=floor, residue=profit",
			"conserved":       conserved, "cold_start_state": initial,
		},
	}
}

type batchSummary struct {
	Requested   int            `json:"requested"`
	Executed    int            `json:"executed"`
	Positive    int            `json:"positive"`
	PayoutCoins int64          `json:"payout_coins"`
	PoolAfter   int64          `json:"pool_after"`
	TierCounts  map[string]int `json:"tier_counts"`
}

func simulateBatchCounts(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "1/5/6/99批量"
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	config.MilestoneSpendCoins = 0
	config.DailyJackpotLimit = math.MaxInt64
	counts := []int{1, 5, 6, 99}
	results := make([]batchSummary, 0, len(counts))
	passed := true
	for _, count := range counts {
		state := domain.StrategyState{}
		random := domain.NewSeededStrategyRandom(seed + int64(count))
		summary := batchSummary{Requested: count, TierCounts: map[string]int{}}
		for index := 0; index < count; index++ {
			decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
			if err != nil {
				return failedScenario(name, err)
			}
			state = decision.NextState
			summary.Executed++
			summary.TierCounts[decision.SelectedTier.ID]++
			summary.PayoutCoins += decision.PayoutCoins
			if decision.PayoutCoins > 0 {
				summary.Positive++
			}
			passed = passed && decision.PoolAfterCoins >= 0
		}
		summary.PoolAfter = state.PoolBalanceCoins
		passed = passed && summary.Executed == count
		results = append(results, summary)
	}
	return scenarioResult{Name: name, Passed: passed, Summary: "每份礼物独立推进一次状态，1/5/6/99均完整执行且任一中间池余额不负", Data: results}
}

func simulateSettlementWagerWindow() scenarioResult {
	const name = "真实流水结算窗口"
	const (
		settlementWager = int64(99)
		referencePrice  = int64(10)
		totalSpend      = int64(100)
		drawCount       = int64(99)
	)
	type window struct {
		Index int64 `json:"index"`
		Paid  int64 `json:"paid_draws"`
		Wager int64 `json:"wager_coins"`
	}
	// 10 抽但只有 98 流水是专门构造的反例：ceil(99/10)=10 的旧抽数算法会提前关窗，真实流水算法仍允许下一抽进入窗口 1。
	windows := []window{{Index: 1, Paid: 10, Wager: 98}}
	drawWindows := make([]int64, 0, drawCount)
	for index := int64(1); index <= drawCount; index++ {
		current := &windows[len(windows)-1]
		if current.Wager >= settlementWager {
			windows = append(windows, window{Index: current.Index + 1})
			current = &windows[len(windows)-1]
		}
		unitSpend := totalSpend / drawCount
		if index <= totalSpend%drawCount {
			unitSpend++
		}
		current.Paid++
		current.Wager += unitSpend
		drawWindows = append(drawWindows, current.Index)
	}
	firstCount, secondCount := 0, 0
	for _, index := range drawWindows {
		if index == 1 {
			firstCount++
		} else if index == 2 {
			secondCount++
		}
	}
	passed := len(windows) == 2 && windows[0].Paid == 11 && windows[0].Wager == 100 && windows[1].Paid == 98 && windows[1].Wager == 98 && firstCount == 1 && secondCount == 98
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("阈值99、参考价10、预置10抽/98流水；99份共100金币后窗口流水=%d/%d，子抽归属=%d/%d", windows[0].Wager, windows[1].Wager, firstCount, secondCount),
		Data: map[string]any{
			"settlement_window_wager": settlementWager,
			"reference_price":         referencePrice, "legacy_derived_draw_threshold": int64(math.Ceil(float64(settlementWager) / float64(referencePrice))),
			"windows": windows, "draws_in_window_1": firstCount, "draws_in_window_2": secondCount,
		},
	}
}

func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult {
	const name = "低中高水位边界"
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	type waterPoint struct {
		Pool       int64    `json:"pool"`
		FiveWeight int64    `json:"five_x_weight_ppm"`
		Factors    []string `json:"factors"`
	}
	points := make([]waterPoint, 0, 4)
	for _, pool := range []int64{9_999_999, 10_000_000, 20_000_000, 20_000_001} {
		_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: pool}, domain.StrategyInput{GiftPriceCoins: 100})
		if err != nil {
			return failedScenario(name, err)
		}
		trace := strategyWeightTrace(traces, "5x")
		factors := make([]string, 0, len(trace.Factors))
		for _, factor := range trace.Factors {
			factors = append(factors, factor.Name)
		}
		points = append(points, waterPoint{Pool: pool, FiveWeight: trace.AdjustedWeightPPM, Factors: factors})
	}
	passed := points[0].FiveWeight == 140_000 && points[1].FiveWeight == 200_000 && points[2].FiveWeight == 200_000 && points[3].FiveWeight == 260_000
	return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7；等于1000万/2000万均为中水位；>2000万应用1.3", Data: points}
}

func simulateUser24HourRTPDownweight(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "用户滚动24小时RTP普通奖降权"
	config = baseProbabilityConfig(config)
	config.User24HourRTPThresholdPPM = 1_500_000
	config.User24HourOrdinaryWinFactorPPM = 700_000
	config.MissProtectionZeroDraws = 5

	type boundaryRow struct {
		Case           string `json:"case"`
		WagerCoins     int64  `json:"wager_coins"`
		PayoutCoins    int64  `json:"payout_coins"`
		PositiveWeight int64  `json:"positive_weight_ppm"`
		ZeroWeight     int64  `json:"zero_weight_ppm"`
		Active         bool   `json:"active"`
	}
	rows := make([]boundaryRow, 0, 3)
	for _, item := range []struct {
		name          string
		wager, payout int64
		wantPositive  int64
		wantActive    bool
	}{
		{name: "149.999%", wager: 100_000, payout: 149_999, wantPositive: 400_000},
		{name: "150%", wager: 100_000, payout: 150_000, wantPositive: 400_000},
		{name: "150.001%", wager: 100_000, payout: 150_001, wantPositive: 280_000, wantActive: true},
	} {
		state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000, User24HourRTP: domain.StrategyRTP{WagerCoins: item.wager, PayoutCoins: item.payout}}
		_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, state, domain.StrategyInput{GiftPriceCoins: 10})
		if err != nil {
			return failedScenario(name, err)
		}
		zero := strategyWeightTrace(traces, "0x").AdjustedWeightPPM
		positive := domain.StrategyPPMScale - zero
		active := strategyTraceHasFactor(traces, domain.StrategyConditionUser24HourRTPDownweight)
		rows = append(rows, boundaryRow{Case: item.name, WagerCoins: item.wager, PayoutCoins: item.payout, PositiveWeight: positive, ZeroWeight: zero, Active: active})
		if positive != item.wantPositive || active != item.wantActive {
			return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("%s 边界权重错误: positive=%d active=%v", item.name, positive, active), Data: rows}
		}
	}

	// Force high-water+recharge raw positive weight above 100%. The ordinary table must normalize to
	// 100% first and only then retain 70%, yielding an exact 700k/300k split.
	combined := config
	combined.HighWaterThresholdCoins = 100
	combined.HighWaterFactorPPM = 3_000_000
	combined.RechargeFactorPPM = 2_000_000
	combinedState := domain.StrategyState{PoolBalanceCoins: 101, User24HourRTP: domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}}
	_, combinedTraces, err := domain.PreviewLuckyGiftStrategyWeights(combined, combinedState, domain.StrategyInput{
		GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, LastRechargeAtMS: 1_000_000,
	})
	if err != nil {
		return failedScenario(name, err)
	}
	combinedZero := strategyWeightTrace(combinedTraces, "0x").AdjustedWeightPPM

	// At the configured miss threshold, protection restores the pre-downweight ordinary table before
	// removing 0x. This also covers tiny retained factors whose integer apportionment rounded an
	// otherwise payable tier to zero: protection remains strictly stronger than personal downweight.
	protectedState := combinedState
	protectedState.PoolBalanceCoins = 1_000_000_000
	protectedState.ConsecutiveZeroDraws = config.MissProtectionZeroDraws
	protected, err := domain.DecideLuckyGiftStrategy(config, protectedState, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{0}, bounds: []int64{400_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	// A 1-PPM retained factor leaves exactly one integer weight unit. Largest-remainder assigns that
	// unit to the unaffordable 1000x tier and rounds the payable 1x tier to zero. Miss protection must
	// rebuild the pre-downweight 1/999999 table, where scripted point zero selects the payable 1x.
	roundingConfig := config
	roundingConfig.User24HourOrdinaryWinFactorPPM = 1
	roundingConfig.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
		{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 1, Enabled: true},
		{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 999_999, Enabled: true},
	}
	roundingState := domain.StrategyState{
		PoolBalanceCoins:     10,
		ConsecutiveZeroDraws: roundingConfig.MissProtectionZeroDraws,
		User24HourRTP:        domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001},
	}
	roundingProtected, err := domain.DecideLuckyGiftStrategy(
		roundingConfig, roundingState, domain.StrategyInput{GiftPriceCoins: 10},
		&scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}},
	)
	if err != nil {
		return failedScenario(name, err)
	}

	// The condition is visible in both jackpot traces but must not gate or rescale either independent
	// jackpot source. High personal 24h RTP therefore still permits an otherwise-qualified jackpot.
	compensationConfig := settledRoundJackpotConfig(config)
	compensationState := qualifiedSettledRoundState(1_000_000_000, 100*60*60*1000)
	compensationState.User24HourRTP = domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001}
	compensation, err := domain.DecideLuckyGiftStrategy(compensationConfig, compensationState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed))
	if err != nil {
		return failedScenario(name, err)
	}
	milestoneConfig := compensationConfig
	milestoneConfig.JackpotMechanism1Enabled = false
	milestoneConfig.JackpotMechanism2Enabled = true
	milestoneConfig.MilestoneSpendCoins = 50
	milestoneState := domain.StrategyState{
		PoolBalanceCoins: 1_000_000_000, PendingSpendJackpotTokens: 1,
		SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800},
		User24HourRTP:  domain.StrategyRTP{WagerCoins: 100_000, PayoutCoins: 150_001},
	}
	milestone, err := domain.DecideLuckyGiftStrategy(milestoneConfig, milestoneState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1))
	if err != nil {
		return failedScenario(name, err)
	}

	passed := combinedZero == 300_000 && protected.PayoutCoins > 0 &&
		roundingProtected.SelectedTier.ID == "1x" && roundingProtected.PayoutCoins == 10 &&
		compensation.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation &&
		milestone.JackpotMechanism == domain.StrategyJackpotMechanismMilestone
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("149.999%%/150%%正常、150.001%%降为70%%；叠加归一后正奖/0x=%d/%d；保底=%s且1PPM舍入仍命中%s；两类大奖=%s/%s",
			domain.StrategyPPMScale-combinedZero, combinedZero, protected.SelectedTier.ID, roundingProtected.SelectedTier.ID, compensation.JackpotMechanism, milestone.JackpotMechanism),
		Data: map[string]any{
			"boundaries": rows, "combined_weight_trace": combinedTraces,
			"miss_protection": protected.Trace, "rounding_miss_protection": roundingProtected.Trace,
			"rtp_compensation": compensation.Trace, "spend_milestone": milestone.Trace,
		},
	}
}

func strategyTraceHasFactor(traces []domain.StrategyWeightTrace, name string) bool {
	for _, trace := range traces {
		for _, factor := range trace.Factors {
			if factor.Name == name {
				return true
			}
		}
	}
	return false
}

func simulateUser24HourRTPBatchCrossing(config domain.StrategyConfig) scenarioResult {
	const name = "用户24小时RTP批内跨线"
	config = baseProbabilityConfig(config)
	config.User24HourRTPThresholdPPM = 1_500_000
	config.User24HourOrdinaryWinFactorPPM = 700_000
	config.MissProtectionZeroDraws = math.MaxInt64
	config.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
		{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 200_000, Enabled: true},
		{ID: "2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 200_000, Enabled: true},
	}
	state := domain.StrategyState{
		PoolBalanceCoins: 1_000_000,
		User24HourRTP:    domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 140},
	}
	// 800000 is the first point of 2x in the normal 600k/200k/200k table. The 200-coin payout
	// changes the rolling snapshot from 140/100 to 340/200 only after this selection.
	first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100}, &scriptedRandom{indexes: []int64{800_000}, bounds: []int64{1_000_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	// The next subdraw now sees 170% and its ordinary mass is 280k. Random point zero proves the
	// enlarged 720k zero tier is live, while the first draw's own probability remained untouched.
	second, err := domain.DecideLuckyGiftStrategy(config, first.NextState, domain.StrategyInput{GiftPriceCoins: 100}, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	firstCondition := strategyConditionTrace(first.Trace, domain.StrategyConditionUser24HourRTPDownweight)
	secondCondition := strategyConditionTrace(second.Trace, domain.StrategyConditionUser24HourRTPDownweight)
	secondZeroWeight := strategyWeightTrace(second.Trace.Weights, "0x").AdjustedWeightPPM
	passed := first.SelectedTier.ID == "2x" && !firstCondition.Passed &&
		first.NextState.User24HourRTP.WagerCoins == 200 && first.NextState.User24HourRTP.PayoutCoins == 340 &&
		secondCondition.Passed && secondZeroWeight == 720_000 && second.SelectedTier.ID == "0x"
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("第一抽用140%%正常表命中%s，抽后变170%%；第二抽正奖/0x=280000/%d并命中%s", first.SelectedTier.ID, secondZeroWeight, second.SelectedTier.ID),
		Data:    map[string]any{"first": first, "second": second},
	}
}

func simulateUser24HourRTPMillionDraws(config domain.StrategyConfig, seed int64) scenarioResult {
	const (
		name  = "用户24小时RTP百万抽概率"
		draws = 1_000_000
	)
	config = baseProbabilityConfig(config)
	config.User24HourRTPThresholdPPM = 1_500_000
	config.User24HourOrdinaryWinFactorPPM = 700_000
	config.MissProtectionZeroDraws = math.MaxInt64
	config.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
		{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 400_000, Enabled: true},
	}
	type group struct {
		Name                  string  `json:"name"`
		ExpectedWinPercent    float64 `json:"expected_win_percent"`
		ObservedWinPercent    float64 `json:"observed_win_percent"`
		AbsoluteErrorPoints   float64 `json:"absolute_error_percentage_points"`
		PositiveDraws         int     `json:"positive_draws"`
		FinalPoolBalanceCoins int64   `json:"final_pool_balance_coins"`
	}
	run := func(groupName string, initial domain.StrategyRTP, expected float64, offset int64) (group, error) {
		state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000, User24HourRTP: initial}
		random := domain.NewSeededStrategyRandom(seed + offset)
		positive := 0
		for index := 0; index < draws; index++ {
			decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100}, random)
			if err != nil {
				return group{}, err
			}
			state = decision.NextState
			if decision.PayoutCoins > 0 {
				positive++
			}
		}
		observed := float64(positive) / draws * 100
		return group{
			Name: groupName, ExpectedWinPercent: expected, ObservedWinPercent: observed,
			AbsoluteErrorPoints: math.Abs(observed - expected), PositiveDraws: positive,
			FinalPoolBalanceCoins: state.PoolBalanceCoins,
		}, nil
	}
	// Very large historical denominators keep each group on its intended side of 150% while the
	// million current draws still advance the exact StrategyState on every iteration.
	normal, err := run("normal_100_percent_rtp", domain.StrategyRTP{WagerCoins: 1_000_000_000_000_000, PayoutCoins: 1_000_000_000_000_000}, 40, 0)
	if err != nil {
		return failedScenario(name, err)
	}
	downweighted, err := run("above_150_percent_rtp", domain.StrategyRTP{WagerCoins: 1_000_000_000_000_000, PayoutCoins: 1_500_010_000_000_000}, 28, 1)
	if err != nil {
		return failedScenario(name, err)
	}
	passed := normal.AbsoluteErrorPoints <= 0.2 && downweighted.AbsoluteErrorPoints <= 0.2 &&
		normal.FinalPoolBalanceCoins >= 0 && downweighted.FinalPoolBalanceCoins >= 0
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("固定种子各%d抽：正常 %.4f%%(目标40%%)，超线 %.4f%%(目标28%%)，误差均<=0.2个百分点", draws, normal.ObservedWinPercent, downweighted.ObservedWinPercent),
		Data:    []group{normal, downweighted},
	}
}

func strategyConditionTrace(trace domain.DecisionTrace, name string) domain.StrategyConditionTrace {
	for _, condition := range trace.Conditions {
		if condition.Name == name {
			return condition
		}
	}
	return domain.StrategyConditionTrace{}
}

func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
	const name = "充值0/299999/300000ms"
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	const now = int64(1_000_000)
	type rechargePoint struct {
		AgeMS      int64 `json:"age_ms"`
		FiveWeight int64 `json:"five_x_weight_ppm"`
		Boosted    bool  `json:"boosted"`
	}
	points := make([]rechargePoint, 0, 3)
	for _, age := range []int64{0, 299_999, 300_000} {
		input := domain.StrategyInput{GiftPriceCoins: 100, NowMS: now, HasRechargeFact: true, LastRechargeAtMS: now - age}
		_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: 15_000_000}, input)
		if err != nil {
			return failedScenario(name, err)
		}
		trace := strategyWeightTrace(traces, "5x")
		boosted := false
		for _, factor := range trace.Factors {
			boosted = boosted || factor.Name == "recent_recharge"
		}
		points = append(points, rechargePoint{AgeMS: age, FiveWeight: trace.AdjustedWeightPPM, Boosted: boosted})
	}
	passed := points[0].Boosted && points[1].Boosted && !points[2].Boosted && points[0].FiveWeight == 220_000 && points[2].FiveWeight == 200_000
	return scenarioResult{Name: name, Passed: passed, Summary: "充值加成严格使用[0,300000ms)：0和299999生效，300000失效", Data: points}
}

func simulateProbabilityMonteCarlo(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
	const name = "概率Monte Carlo"
	config = baseProbabilityConfig(config)
	config.MissProtectionZeroDraws = math.MaxInt64
	state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000_000}
	random := domain.NewSeededStrategyRandom(seed)
	counts := map[string]int{}
	var multiplierSum float64
	for index := 0; index < draws; index++ {
		decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, random)
		if err != nil {
			return failedScenario(name, err)
		}
		state = decision.NextState
		counts[decision.SelectedTier.ID]++
		multiplierSum += float64(decision.SelectedTier.MultiplierPPM) / float64(domain.StrategyPPMScale)
	}
	expected := map[string]float64{"0x": 0.60, "5x": 0.20, "50x": 0.15, "200x": 0.04, "500x": 0, "1000x": 0.01}
	empirical := map[string]float64{}
	passed := true
	for tier, probability := range expected {
		empirical[tier] = float64(counts[tier]) / float64(draws)
		// Five standard deviations makes the assertion scale with -draws instead
		// of baking a tolerance that is too strict for a quick local smoke run.
		tolerance := 5*math.Sqrt(probability*(1-probability)/float64(draws)) + 1/float64(draws)
		passed = passed && math.Abs(empirical[tier]-probability) <= tolerance
	}
	empiricalEV := multiplierSum / float64(draws)
	const multiplierVariance = 11_277.75 // E[X^2]=11980, E[X]^2=702.25.
	evTolerance := 5 * math.Sqrt(multiplierVariance/float64(draws))
	passed = passed && math.Abs(empiricalEV-26.5) <= evTolerance
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("%d抽经验EV=%.4fx（只验证图示概率采样，不声称通过98%%资金RTP）", draws, empiricalEV),
		Data:    map[string]any{"counts": counts, "expected_probability": expected, "empirical_probability": empirical, "empirical_ev_multiplier": empiricalEV},
	}
}

func simulateFundedRTPLongRun(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
	const name = "RTP长跑"
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	config.DailyJackpotLimit = math.MaxInt64
	config.MilestoneSpendCoins = 0
	state := domain.StrategyState{}
	random := domain.NewSeededStrategyRandom(seed)
	var contributed, payout int64
	for index := 0; index < draws; index++ {
		decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
		if err != nil {
			return failedScenario(name, err)
		}
		state = decision.NextState
		contributed += 98
		payout += decision.PayoutCoins
		if state.PoolBalanceCoins < 0 {
			return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d抽资金为负", index+1)}
		}
	}
	rtpPercent := float64(payout) / float64(draws*100) * 100
	conserved := contributed-payout == state.PoolBalanceCoins
	passed := conserved && payout <= contributed && rtpPercent <= 98
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("%d抽实付RTP=%.4f%%，投入=%d，赔付=%d，池余=%d", draws, rtpPercent, contributed, payout, state.PoolBalanceCoins),
		Data:    map[string]any{"contributed": contributed, "payout": payout, "pool_after": state.PoolBalanceCoins, "rtp_percent": rtpPercent, "fund_conserved": conserved},
	}
}

func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) domain.StrategyWeightTrace {
	for _, trace := range traces {
		if trace.TierID == tierID {
			return trace
		}
	}
	return domain.StrategyWeightTrace{}
}

type settledRoundQualificationRow struct {
	Case               string `json:"case"`
	RegisteredAgeHours int64  `json:"registered_age_hours"`
	RoundWagerCoins    int64  `json:"round_wager_coins"`
	RoundPayoutCoins   int64  `json:"round_payout_coins"`
	RollingWagerCoins  int64  `json:"rolling_48h_wager_coins"`
	RollingPayoutCoins int64  `json:"rolling_48h_payout_coins"`
	GlobalWagerCoins   int64  `json:"global_wager_coins"`
	GlobalPayoutCoins  int64  `json:"global_payout_coins"`
	Wanted             bool   `json:"wanted"`
	Triggered          bool   `json:"triggered"`
	SelectedTier       string `json:"selected_tier"`
}

func simulateSettledRoundQualification(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "已结算轮次+注册48小时大奖资格"
	config = settledRoundJackpotConfig(config)
	const (
		hourMS     = int64(60 * 60 * 1000)
		closedAtMS = int64(100 * 60 * 60 * 1000)
	)
	base := qualifiedSettledRoundState(20_000, closedAtMS)
	cases := []struct {
		name  string
		state domain.StrategyState
		want  bool
	}{
		{name: "注册47小时，即使两个用户RTP都是95%也失败", state: withRegisteredAge(base, 47*hourMS), want: false},
		{name: "注册恰满48小时，两个用户RTP都是95%通过", state: withRegisteredAge(base, 48*hourMS), want: true},
		{name: "注册已满48小时且只在最后5分钟玩，48小时总RTP就是这5分钟的95%", state: withLastFiveMinuteHistory(withRegisteredAge(base, 72*hourMS)), want: true},
		{name: "已结算用户轮次RTP恰好96%失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false},
		{name: "滚动48小时总RTP恰好96%失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false},
		{name: "已结算大盘RTP恰好98%仍通过", state: withRegisteredAge(base, 48*hourMS), want: true},
		{name: "已结算大盘RTP超过98%失败", state: withGlobalRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_801), want: false},
		{name: "仅用户轮次门失败，48小时门通过，仍失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false},
		{name: "仅48小时门失败，用户轮次门通过，仍失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false},
		{name: "用户轮次没有消费样本失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false},
		{name: "滚动48小时没有消费样本失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false},
	}
	rows := make([]settledRoundQualificationRow, 0, len(cases))
	passed := true
	for index, item := range cases {
		decision, err := domain.DecideLuckyGiftStrategy(config, item.state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
		if err != nil {
			return failedScenario(name, err)
		}
		triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
		passed = passed && triggered == item.want
		rows = append(rows, settledRoundQualificationRow{
			Case: item.name, RegisteredAgeHours: (item.state.QualificationClosedAtMS - item.state.UserRegisteredAtMS) / hourMS,
			RoundWagerCoins: item.state.UserRoundRTP.WagerCoins, RoundPayoutCoins: item.state.UserRoundRTP.PayoutCoins,
			RollingWagerCoins: item.state.User48HourRTP.WagerCoins, RollingPayoutCoins: item.state.User48HourRTP.PayoutCoins,
			GlobalWagerCoins: item.state.GlobalRTP.WagerCoins, GlobalPayoutCoins: item.state.GlobalRTP.PayoutCoins,
			Wanted: item.want, Triggered: triggered, SelectedTier: decision.SelectedTier.ID,
		})
	}
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: "注册<48h失败；恰48h及只玩最后5分钟按实际总流水判断；用户轮次/48h严格<96%，大盘允许=98%，超限或零样本失败",
		Data:    rows,
	}
}

func settledRoundJackpotConfig(config domain.StrategyConfig) domain.StrategyConfig {
	config = baseProbabilityConfig(config)
	config.JackpotMechanism1Enabled = true
	config.JackpotMechanism2Enabled = false
	config.MilestoneSpendCoins = 0
	config.SettledRoundRTPEligibility = true
	config.GlobalRTPMaxPPM = 980_000
	config.UserRoundRTPMaxPPM = 960_000
	config.User48HourRTPMaxPPM = 960_000
	config.DailyJackpotLimit = 5
	return config
}

func qualifiedSettledRoundState(pool, closedAtMS int64) domain.StrategyState {
	return domain.StrategyState{
		PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS,
		UserRegisteredAtMS: closedAtMS - 48*60*60*1000,
		GlobalRTP:          domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800},
		UserRoundRTP:       domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
		User48HourRTP:      domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
	}
}

func withRegisteredAge(state domain.StrategyState, ageMS int64) domain.StrategyState {
	state.UserRegisteredAtMS = state.QualificationClosedAtMS - ageMS
	return state
}

func withLastFiveMinuteHistory(state domain.StrategyState) domain.StrategyState {
	// 前47小时55分钟没有送礼不会制造“空白样本”；滚动48小时的总分子/分母就是最后5分钟实际发生的95/100。
	state.UserRoundRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95}
	state.User48HourRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95}
	return state
}

func withUserRoundRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState {
	state.UserRoundRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}
	return state
}

func withUser48HourRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState {
	state.User48HourRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}
	return state
}

func withGlobalRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState {
	state.GlobalRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}
	return state
}

func simulatePoolBlockedQualificationConsumption(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "池不足仍消费已结算轮次资格"
	config = settledRoundJackpotConfig(config)
	// 普通档固定为0，隔离出“大奖不可支付后只消费一次资格”的状态变化。
	config.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
		{ID: "200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
		{ID: "500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true},
		{ID: "1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
	}
	state := qualifiedSettledRoundState(49, 100*60*60*1000)
	first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed))
	if err != nil {
		return failedScenario(name, err)
	}
	afterTopUp := first.NextState
	afterTopUp.PoolBalanceCoins = 20_000
	second, err := domain.DecideLuckyGiftStrategy(config, afterTopUp, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1))
	if err != nil {
		return failedScenario(name, err)
	}
	passed := !first.Jackpot && !first.NextState.SettledRoundPending && !second.Jackpot && second.JackpotMechanism == ""
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("首抽池=%d不足支付200x，资格尝试后pending=%v；补池到20000再抽也不会复用旧资格", state.PoolBalanceCoins, first.NextState.SettledRoundPending),
		Data:    map[string]any{"pool_blocked_attempt": first, "after_top_up_without_new_closed_round": second},
	}
}

func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "大奖集合"
	config = settledRoundJackpotConfig(config)
	all := map[string]int{}
	for index := int64(0); index < 120; index++ {
		decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(10_000, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index))
		if err != nil {
			return failedScenario(name, err)
		}
		all[decision.SelectedTier.ID]++
	}
	limited := map[string]int{}
	for index := int64(0); index < 40; index++ {
		decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(4_999, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index))
		if err != nil {
			return failedScenario(name, err)
		}
		limited[decision.SelectedTier.ID]++
	}
	_, has200 := all["jackpot_200x"]
	_, has500 := all["jackpot_500x"]
	_, has1000 := all["jackpot_1000x"]
	passed := has200 && has500 && has1000 && len(limited) == 1 && limited["jackpot_200x"] == 40
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%v；P=4999时只可能200x=%v", has200 && has500 && has1000, limited),
		Data:    map[string]any{"pool_10000_counts": all, "pool_4999_counts": limited},
	}
}

func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "日5次上限"
	config = settledRoundJackpotConfig(config)
	state := qualifiedSettledRoundState(1_000_000, 100*60*60*1000)
	for index := 0; index < 5; index++ {
		// 每次循环代表又结算了一个新的大盘轮次；同一轮资格在完成一次尝试后不会自动续期。
		state.SettledRoundPending = true
		state.QualificationClosedAtMS += 60 * 60 * 1000
		decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
		if err != nil {
			return failedScenario(name, err)
		}
		state = decision.NextState
		if !decision.Jackpot {
			return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)}
		}
	}
	state.SettledRoundPending = true
	state.QualificationClosedAtMS += 60 * 60 * 1000
	// 大奖路径达到上限后仍回落普通随机；同倍率 ordinary 200x 不属于大奖，不能被 cap 删除。
	sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.SelectedTier.ID == "200x" && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: fmt.Sprintf("5个独立补偿大奖后计数=%d；第6个轮次补偿受限但普通200x仍可命中且不增加大奖次数", state.UserDailyJackpotWins),
		Data:    map[string]any{"sixth": sixth},
	}
}

type laluLuckyV5TierFixture struct {
	id         string
	multiplier int64
	weight     int64
	high       bool
}

// laluLuckyV5RuleFixture freezes the disabled test rule lalu/lucky/v5 that exposed the
// overlap bug. Values are intentionally explicit rather than generated from a curve:
// a later test-rule edit must produce a visible fixture diff before it can change this
// regression report. The simulator never publishes or enables this snapshot.
func laluLuckyV5RuleFixture() domain.RuleConfig {
	stage := func(name string, min7D, min30D int64, rows []laluLuckyV5TierFixture) domain.RuleStage {
		out := domain.RuleStage{Stage: name, MinRecharge7DCoins: min7D, MinRecharge30DCoins: min30D, Tiers: make([]domain.RuleTier, 0, len(rows))}
		for _, row := range rows {
			out.Tiers = append(out.Tiers, domain.RuleTier{
				Stage: name, TierID: name + "_" + row.id, MultiplierPPM: row.multiplier,
				BaseWeightPPM: row.weight, HighWaterOnly: row.high, Enabled: true,
			})
		}
		return out
	}
	return domain.RuleConfig{
		AppCode: "lalu", PoolID: "lucky", RuleVersion: 5, StrategyVersion: domain.StrategyDynamicV3, Enabled: false,
		TargetRTPPPM: 890_000, PoolRatePPM: 890_000, ProfitRatePPM: 10_000, AnchorRatePPM: 100_000,
		SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 100,
		NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, EffectiveFromMS: 1_780_032_226_505,
		InitialPoolCoins: 0, LossStreakGuarantee: 4,
		LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000,
		HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000,
		RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000,
		JackpotMultiplierPPMs:  []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000},
		JackpotGlobalRTPMaxPPM: 980_000, JackpotUserRoundRTPMaxPPM: 950_000, JackpotUser48hRTPMaxPPM: 950_000,
		SettledRoundRTPEligibility: true, JackpotSpendThresholdCoins: 800_000, MaxJackpotHitsPerUserDay: 10,
		MaxSinglePayout: 4_000_000_000, UserHourlyPayoutCap: 4_000_000_000, UserDailyPayoutCap: 4_000_000_000,
		DeviceDailyPayoutCap: 4_000_000_000, RoomHourlyPayoutCap: 4_000_000_000, AnchorDailyPayoutCap: 4_000_000_000,
		Stages: []domain.RuleStage{
			stage(domain.StageNovice, 0, 0, []laluLuckyV5TierFixture{
				{id: "none", multiplier: 0, weight: 508_000},
				{id: "0_5x", multiplier: 500_000, weight: 254_261},
				{id: "1x", multiplier: 1_000_000, weight: 127_131},
				{id: "2x", multiplier: 2_000_000, weight: 63_567},
				{id: "5x", multiplier: 5_000_000, weight: 25_427},
				{id: "10x", multiplier: 10_000_000, weight: 12_713, high: true},
				{id: "20x", multiplier: 20_000_000, weight: 6_357, high: true},
				{id: "50x", multiplier: 50_000_000, weight: 2_544, high: true},
			}),
			stage(domain.StageNormal, 8, 100, []laluLuckyV5TierFixture{
				{id: "none", multiplier: 0, weight: 530_785},
				{id: "0_5x", multiplier: 500_000, weight: 161_748},
				{id: "0_8x", multiplier: 800_000, weight: 101_094},
				{id: "1x", multiplier: 1_000_000, weight: 80_875},
				{id: "1_5x", multiplier: 1_500_000, weight: 53_917},
				{id: "2x", multiplier: 2_000_000, weight: 40_438, high: true},
				{id: "5x", multiplier: 5_000_000, weight: 16_175, high: true},
				{id: "10x", multiplier: 10_000_000, weight: 8_088, high: true},
				{id: "20x", multiplier: 20_000_000, weight: 4_046, high: true},
				{id: "50x", multiplier: 50_000_000, weight: 1_619, high: true},
				{id: "100x", multiplier: 100_000_000, weight: 810, high: true},
				{id: "200x", multiplier: 200_000_000, weight: 405, high: true},
			}),
			stage(domain.StageAdvanced, 10, 200, []laluLuckyV5TierFixture{
				{id: "none", multiplier: 0, weight: 658_929},
				{id: "0_5x", multiplier: 500_000, weight: 126_588},
				{id: "1x", multiplier: 1_000_000, weight: 63_295},
				{id: "1_2x", multiplier: 1_200_000, weight: 52_746},
				{id: "1_5x", multiplier: 1_500_000, weight: 42_197},
				{id: "2x", multiplier: 2_000_000, weight: 31_648},
				{id: "5x", multiplier: 5_000_000, weight: 12_659, high: true},
				{id: "10x", multiplier: 10_000_000, weight: 6_331, high: true},
				{id: "20x", multiplier: 20_000_000, weight: 3_166, high: true},
				{id: "50x", multiplier: 50_000_000, weight: 1_266, high: true},
				{id: "100x", multiplier: 100_000_000, weight: 634, high: true},
				{id: "200x", multiplier: 200_000_000, weight: 317, high: true},
				{id: "500x", multiplier: 500_000_000, weight: 127, high: true},
				{id: "1000x", multiplier: 1_000_000_000, weight: 64, high: true},
				{id: "2000x", multiplier: 2_000_000_000, weight: 33, high: true},
			}),
		},
	}
}

func v5FixtureInput(config domain.Config, stage string, risk domain.StrategyRiskCapacity) domain.StrategyInput {
	input := domain.StrategyInput{GiftPriceCoins: config.GiftPrice, PoolContributionCoins: 89, RiskCapacity: risk}
	switch stage {
	case domain.StageNormal:
		input.Recharge7DCoins, input.Recharge30DCoins = 8, 100
	case domain.StageAdvanced:
		input.Recharge7DCoins, input.Recharge30DCoins = 10, 200
	}
	return input
}

func v5FixtureRiskCapacity(limit int64) domain.StrategyRiskCapacity {
	return domain.StrategyRiskCapacity{
		Enabled: true, SingleDrawCoins: limit, UserHourCoins: limit, UserDayCoins: limit,
		DeviceDayCoins: limit, RoomHourCoins: limit, AnchorDayCoins: limit,
	}
}

func qualifiedV5FixtureState(pool int64) domain.StrategyState {
	const closedAtMS = int64(100 * 60 * 60 * 1000)
	return domain.StrategyState{
		PoolBalanceCoins: pool, 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},
	}
}

func strategyOrdinaryDrawPoint(config domain.StrategyConfig, state domain.StrategyState, input domain.StrategyInput, tierID string) (int64, int64, int64, error) {
	_, weights, err := domain.PreviewLuckyGiftStrategyWeights(config, state, input)
	if err != nil {
		return 0, 0, 0, err
	}
	var index, bound int64
	tierWeight := int64(0)
	for _, item := range weights {
		if item.TierID == tierID {
			tierWeight = item.AdjustedWeightPPM
			index = bound
		}
		bound += item.AdjustedWeightPPM
	}
	if tierWeight <= 0 || bound <= 0 {
		return 0, 0, 0, fmt.Errorf("ordinary tier %s has no selectable weight", tierID)
	}
	return index, bound, tierWeight, nil
}

func fixtureStageExpectedRTP(stage domain.RuleStage) int64 {
	var expected int64
	for _, tier := range stage.Tiers {
		if tier.Enabled {
			expected += tier.MultiplierPPM * tier.BaseWeightPPM / domain.StrategyPPMScale
		}
	}
	return expected
}

func removalCount(trace domain.DecisionTrace, reason string) int {
	count := 0
	for _, removed := range trace.Removed {
		if removed.Reason == reason {
			count++
		}
	}
	return count
}

func simulateDualSourceOverlap(_ domain.StrategyConfig) scenarioResult {
	const name = "test lalu/lucky/v5 同倍率双来源"
	rule := laluLuckyV5RuleFixture()
	runtimeConfig, config, err := mysqlstorage.BuildLuckyDynamicStrategyForRule(rule)
	if err != nil {
		return failedScenario(name, err)
	}
	passed := runtimeConfig.TargetRTPPPM == 890_000 && config.DailyJackpotLimit == 10
	stageRTP := make(map[string]int64, len(rule.Stages))
	for _, stage := range rule.Stages {
		stageRTP[stage.Stage] = fixtureStageExpectedRTP(stage)
		passed = passed && stageRTP[stage.Stage] == rule.TargetRTPPPM
	}

	const poolBefore = int64(15_000_000) // 真实低/高水位之间，基础权重不被水位因子改变。
	fullRisk := v5FixtureRiskCapacity(4_000_000_000)
	type overlapCase struct {
		label      string
		stage      string
		multiplier int64
	}
	overlaps := []overlapCase{
		{label: "100x", stage: domain.StageNormal, multiplier: 100_000_000},
		{label: "200x", stage: domain.StageNormal, multiplier: 200_000_000},
		{label: "500x", stage: domain.StageAdvanced, multiplier: 500_000_000},
		{label: "1000x", stage: domain.StageAdvanced, multiplier: 1_000_000_000},
	}
	ordinaryEvidence := make(map[string]any, len(overlaps))
	jackpotEvidence := make(map[string]any, len(overlaps))
	for jackpotIndex, overlap := range overlaps {
		ordinaryID := fmt.Sprintf("multiplier_%d", overlap.multiplier)
		ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore}
		ordinaryInput := v5FixtureInput(runtimeConfig, overlap.stage, fullRisk)
		ordinaryIndex, ordinaryBound, ordinaryWeight, pointErr := strategyOrdinaryDrawPoint(config, ordinaryState, ordinaryInput, ordinaryID)
		if pointErr != nil {
			return failedScenario(name, pointErr)
		}
		ordinary, drawErr := domain.DecideLuckyGiftStrategy(config, ordinaryState, ordinaryInput, &scriptedRandom{indexes: []int64{ordinaryIndex}, bounds: []int64{ordinaryBound}})
		if drawErr != nil {
			return failedScenario(name, drawErr)
		}
		ordinaryPayout := runtimeConfig.GiftPrice * overlap.multiplier / domain.StrategyPPMScale
		ordinaryOK := ordinary.SelectedTier.ID == ordinaryID && !ordinary.Jackpot && ordinary.NextState.UserDailyJackpotWins == 0 &&
			ordinary.PayoutCoins == ordinaryPayout && ordinary.PoolAfterCoins == poolBefore+ordinaryInput.PoolContributionCoins-ordinaryPayout && len(ordinary.Trace.Draws) == 1
		passed = passed && ordinaryOK
		ordinaryEvidence[overlap.label] = map[string]any{"tier_id": ordinary.SelectedTier.ID, "stage": ordinary.Stage, "weight": ordinaryWeight, "payout": ordinary.PayoutCoins, "pool_after": ordinary.PoolAfterCoins, "jackpot_hits": ordinary.NextState.UserDailyJackpotWins}

		jackpotState := qualifiedV5FixtureState(poolBefore)
		jackpotInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk)
		jackpot, drawErr := domain.DecideLuckyGiftStrategy(config, jackpotState, jackpotInput, &scriptedRandom{indexes: []int64{int64(jackpotIndex)}, bounds: []int64{int64(len(overlaps))}})
		if drawErr != nil {
			return failedScenario(name, drawErr)
		}
		jackpotID := fmt.Sprintf("jackpot_%d", overlap.multiplier)
		jackpotOK := jackpot.SelectedTier.ID == jackpotID && jackpot.Jackpot && jackpot.NextState.UserDailyJackpotWins == 1 &&
			jackpot.PayoutCoins == ordinaryPayout && jackpot.PoolAfterCoins == poolBefore+jackpotInput.PoolContributionCoins-ordinaryPayout &&
			len(jackpot.Trace.Draws) == 1 && jackpot.Trace.Draws[0].Kind == domain.StrategyDrawKindRTPCompensation
		passed = passed && jackpotOK
		jackpotEvidence[overlap.label] = map[string]any{"tier_id": jackpot.SelectedTier.ID, "weight": jackpot.SelectedTier.JackpotWeight, "payout": jackpot.PayoutCoins, "pool_after": jackpot.PoolAfterCoins, "jackpot_hits": jackpot.NextState.UserDailyJackpotWins, "draw_count": len(jackpot.Trace.Draws)}
	}

	// 真实日 cap=10 时补偿路径被挡住，但同倍率普通 1000x 仍按 advanced 的 64ppm 命中；
	// scriptedRandom 只提供一次随机数，若实现错误地“先发大奖再发普通奖”会立刻耗尽并失败。
	capState := qualifiedV5FixtureState(poolBefore)
	capState.UserDailyJackpotWins = rule.MaxJackpotHitsPerUserDay
	capInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk)
	capIndex, capBound, capWeight, err := strategyOrdinaryDrawPoint(config, capState, capInput, "multiplier_1000000000")
	if err != nil {
		return failedScenario(name, err)
	}
	afterCap, err := domain.DecideLuckyGiftStrategy(config, capState, capInput, &scriptedRandom{indexes: []int64{capIndex}, bounds: []int64{capBound}})
	if err != nil {
		return failedScenario(name, err)
	}
	passed = passed && afterCap.SelectedTier.ID == "multiplier_1000000000" && !afterCap.Jackpot && len(afterCap.Trace.Draws) == 1 &&
		afterCap.NextState.UserDailyJackpotWins == rule.MaxJackpotHitsPerUserDay && !afterCap.NextState.SettledRoundPending

	// 奖池不足会一次性消费本轮补偿资格并回落普通 1x；普通路径仍使用本抽 89 金币入池后的 P/W。
	fallbackState := qualifiedV5FixtureState(100)
	fallbackInput := v5FixtureInput(runtimeConfig, domain.StageNormal, fullRisk)
	fallbackIndex, fallbackBound, _, err := strategyOrdinaryDrawPoint(config, fallbackState, fallbackInput, "multiplier_1000000")
	if err != nil {
		return failedScenario(name, err)
	}
	fallback, err := domain.DecideLuckyGiftStrategy(config, fallbackState, fallbackInput, &scriptedRandom{indexes: []int64{fallbackIndex}, bounds: []int64{fallbackBound}})
	if err != nil {
		return failedScenario(name, err)
	}
	passed = passed && fallback.SelectedTier.ID == "multiplier_1000000" && !fallback.Jackpot && len(fallback.Trace.Draws) == 1 &&
		removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient) == 4 && !fallback.NextState.SettledRoundPending && fallback.PoolAfterCoins == 89

	// 六个维度逐个压到 9,999：所有补偿候选最小为 100x=10,000，必须被同一个硬门挡住；
	// 同时强抽普通 100x，证明普通奖也不能绕开该门，只是不会读取大奖日 cap。
	type riskDimension struct {
		name string
		set  func(*domain.StrategyRiskCapacity, int64)
	}
	riskDimensions := []riskDimension{
		{name: "single", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
		{name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
		{name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
		{name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
		{name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
		{name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
	}
	riskEvidence := make(map[string]bool, len(riskDimensions))
	for _, dimension := range riskDimensions {
		risk := fullRisk
		dimension.set(&risk, 9_999)
		blockedSpecialState := qualifiedV5FixtureState(poolBefore)
		blockedSpecialInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk)
		oneXIndex, oneXBound, _, pointErr := strategyOrdinaryDrawPoint(config, blockedSpecialState, blockedSpecialInput, "multiplier_1000000")
		if pointErr != nil {
			return failedScenario(name, pointErr)
		}
		blockedSpecial, drawErr := domain.DecideLuckyGiftStrategy(config, blockedSpecialState, blockedSpecialInput, &scriptedRandom{indexes: []int64{oneXIndex}, bounds: []int64{oneXBound}})
		if drawErr != nil {
			return failedScenario(name, drawErr)
		}

		blockedOrdinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore}
		blockedOrdinaryInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk)
		highIndex, highBound, highWeight, pointErr := strategyOrdinaryDrawPoint(config, blockedOrdinaryState, blockedOrdinaryInput, "multiplier_100000000")
		if pointErr != nil {
			return failedScenario(name, pointErr)
		}
		blockedOrdinary, drawErr := domain.DecideLuckyGiftStrategy(config, blockedOrdinaryState, blockedOrdinaryInput, &scriptedRandom{indexes: []int64{highIndex, 0}, bounds: []int64{highBound, highBound - highWeight}})
		if drawErr != nil {
			return failedScenario(name, drawErr)
		}
		dimensionOK := blockedSpecial.SelectedTier.ID == "multiplier_1000000" && !blockedSpecial.Jackpot &&
			removalCount(blockedSpecial.Trace, domain.StrategyReasonRiskCapacity) == 4 &&
			blockedOrdinary.SelectedTier.ID == "multiplier_0" && len(blockedOrdinary.Trace.Draws) == 2 &&
			blockedOrdinary.Trace.Draws[0].TierID == "multiplier_100000000" && blockedOrdinary.Trace.Draws[0].RemovedReason == domain.StrategyReasonRiskCapacity
		riskEvidence[dimension.name] = dimensionOK
		passed = passed && dimensionOK
	}

	return scenarioResult{
		Name: name, Passed: passed,
		Summary: "真实v5三阶段RTP=89%；100/200/500/1000x普通与补偿独立，补偿优先且单抽单发，阻断/cap后回落普通，池与六维门共用",
		Data: map[string]any{
			"fixture":                map[string]any{"app": rule.AppCode, "pool": rule.PoolID, "version": rule.RuleVersion, "enabled": rule.Enabled, "target_rtp_ppm": rule.TargetRTPPPM, "stage_expected_rtp_ppm": stageRTP, "daily_jackpot_cap": rule.MaxJackpotHitsPerUserDay},
			"ordinary_by_multiplier": ordinaryEvidence, "compensation_by_multiplier": jackpotEvidence,
			"ordinary_after_cap":    map[string]any{"tier_id": afterCap.SelectedTier.ID, "weight": capWeight, "jackpot_hits": afterCap.NextState.UserDailyJackpotWins, "draw_count": len(afterCap.Trace.Draws)},
			"pool_blocked_fallback": map[string]any{"tier_id": fallback.SelectedTier.ID, "pool_after": fallback.PoolAfterCoins, "removed_jackpots": removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient)},
			"six_risk_dimensions":   riskEvidence,
		},
	}
}

func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult {
	const name = "六维risk capacity代表边界"
	config = baseProbabilityConfig(config)
	config.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
		{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
	}
	type dimension struct {
		name string
		set  func(*domain.StrategyRiskCapacity, int64)
	}
	dimensions := []dimension{
		{name: "single_draw", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
		{name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
		{name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
		{name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
		{name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
		{name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
	}
	rows := map[string]map[string]any{}
	passed := true
	for _, item := range dimensions {
		capacity := domain.StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
		item.set(&capacity, 50)
		allowed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
		if err != nil {
			return failedScenario(name, err)
		}
		item.set(&capacity, 49)
		blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
		if err != nil {
			return failedScenario(name, err)
		}
		rowPass := allowed.PayoutCoins == 50 && blocked.PayoutCoins == 0 && blocked.PoolAfterCoins == 50
		passed = passed && rowPass
		rows[item.name] = map[string]any{"equal_capacity_payout": allowed.PayoutCoins, "below_capacity_payout": blocked.PayoutCoins, "pass": rowPass}
	}
	return scenarioResult{Name: name, Passed: passed, Summary: "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", Data: rows}
}

func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
	const name = "充值分层"
	type row struct {
		Recharge7D  int64  `json:"recharge_7d"`
		Recharge30D int64  `json:"recharge_30d"`
		Stage       string `json:"stage"`
	}
	inputs := [][2]int64{
		{99, 499},  // 非零但两维低于 normal。
		{100, 499}, // 7 日等于 normal，30 日未达，仍为 novice。
		{99, 500},  // 30 日等于 normal，7 日未达，仍为 novice。
		{100, 500}, // 两维等于 normal，进入 normal。
		{500, 2_000},
	}
	wants := []string{domain.StageNovice, domain.StageNovice, domain.StageNovice, domain.StageNormal, domain.StageAdvanced}
	rows := make([]row, 0, len(inputs))
	passed := true
	for index, input := range inputs {
		stage := domain.SelectLuckyGiftRechargeStage(config, input[0], input[1])
		passed = passed && stage == wants[index]
		rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage})
	}
	return scenarioResult{Name: name, Passed: passed, Summary: "normal/advanced同时达到7日与30日下限才提档；任一维低于normal即使非零也是novice", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}}
}

func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "组合优先级"
	config = settledRoundJackpotConfig(config)
	config.LowWaterThresholdCoins = 20_000
	config.HighWaterThresholdCoins = 30_000
	config.LowWaterFactorPPM = 700_000
	config.HighWaterFactorPPM = 1_300_000
	config.RechargeFactorPPM = 1_100_000
	state := qualifiedSettledRoundState(10_000, 100*60*60*1000)
	config.JackpotMechanism2Enabled = true
	config.MilestoneSpendCoins = 50
	state.PendingSpendJackpotTokens = 1
	state.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}
	state.ConsecutiveZeroDraws = 5
	input := domain.StrategyInput{
		GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
		LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
	}
	priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed))
	if err != nil {
		return failedScenario(name, err)
	}
	// 两套资格同时存在时，48h 补偿先支付，消费 token 保留到下一个子抽。
	priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.NextState.PendingSpendJackpotTokens == 1 && priority.Stage == domain.StageAdvanced

	blockedState := state
	blockedState.PoolBalanceCoins = 50
	blocked, err := domain.DecideLuckyGiftStrategy(config, blockedState, input, domain.NewSeededStrategyRandom(seed+1))
	if err != nil {
		return failedScenario(name, err)
	}
	// 大奖资格不能透支奖池；48h 资格本轮结束，消费 token 则等待后续补水。
	blockedPass := !blocked.NextState.SettledRoundPending && blocked.NextState.PendingSpendJackpotTokens == 1 && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
	return scenarioResult{
		Name: name, Passed: priorityPass && blockedPass,
		Summary: fmt.Sprintf("优先级=48h补偿>消费达标>普通P/W；48h成功不扣消费token，池不足时消费token保留，本抽仅赔%s", blocked.SelectedTier.ID),
		Data:    map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
	}
}

func simulateDualJackpotSpendStrategy(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "双大奖消费资格矩阵"
	config = settledRoundJackpotConfig(config)
	config.JackpotMechanism2Enabled = true
	config.MilestoneSpendCoins = 50
	input := domain.StrategyInput{GiftPriceCoins: 10}
	base := domain.StrategyState{
		PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1,
		SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800},
	}
	type row struct {
		Case         string `json:"case"`
		GlobalWager  int64  `json:"global_wager"`
		GlobalPayout int64  `json:"global_payout"`
		TokensBefore int64  `json:"tokens_before"`
		TokensAfter  int64  `json:"tokens_after"`
		Mechanism    string `json:"mechanism"`
		Jackpot      bool   `json:"jackpot"`
		Payout       int64  `json:"payout"`
		PoolAfter    int64  `json:"pool_after"`
		Reason       string `json:"reason"`
	}
	rows := make([]row, 0, 8)
	run := func(caseName string, state domain.StrategyState, drawInput domain.StrategyInput, offset int64) (domain.StrategyDecision, error) {
		decision, err := domain.DecideLuckyGiftStrategy(config, state, drawInput, domain.NewSeededStrategyRandom(seed+offset))
		if err == nil {
			rows = append(rows, row{
				Case: caseName, GlobalWager: state.SpendGlobalRTP.WagerCoins, GlobalPayout: state.SpendGlobalRTP.PayoutCoins,
				TokensBefore: state.PendingSpendJackpotTokens, TokensAfter: decision.NextState.PendingSpendJackpotTokens,
				Mechanism: decision.JackpotMechanism, Jackpot: decision.Jackpot, Payout: decision.PayoutCoins,
				PoolAfter: decision.PoolAfterCoins, Reason: decision.Trace.BlockedReason,
			})
		}
		return decision, err
	}

	equal, err := run("大盘等于98%", base, input, 0)
	if err != nil {
		return failedScenario(name, err)
	}
	aboveState := base
	aboveState.SpendGlobalRTP.PayoutCoins = 9_801
	above, err := run("大盘高于98%", aboveState, input, 1)
	if err != nil {
		return failedScenario(name, err)
	}
	noSampleState := base
	noSampleState.SpendGlobalRTP = domain.StrategyRTP{}
	noSample, err := run("无已结算大盘", noSampleState, input, 2)
	if err != nil {
		return failedScenario(name, err)
	}

	// 40->150 crosses 50/100/150 in one paid child draw. A scripted zero proves those three new
	// tokens cannot influence the current selection; the following child draw consumes only one.
	crossState := base
	crossState.PendingSpendJackpotTokens = 0
	crossState.UserDaySpendCoins = 40
	crossInput := domain.StrategyInput{GiftPriceCoins: 110}
	crossed, err := domain.DecideLuckyGiftStrategy(config, crossState, crossInput, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}})
	if err != nil {
		return failedScenario(name, err)
	}
	rows = append(rows, row{Case: "当前子抽跨三档", GlobalWager: crossState.SpendGlobalRTP.WagerCoins, GlobalPayout: crossState.SpendGlobalRTP.PayoutCoins, TokensBefore: 0, TokensAfter: crossed.NextState.PendingSpendJackpotTokens, Mechanism: crossed.JackpotMechanism, Jackpot: crossed.Jackpot, Payout: crossed.PayoutCoins, PoolAfter: crossed.PoolAfterCoins, Reason: crossed.Trace.BlockedReason})
	next, err := run("下一子抽消费一档", crossed.NextState, input, 3)
	if err != nil {
		return failedScenario(name, err)
	}

	qualified := qualifiedSettledRoundState(10_000, 100*60*60*1000)
	qualified.PendingSpendJackpotTokens = 1
	qualified.SpendGlobalRTP = base.SpendGlobalRTP
	priority, err := run("双资格48h优先", qualified, input, 4)
	if err != nil {
		return failedScenario(name, err)
	}
	qualified.UserRoundRTP.PayoutCoins = 9_600
	fallback, err := run("48h不合格消费接续", qualified, input, 5)
	if err != nil {
		return failedScenario(name, err)
	}
	poolBlockedState := base
	poolBlockedState.PoolBalanceCoins = 49
	poolBlocked, err := run("奖池不足资格保留", poolBlockedState, input, 6)
	if err != nil {
		return failedScenario(name, err)
	}

	passed := equal.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && equal.NextState.PendingSpendJackpotTokens == 0 &&
		!above.Jackpot && above.NextState.PendingSpendJackpotTokens == 1 &&
		!noSample.Jackpot && noSample.NextState.PendingSpendJackpotTokens == 1 &&
		!crossed.Jackpot && crossed.NextState.PendingSpendJackpotTokens == 3 &&
		next.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && next.NextState.PendingSpendJackpotTokens == 2 &&
		priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && priority.NextState.PendingSpendJackpotTokens == 1 &&
		fallback.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && fallback.NextState.PendingSpendJackpotTokens == 0 &&
		!poolBlocked.Jackpot && poolBlocked.NextState.PendingSpendJackpotTokens == 1
	return scenarioResult{
		Name: name, Passed: passed,
		Summary: "验证大盘<=边界、无样本关闭、每档累计、下一子抽生效、48h优先/接续和资金不足跨轮保留",
		Data:    rows,
	}
}

// simulationStore models the repository contract that the pure kernel expects:
// command idempotency and state locking wrap the draw in one critical section. The
// simulator does not claim an in-memory mutex is the production implementation; it
// verifies the state-machine invariants a row lock/CAS transaction must preserve.
type simulationStore struct {
	mu          sync.Mutex
	config      domain.StrategyConfig
	state       domain.StrategyState
	random      domain.StrategyRandomSource
	seen        map[string]domain.StrategyDecision
	transitions int
}

func newSimulationStore(config domain.StrategyConfig, state domain.StrategyState, seed int64) *simulationStore {
	return &simulationStore{config: config, state: state, random: domain.NewSeededStrategyRandom(seed), seen: map[string]domain.StrategyDecision{}}
}

func (s *simulationStore) execute(commandID string, input domain.StrategyInput) (domain.StrategyDecision, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if existing, ok := s.seen[commandID]; ok {
		return existing, nil
	}
	decision, err := domain.DecideLuckyGiftStrategy(s.config, s.state, input, s.random)
	if err != nil {
		return domain.StrategyDecision{}, err
	}
	s.state = decision.NextState
	s.seen[commandID] = decision
	s.transitions++
	return decision, nil
}

func simulateConcurrencyAndIdempotency(config domain.StrategyConfig, seed int64) scenarioResult {
	const name = "并发/幂等模型"
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	config.DailyJackpotLimit = math.MaxInt64
	config.MilestoneSpendCoins = 0
	input := domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}

	duplicateStore := newSimulationStore(config, domain.StrategyState{}, seed)
	duplicateErrors := runConcurrentCommands(duplicateStore, 32, func(int) string { return "same-command" }, input)
	duplicatePass := len(duplicateErrors) == 0 && duplicateStore.transitions == 1 && duplicateStore.state.Version == 1 && len(duplicateStore.seen) == 1 && duplicateStore.state.PoolBalanceCoins >= 0

	uniqueStore := newSimulationStore(config, domain.StrategyState{}, seed+1)
	uniqueErrors := runConcurrentCommands(uniqueStore, 100, func(index int) string { return fmt.Sprintf("command-%03d", index) }, input)
	uniquePass := len(uniqueErrors) == 0 && uniqueStore.transitions == 100 && uniqueStore.state.Version == 100 && len(uniqueStore.seen) == 100 && uniqueStore.state.PoolBalanceCoins >= 0
	return scenarioResult{
		Name: name, Passed: duplicatePass && uniquePass,
		Summary: fmt.Sprintf("32个同幂等键仅1次状态迁移；100个并发唯一键串行化为100个版本且池余=%d", uniqueStore.state.PoolBalanceCoins),
		Data: map[string]any{
			"duplicate": map[string]any{"requests": 32, "transitions": duplicateStore.transitions, "version": duplicateStore.state.Version, "errors": duplicateErrors},
			"unique":    map[string]any{"requests": 100, "transitions": uniqueStore.transitions, "version": uniqueStore.state.Version, "pool_after": uniqueStore.state.PoolBalanceCoins, "errors": uniqueErrors},
		},
	}
}

func runConcurrentCommands(store *simulationStore, count int, commandID func(int) string, input domain.StrategyInput) []string {
	var wait sync.WaitGroup
	errorsCh := make(chan string, count)
	for index := 0; index < count; index++ {
		wait.Add(1)
		go func(index int) {
			defer wait.Done()
			if _, err := store.execute(commandID(index), input); err != nil {
				errorsCh <- err.Error()
			}
		}(index)
	}
	wait.Wait()
	close(errorsCh)
	errorsFound := make([]string, 0, len(errorsCh))
	for err := range errorsCh {
		errorsFound = append(errorsFound, err)
	}
	sort.Strings(errorsFound)
	return errorsFound
}

type groupLongRunSummary struct {
	ExpectedStage string         `json:"expected_stage"`
	ObservedStage string         `json:"observed_stage"`
	Draws         int            `json:"draws"`
	TierCounts    map[string]int `json:"tier_counts"`
	PayoutCoins   int64          `json:"payout_coins"`
	PoolAfter     int64          `json:"pool_after"`
	RTPPercent    float64        `json:"rtp_percent"`
}

func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
	const name = "长跑分群"
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	config.DailyJackpotLimit = math.MaxInt64
	config.MilestoneSpendCoins = 0
	// This grouping run uses a production-safe table (stage EV 85%/97.5%/98%),
	// not the image's 2650% probability snapshot. That keeps distribution changes
	// visible instead of letting P/W drain every group into the same 5x-only cycle.
	config.LowWaterThresholdCoins = 0
	config.HighWaterThresholdCoins = math.MaxInt64
	config.LowWaterFactorPPM = domain.StrategyPPMScale
	config.HighWaterFactorPPM = domain.StrategyPPMScale
	config.RechargeFactorPPM = domain.StrategyPPMScale
	config.MissProtectionZeroDraws = math.MaxInt64
	config.Tiers = []domain.StrategyTier{
		{ID: "0x", MultiplierPPM: 0, Enabled: true},
		{ID: "0.5x", MultiplierPPM: 500_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 100_000, domain.StageNormal: 50_000, domain.StageAdvanced: 40_000}},
		{ID: "1x", MultiplierPPM: 1_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 800_000, domain.StageNormal: 850_000, domain.StageAdvanced: 840_000}},
		{ID: "2x", MultiplierPPM: 2_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 0, domain.StageNormal: 50_000, domain.StageAdvanced: 60_000}},
	}
	groups := []struct {
		stage       string
		recharge7D  int64
		recharge30D int64
	}{
		{stage: domain.StageNovice, recharge7D: 99, recharge30D: 499},
		{stage: domain.StageNormal, recharge7D: 100, recharge30D: 500},
		{stage: domain.StageAdvanced, recharge7D: 500, recharge30D: 2_000},
	}
	results := make([]groupLongRunSummary, 0, len(groups))
	passed := true
	for groupIndex, group := range groups {
		state := domain.StrategyState{}
		random := domain.NewSeededStrategyRandom(seed + int64(groupIndex))
		summary := groupLongRunSummary{ExpectedStage: group.stage, Draws: draws, TierCounts: map[string]int{}}
		for index := 0; index < draws; index++ {
			decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{
				GiftPriceCoins: 100, PoolContributionCoins: 98,
				Recharge7DCoins: group.recharge7D, Recharge30DCoins: group.recharge30D,
			}, random)
			if err != nil {
				return failedScenario(name, err)
			}
			state = decision.NextState
			summary.ObservedStage = decision.Stage
			summary.TierCounts[decision.SelectedTier.ID]++
			summary.PayoutCoins += decision.PayoutCoins
			if state.PoolBalanceCoins < 0 {
				passed = false
			}
		}
		summary.PoolAfter = state.PoolBalanceCoins
		summary.RTPPercent = float64(summary.PayoutCoins) / float64(draws*100) * 100
		passed = passed && summary.ObservedStage == group.stage && summary.RTPPercent <= 98 && int64(draws*98)-summary.PayoutCoins == summary.PoolAfter
		results = append(results, summary)
	}
	return scenarioResult{Name: name, Passed: passed, Summary: fmt.Sprintf("novice/normal/advanced各%d抽，分层权重不同但三组资金RTP均<=98%%且守恒", draws), Data: results}
}
