package luckygift

import (
	"fmt"
	"reflect"
	"testing"
)

type scriptedStrategyRandom struct {
	t        *testing.T
	indexes  []int64
	bounds   []int64
	position int
}

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

func strategyTestConfig() StrategyConfig {
	config := DefaultLuckyGiftStrategyConfig()
	// The 0/5/50/200/1000 table is the supplied image's deliberately unsafe
	// 2650% example. It is a test fixture only; the exported default stays at 98%.
	config.Tiers = []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},
	}
	// Core P/W tests isolate the base distribution; dynamic factors have dedicated
	// boundary tests below and must not silently move scripted intervals here.
	config.LowWaterThresholdCoins = 0
	config.HighWaterThresholdCoins = 1_000_000_000
	config.LowWaterFactorPPM = StrategyPPMScale
	config.HighWaterFactorPPM = StrategyPPMScale
	config.RechargeFactorPPM = StrategyPPMScale
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	config.DailyJackpotLimit = 1_000_000
	return config
}

func strategyTestInput(price int64) StrategyInput {
	return StrategyInput{GiftPriceCoins: price}
}

func TestLuckyGiftStrategyDeletesUnaffordableTierAndZeroBeforeRedraw(t *testing.T) {
	config := strategyTestConfig()
	random := &scriptedStrategyRandom{
		t: t,
		// 950000 selects 200x from the original 1,000,000 range. After deleting
		// 200x+0x, 359999 selects 1000x; the last zero selects affordable 5x.
		indexes: []int64{950_000, 359_999, 0},
		bounds:  []int64{1_000_000, 360_000, 350_000},
	}
	decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 800}, strategyTestInput(10), random)
	if err != nil {
		t.Fatal(err)
	}
	if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.PoolAfterCoins != 750 {
		t.Fatalf("decision=%+v", decision)
	}
	if got, want := decision.Trace.OriginalTierID, "200x"; got != want {
		t.Fatalf("original tier=%s want=%s", got, want)
	}
	if got, want := decision.Trace.RedrawTierIDs, []string{"1000x", "5x"}; !reflect.DeepEqual(got, want) {
		t.Fatalf("redraws=%v want=%v", got, want)
	}
	wantRemoved := []StrategyRemovalTrace{
		{TierID: "200x", Reason: StrategyReasonPoolInsufficient},
		{TierID: "0x", Reason: StrategyRemovalPWRedrawZero},
		{TierID: "1000x", Reason: StrategyReasonPoolInsufficient},
	}
	if !reflect.DeepEqual(decision.Trace.Removed, wantRemoved) {
		t.Fatalf("removed=%+v want=%+v", decision.Trace.Removed, wantRemoved)
	}
}

func TestLuckyGiftStrategyPWEqualityAndCandidateExhaustion(t *testing.T) {
	t.Run("W equals P is payable", func(t *testing.T) {
		config := strategyTestConfig()
		decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 2_000}, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}})
		if err != nil {
			t.Fatal(err)
		}
		if decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.PoolAfterCoins != 0 {
			t.Fatalf("P=W decision=%+v", decision)
		}
	})

	t.Run("all positive tiers impossible falls back to zero", func(t *testing.T) {
		config := strategyTestConfig()
		random := &scriptedStrategyRandom{
			t: t,
			// Exhaust 1000x, 200x, 50x, then 5x. Zero is removed with the first
			// failed P/W comparison, so no extra random call can hide exhaustion.
			indexes: []int64{999_999, 350_000, 349_999, 0},
			bounds:  []int64{1_000_000, 390_000, 350_000, 200_000},
		}
		decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49}, strategyTestInput(10), random)
		if err != nil {
			t.Fatal(err)
		}
		if decision.PayoutCoins != 0 || decision.SelectedTier.ID != "0x" || decision.Trace.FinalReason != StrategyReasonNoPayableTier {
			t.Fatalf("exhausted decision=%+v", decision)
		}
		if decision.NextState.PoolBalanceCoins < 0 {
			t.Fatalf("pool became negative: %d", decision.NextState.PoolBalanceCoins)
		}
	})
}

func TestLuckyGiftStrategyMissProtectionSixthDraw(t *testing.T) {
	config := strategyTestConfig()
	state := StrategyState{PoolBalanceCoins: 800, ConsecutiveZeroDraws: 5}
	decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{400_000}})
	if err != nil {
		t.Fatal(err)
	}
	if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.NextState.ConsecutiveZeroDraws != 0 {
		t.Fatalf("protected decision=%+v", decision)
	}
	if len(decision.Trace.Removed) == 0 || decision.Trace.Removed[0].Reason != StrategyRemovalMissProtection {
		t.Fatalf("zero exclusion not traced: %+v", decision.Trace.Removed)
	}

	// Protection cannot manufacture money: when even 5x exceeds P, the strategy
	// returns zero and leaves an explicit blocked reason instead of overdrawing.
	blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49, ConsecutiveZeroDraws: 5}, strategyTestInput(10), &scriptedStrategyRandom{t: t})
	if err != nil {
		t.Fatal(err)
	}
	if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonMissProtectionBlocked || blocked.NextState.PoolBalanceCoins != 49 {
		t.Fatalf("blocked protection=%+v", blocked)
	}
}

func TestLuckyGiftStrategyDynamicWeightBoundariesAndMultiplication(t *testing.T) {
	config := strategyTestConfig()
	config.LowWaterThresholdCoins = 1_000
	config.HighWaterThresholdCoins = 2_000
	config.LowWaterFactorPPM = 700_000
	config.HighWaterFactorPPM = 1_300_000
	config.RechargeFactorPPM = 1_100_000
	config.JackpotMechanism1Enabled = false
	config.JackpotMechanism2Enabled = false
	tests := []struct {
		name            string
		pool            int64
		rechargeAge     int64
		hasRecharge     bool
		want5x          int64
		wantFactorNames []string
	}{
		{name: "low below threshold", pool: 999, want5x: 140_000, wantFactorNames: []string{"low_water"}},
		{name: "low equality is middle", pool: 1_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
		{name: "high equality is middle", pool: 2_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
		{name: "high above threshold", pool: 2_001, want5x: 260_000, wantFactorNames: []string{"high_water"}},
		{name: "recharge starts inclusive and multiplies low", pool: 999, hasRecharge: true, rechargeAge: 0, want5x: 154_000, wantFactorNames: []string{"low_water", "recent_recharge"}},
		{name: "recharge end minus one inclusive", pool: 1_500, hasRecharge: true, rechargeAge: 299_999, want5x: 220_000, wantFactorNames: []string{"recent_recharge"}},
		{name: "recharge end exclusive", pool: 1_500, hasRecharge: true, rechargeAge: 300_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			input := strategyTestInput(10)
			input.NowMS = 1_000_000
			input.HasRechargeFact = tt.hasRecharge
			input.LastRechargeAtMS = input.NowMS - tt.rechargeAge
			_, traces, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{PoolBalanceCoins: tt.pool}, input)
			if err != nil {
				t.Fatal(err)
			}
			trace := findWeightTrace(t, traces, "5x")
			if trace.AdjustedWeightPPM != tt.want5x {
				t.Fatalf("5x adjusted=%d want=%d full=%+v", trace.AdjustedWeightPPM, tt.want5x, trace)
			}
			factorNames := make([]string, 0, len(trace.Factors))
			for _, factor := range trace.Factors {
				factorNames = append(factorNames, factor.Name)
			}
			if !reflect.DeepEqual(factorNames, tt.wantFactorNames) {
				t.Fatalf("factor names=%v want=%v", factorNames, tt.wantFactorNames)
			}
			var total int64
			for _, item := range traces {
				total += item.AdjustedWeightPPM
			}
			if total != StrategyPPMScale {
				t.Fatalf("weights sum=%d", total)
			}
		})
	}
}

func TestLuckyGiftStrategyJackpotMechanismOneRequiresAllNonZeroWindows(t *testing.T) {
	config := strategyTestConfig()
	config.JackpotMechanism1Enabled = true
	config.DailyJackpotLimit = 5
	base := StrategyState{
		PoolBalanceCoins: 10_000,
		GlobalRTP:        StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
		UserDayRTP:       StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
		User72HourRTP:    StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
	}
	tests := []struct {
		name      string
		mutate    func(*StrategyState)
		wantPrize bool
	}{
		{name: "all inclusive boundaries pass", wantPrize: true},
		{name: "global above 98", mutate: func(s *StrategyState) { s.GlobalRTP.PayoutCoins = 99 }},
		{name: "day above 96", mutate: func(s *StrategyState) { s.UserDayRTP.PayoutCoins = 97 }},
		{name: "72h above 96", mutate: func(s *StrategyState) { s.User72HourRTP.PayoutCoins = 97 }},
		{name: "global denominator zero", mutate: func(s *StrategyState) { s.GlobalRTP = StrategyRTP{} }},
		{name: "day denominator zero", mutate: func(s *StrategyState) { s.UserDayRTP = StrategyRTP{} }},
		{name: "72h denominator zero", mutate: func(s *StrategyState) { s.User72HourRTP = StrategyRTP{} }},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			state := base
			if tt.mutate != nil {
				tt.mutate(&state)
			}
			decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(7))
			if err != nil {
				t.Fatal(err)
			}
			got := decision.JackpotMechanism == StrategyJackpotMechanismRTPCompensation
			if got != tt.wantPrize {
				t.Fatalf("mechanism1=%v want=%v trace=%+v", got, tt.wantPrize, decision.Trace)
			}
		})
	}
}

func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
	if got := MilestoneTokensEarned(49, 101, 50); got != 2 {
		t.Fatalf("crossed tokens=%d want=2", got)
	}
	config := strategyTestConfig()
	config.JackpotMechanism2Enabled = true
	config.DailyJackpotLimit = 5
	config.MilestoneSpendCoins = 50
	state := StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}
	blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(11))
	if err != nil {
		t.Fatal(err)
	}
	if blocked.ConsumedMilestoneToken || blocked.NextState.PendingMilestoneTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
		t.Fatalf("blocked token decision=%+v", blocked)
	}
	if len(blocked.Trace.Removed) < 3 {
		t.Fatalf("blocked jackpot removals=%+v want all 200/500/1000 tiers", blocked.Trace.Removed)
	}
	for _, removed := range blocked.Trace.Removed[:3] {
		if removed.Reason != StrategyReasonPoolInsufficient {
			t.Fatalf("blocked jackpot removal=%+v", removed)
		}
	}

	state = blocked.NextState
	state.PoolBalanceCoins = 10_000
	paid, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(12))
	if err != nil {
		t.Fatal(err)
	}
	if !paid.ConsumedMilestoneToken || paid.NextState.PendingMilestoneTokens != 0 || !paid.Jackpot || paid.PayoutCoins <= 0 {
		t.Fatalf("consumed token decision=%+v", paid)
	}

	// Crossing happens during finalize, after this draw has already been selected.
	// The token must therefore be persisted for the next request, not consumed now.
	crossingState := StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}
	crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
	if err != nil {
		t.Fatal(err)
	}
	if crossing.ConsumedMilestoneToken || crossing.NextState.PendingMilestoneTokens != 1 || crossing.Trace.MilestoneTokensEarned != 1 {
		t.Fatalf("crossing decision=%+v", crossing)
	}
}

func TestLuckyGiftStrategyDualJackpotSpendGlobalGateAndDurability(t *testing.T) {
	config := strategyTestConfig()
	config.SettledRoundRTPEligibility = true
	config.JackpotMechanism1Enabled = true
	config.JackpotMechanism2Enabled = true
	config.MilestoneSpendCoins = 50
	config.GlobalRTPMaxPPM = 980_000
	config.UserRoundRTPMaxPPM = 960_000
	config.User48HourRTPMaxPPM = 960_000
	config.DailyJackpotLimit = 5

	base := StrategyState{PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}}
	paid, err := DecideLuckyGiftStrategy(config, base, strategyTestInput(10), NewSeededStrategyRandom(80))
	if err != nil {
		t.Fatal(err)
	}
	if paid.JackpotMechanism != StrategyJackpotMechanismMilestone || !paid.ConsumedMilestoneToken || paid.NextState.PendingSpendJackpotTokens != 0 ||
		paid.Trace.SpendMilestoneTokensBefore != 1 || paid.Trace.SpendMilestoneTokensAfter != 0 {
		t.Fatalf("global equality should pay exactly one spend token: %+v", paid)
	}

	for _, test := range []struct {
		name string
		rtp  StrategyRTP
	}{
		{name: "no closed platform sample", rtp: StrategyRTP{}},
		{name: "platform above configured maximum", rtp: StrategyRTP{WagerCoins: 100, PayoutCoins: 99}},
	} {
		t.Run(test.name, func(t *testing.T) {
			state := base
			state.SpendGlobalRTP = test.rtp
			blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(81))
			if err != nil {
				t.Fatal(err)
			}
			if blocked.JackpotMechanism != "" || blocked.NextState.PendingSpendJackpotTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
				t.Fatalf("blocked spend token must remain durable: %+v", blocked)
			}
		})
	}

	capState := base
	capState.UserDailyJackpotWins = config.DailyJackpotLimit
	blockedBySharedCap, err := DecideLuckyGiftStrategy(config, capState, strategyTestInput(10), NewSeededStrategyRandom(82))
	if err != nil {
		t.Fatal(err)
	}
	if blockedBySharedCap.NextState.PendingSpendJackpotTokens != 1 || blockedBySharedCap.Jackpot {
		t.Fatalf("daily cap must be shared without consuming spend token: %+v", blockedBySharedCap)
	}
}

func TestLuckyGiftStrategyDualJackpotCrossingAndPriority(t *testing.T) {
	config := strategyTestConfig()
	config.SettledRoundRTPEligibility = true
	config.JackpotMechanism1Enabled = true
	config.JackpotMechanism2Enabled = true
	config.MilestoneSpendCoins = 50
	config.GlobalRTPMaxPPM = 980_000
	config.UserRoundRTPMaxPPM = 960_000
	config.User48HourRTPMaxPPM = 960_000
	config.DailyJackpotLimit = 5

	// One paid subdraw can cross several exact multiples, but its new promises are appended only
	// after selection. Even with a valid global sample this draw must stay on the ordinary path.
	crossingState := StrategyState{
		PoolBalanceCoins: 10_000, UserDaySpendCoins: 40,
		SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
	}
	crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(110), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
	if err != nil {
		t.Fatal(err)
	}
	if crossing.Jackpot || crossing.NextState.PendingSpendJackpotTokens != 3 || crossing.Trace.MilestoneTokensEarned != 3 ||
		crossing.Trace.SpendMilestoneTokensBefore != 0 || crossing.Trace.SpendMilestoneTokensAfter != 3 {
		t.Fatalf("current draw must only earn three next-draw tokens: %+v", crossing)
	}

	const hourMS = int64(60 * 60 * 1000)
	qualified := StrategyState{
		PoolBalanceCoins:          10_000,
		PendingSpendJackpotTokens: 1,
		SpendGlobalRTP:            StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
		SettledRoundPending:       true,
		GlobalRTP:                 StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
		UserRoundRTP:              StrategyRTP{WagerCoins: 100, PayoutCoins: 95},
		User48HourRTP:             StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
		UserRegisteredAtMS:        hourMS,
		QualificationClosedAtMS:   49 * hourMS,
	}
	compensated, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(83))
	if err != nil {
		t.Fatal(err)
	}
	if compensated.JackpotMechanism != StrategyJackpotMechanismRTPCompensation || compensated.NextState.PendingSpendJackpotTokens != 1 || compensated.NextState.SettledRoundPending ||
		compensated.Trace.SpendMilestoneTokensBefore != 1 || compensated.Trace.SpendMilestoneTokensAfter != 1 {
		t.Fatalf("48h qualification must win first without consuming spend token: %+v", compensated)
	}

	// A failed personal RTP condition consumes only the one-shot 48h qualification. The same child
	// draw may still use the independent spend promise because registration and personal RTP do not gate it.
	qualified.UserRoundRTP.PayoutCoins = 96
	spendFallback, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(84))
	if err != nil {
		t.Fatal(err)
	}
	if spendFallback.JackpotMechanism != StrategyJackpotMechanismMilestone || spendFallback.NextState.PendingSpendJackpotTokens != 0 || spendFallback.NextState.SettledRoundPending {
		t.Fatalf("ineligible 48h path should fall through to spend jackpot: %+v", spendFallback)
	}

	qualified.PoolBalanceCoins = 49
	qualified.UserRoundRTP.PayoutCoins = 95
	poolBlocked, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(85))
	if err != nil {
		t.Fatal(err)
	}
	if poolBlocked.Jackpot || poolBlocked.NextState.SettledRoundPending || poolBlocked.NextState.PendingSpendJackpotTokens != 1 {
		t.Fatalf("48h is one-shot while pool-blocked spend token remains: %+v", poolBlocked)
	}
}

func TestLuckyGiftStrategySpendJackpotSharesAllSixInclusiveRiskCaps(t *testing.T) {
	config := strategyTestConfig()
	config.SettledRoundRTPEligibility = true
	config.JackpotMechanism1Enabled = true
	config.JackpotMechanism2Enabled = true
	config.MilestoneSpendCoins = 50
	config.GlobalRTPMaxPPM = 980_000
	config.UserRoundRTPMaxPPM = 960_000
	config.User48HourRTPMaxPPM = 960_000
	config.DailyJackpotLimit = 5
	setters := []struct {
		name string
		set  func(*StrategyRiskCapacity, int64)
	}{
		{name: "single", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
		{name: "user-hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
		{name: "user-day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
		{name: "device-day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
		{name: "room-hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
		{name: "anchor-day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
	}
	for _, test := range setters {
		t.Run(test.name, func(t *testing.T) {
			state := StrategyState{
				PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1,
				SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
			}
			capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 10_000, UserHourCoins: 10_000, UserDayCoins: 10_000, DeviceDayCoins: 10_000, RoomHourCoins: 10_000, AnchorDayCoins: 10_000}
			test.set(&capacity, 1_999)
			input := strategyTestInput(10)
			input.RiskCapacity = capacity
			blocked, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(90))
			if err != nil {
				t.Fatal(err)
			}
			if blocked.Jackpot || blocked.NextState.PendingSpendJackpotTokens != 1 {
				t.Fatalf("capacity below minimum jackpot must retain token: %+v", blocked)
			}

			test.set(&capacity, 2_000)
			input.RiskCapacity = capacity
			paid, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(91))
			if err != nil {
				t.Fatal(err)
			}
			if !paid.Jackpot || paid.PayoutCoins != 2_000 || paid.NextState.PendingSpendJackpotTokens != 0 {
				t.Fatalf("capacity equality must permit the 200x jackpot: %+v", paid)
			}
		})
	}
}

func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) {
	config := strategyTestConfig()
	config.JackpotMechanism2Enabled = true
	config.DailyJackpotLimit = 5
	state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5}
	// 大奖 token 受每日上限阻断，但普通概率里的同倍率 200x 仍按原权重命中；
	// 倍率相同不代表来源相同，也不能消耗大奖次数或 token。
	random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}}
	decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random)
	if err != nil {
		t.Fatal(err)
	}
	if decision.Jackpot || decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 {
		t.Fatalf("daily-limit decision=%+v", decision)
	}
	if len(decision.Trace.Draws) != 1 || decision.Trace.Draws[0].RemovedReason != "" {
		t.Fatalf("ordinary overlap must not be removed by jackpot cap: %+v", decision.Trace.Draws)
	}
}

func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T) {
	config := strategyTestConfig()
	config.Tiers = []StrategyTier{
		{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
		{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: StrategyPPMScale, Enabled: true},
	}
	setters := []struct {
		name string
		set  func(*StrategyRiskCapacity, int64)
	}{
		{name: "single draw", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
		{name: "user hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
		{name: "user day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
		{name: "device day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
		{name: "room hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
		{name: "anchor day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
	}
	for _, setter := range setters {
		t.Run(setter.name, func(t *testing.T) {
			capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
			setter.set(&capacity, 50)
			input := strategyTestInput(10)
			input.RiskCapacity = capacity
			allowed, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
			if err != nil {
				t.Fatal(err)
			}
			if allowed.PayoutCoins != 50 || allowed.PoolAfterCoins != 0 {
				t.Fatalf("boundary must pass: %+v", allowed)
			}
			setter.set(&capacity, 49)
			input.RiskCapacity = capacity
			blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
			if err != nil {
				t.Fatal(err)
			}
			if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonNoPayableTier {
				t.Fatalf("below boundary must block: %+v", blocked)
			}
			if len(blocked.Trace.Removed) != 1 || blocked.Trace.Removed[0].TierID != "5x" || blocked.Trace.Removed[0].Reason != StrategyReasonRiskCapacity {
				t.Fatalf("risk retry must not remove zero: %+v", blocked.Trace.Removed)
			}
		})
	}
}

func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) {
	config := DefaultLuckyGiftStrategyConfig()
	config.RechargeStages = []StrategyRechargeStage{
		{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
		{Name: StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
		{Name: StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
	}
	stageCases := []struct {
		seven, thirty int64
		want          string
	}{
		{seven: 0, thirty: 0, want: StageNovice},
		// 两个非零充值仍低于 normal，必须留在 novice；novice 不是“只有零充值”。
		{seven: 99, thirty: 499, want: StageNovice},
		{seven: 100, thirty: 499, want: StageNovice},
		{seven: 99, thirty: 500, want: StageNovice},
		// 两维等于门槛都算达到；任一维未达 advanced 时仍保持 normal。
		{seven: 100, thirty: 500, want: StageNormal},
		{seven: 500, thirty: 1_999, want: StageNormal},
		{seven: 499, thirty: 2_000, want: StageNormal},
		{seven: 500, thirty: 2_000, want: StageAdvanced},
	}
	for _, tt := range stageCases {
		if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want {
			t.Fatalf("stage(%d,%d)=%s want=%s", tt.seven, tt.thirty, got, tt.want)
		}
	}
	split, err := SplitLuckyGiftStrategyFunds(100, config)
	if err != nil {
		t.Fatal(err)
	}
	if split != (StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
		t.Fatalf("split=%+v", split)
	}
	if split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins != 100 {
		t.Fatal("fund split did not conserve money")
	}
	small, err := SplitLuckyGiftStrategyFunds(10, config)
	if err != nil {
		t.Fatal(err)
	}
	if small != (StrategyFundSplit{PublicPoolCoins: 9, ProfitPoolCoins: 1, AnchorReturnCoins: 0}) {
		t.Fatalf("small split=%+v want public floor with residue in profit", small)
	}
	if initial := InitialLuckyGiftStrategyState(config); initial.PoolBalanceCoins != config.ColdStartPoolCoins || initial.PoolBalanceCoins < 0 {
		t.Fatalf("initial=%+v", initial)
	}
}

func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing.T) {
	config := DefaultLuckyGiftStrategyConfig()
	var weightedMultiplierMicros int64
	for _, tier := range config.Tiers {
		weightedMultiplierMicros += tier.BaseWeightPPM * tier.MultiplierPPM / StrategyPPMScale
	}
	if weightedMultiplierMicros != 980_000 {
		t.Fatalf("default nominal EV=%dppm want=980000ppm", weightedMultiplierMicros)
	}
	for _, tier := range config.Tiers {
		if tier.Jackpot && tier.BaseWeightPPM != 0 {
			t.Fatalf("default jackpot tier %s leaked into ordinary probability", tier.ID)
		}
	}
}

func TestLuckyGiftStrategyRejectsJackpotTierWithOrdinaryWeight(t *testing.T) {
	for _, mutate := range []func(*StrategyTier){
		func(tier *StrategyTier) { tier.BaseWeightPPM = 1 },
		func(tier *StrategyTier) { tier.StageWeightPPM = map[string]int64{StageNovice: 1} },
	} {
		config := DefaultLuckyGiftStrategyConfig()
		for index := range config.Tiers {
			if config.Tiers[index].Jackpot {
				mutate(&config.Tiers[index])
				break
			}
		}
		if _, _, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{}, strategyTestInput(10)); err == nil {
			t.Fatal("jackpot tier carrying an ordinary weight should be rejected")
		}
	}
}

func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
	config := strategyTestConfig()
	config.JackpotMechanism1Enabled = true
	config.JackpotMechanism2Enabled = true
	config.DailyJackpotLimit = 5
	state := StrategyState{
		PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
		GlobalRTP:     StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
		UserDayRTP:    StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
		User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
	}
	decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(8))
	if err != nil {
		t.Fatal(err)
	}
	if decision.JackpotMechanism != StrategyJackpotMechanismMilestone || !decision.ConsumedMilestoneToken {
		t.Fatalf("milestone must outrank mechanism1/miss protection: %+v", decision)
	}
}

func findWeightTrace(t *testing.T, traces []StrategyWeightTrace, tierID string) StrategyWeightTrace {
	t.Helper()
	for _, trace := range traces {
		if trace.TierID == tierID {
			return trace
		}
	}
	t.Fatalf("weight trace %s not found", tierID)
	return StrategyWeightTrace{}
}
