package luckygift

import (
	"errors"
	"fmt"
	"math"
	"math/big"
	"math/rand"
	"sort"
	"strings"
)

const (
	// StrategyPPMScale keeps probability, multiplier and adjustment arithmetic in one
	// integer unit.  The strategy never uses float64 for money or eligibility gates.
	StrategyPPMScale int64 = 1_000_000

	StrategyReasonZeroSelected              = "zero_selected"
	StrategyReasonPaid                      = "paid"
	StrategyReasonNoPayableTier             = "no_payable_nonzero_tier_fallback_zero"
	StrategyReasonMissProtectionBlocked     = "miss_protection_blocked_no_payable_tier"
	StrategyReasonMilestoneJackpot          = "milestone_token_jackpot"
	StrategyReasonRTPCompensationJackpot    = "rtp_compensation_jackpot"
	StrategyReasonMilestoneTokenRetained    = "milestone_token_retained_no_payable_jackpot"
	StrategyReasonDailyJackpotLimit         = "daily_jackpot_limit"
	StrategyReasonJackpotRateLimit          = "jackpot_rate_limit"
	StrategyReasonPoolInsufficient          = "pool_insufficient_w_gt_p"
	StrategyReasonRiskCapacity              = "risk_capacity_exceeded"
	StrategyReasonNoPayableJackpot          = "no_payable_jackpot_candidate"
	StrategyReasonSpendGlobalRTPBlocked     = "spend_global_rtp_gate_failed"
	StrategyJackpotMechanismMilestone       = "spend_milestone"
	StrategyJackpotMechanismRTPCompensation = "rtp_compensation"
	StrategyDrawKindOriginal                = "original"
	StrategyDrawKindRedraw                  = "redraw"
	StrategyDrawKindMilestone               = "jackpot_mechanism_2"
	StrategyDrawKindRTPCompensation         = "jackpot_mechanism_1"
	StrategyRemovalMissProtection           = "miss_protection_excludes_zero"
	StrategyRemovalPWRedrawZero             = "w_gt_p_redraw_excludes_zero"
	StrategyConditionGlobalRTP              = "global_rtp"
	StrategyConditionSpendGlobalRTP         = "spend_global_rtp"
	StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit"
	StrategyConditionJackpotRateLimit       = "jackpot_rate_limit"
	StrategyConditionSettledRound           = "settled_round_qualification"
	StrategyConditionUserRoundRTP           = "user_round_rtp"
	StrategyConditionUser48HourRTP          = "user_48h_rtp"
	StrategyConditionUser48HourMaturity     = "user_registered_48h"
	// StrategyConditionUser24HourRTPDownweight is an ordinary-draw control only. A trace can show
	// this condition as active on a jackpot decision, but neither jackpot selection path reads it.
	StrategyConditionUser24HourRTPDownweight = "user_24h_rtp_downweight"
	// Deprecated trace names remain available to the pure legacy compatibility path only.
	StrategyConditionUserDayRTP        = "user_day_rtp"
	StrategyConditionUser72HourRTP     = "user_72h_rtp"
	StrategyConditionDailyJackpotLimit = "daily_jackpot_limit"
	StrategyConditionPayableJackpot    = "payable_jackpot_candidate"
	StrategyDefaultZeroTierID          = "0x"
)

var (
	ErrStrategyConfig       = errors.New("lucky gift strategy config is invalid")
	ErrStrategyInput        = errors.New("lucky gift strategy input is invalid")
	ErrStrategyRandomSource = errors.New("lucky gift strategy random source is required")
)

// StrategyRandomSource is the only nondeterministic boundary of the kernel.
// Production should adapt crypto/rand; tests and strategy-sim deliberately inject a
// scripted or seeded source so every deletion/redraw path is reproducible.
type StrategyRandomSource interface {
	Int63n(n int64) (int64, error)
}

// StrategyRandomFunc lets infrastructure adapt an existing secure RNG without
// making the pure domain package depend on a concrete implementation.
type StrategyRandomFunc func(n int64) (int64, error)

func (f StrategyRandomFunc) Int63n(n int64) (int64, error) { return f(n) }

type seededStrategyRandom struct{ source *rand.Rand }

// NewSeededStrategyRandom is intentionally for deterministic simulation and tests.
// It must not be wired into the production draw path because math/rand is predictable.
func NewSeededStrategyRandom(seed int64) StrategyRandomSource {
	return &seededStrategyRandom{source: rand.New(rand.NewSource(seed))}
}

func (r *seededStrategyRandom) Int63n(n int64) (int64, error) {
	if r == nil || r.source == nil || n <= 0 {
		return 0, fmt.Errorf("%w: random bound must be positive", ErrStrategyInput)
	}
	return r.source.Int63n(n), nil
}

// StrategyTier is one source-specific selectable tier, not a multiplier identity.
// The same multiplier may have an ordinary tier and a separate jackpot tier:
// BaseWeightPPM drives only ordinary draws, while JackpotWeight is used only after
// all RTP, pool, daily-limit and risk gates pass.
type StrategyTier struct {
	ID             string           `json:"id"`
	MultiplierPPM  int64            `json:"multiplier_ppm"`
	BaseWeightPPM  int64            `json:"base_weight_ppm"`
	StageWeightPPM map[string]int64 `json:"stage_weight_ppm,omitempty"`
	Jackpot        bool             `json:"jackpot"`
	JackpotWeight  int64            `json:"jackpot_weight,omitempty"`
	Enabled        bool             `json:"enabled"`
}

// StrategyRechargeStage is ordered by its explicit seven-day and thirty-day
// recharge floors. A user enters the highest stage for which both floors pass.
type StrategyRechargeStage struct {
	Name                string `json:"name"`
	MinRecharge7DCoins  int64  `json:"min_recharge_7d_coins"`
	MinRecharge30DCoins int64  `json:"min_recharge_30d_coins"`
}

// StrategyConfig contains only immutable rule-version data. Runtime counters,
// pool money and persisted milestone tokens live in StrategyState instead.
type StrategyConfig struct {
	// RuleVersion binds durable spend tokens to the immutable rule that minted them. Zero keeps the
	// isolated legacy kernel source-compatible; production dynamic_v3 always supplies a positive version.
	RuleVersion int64          `json:"rule_version"`
	Tiers       []StrategyTier `json:"tiers"`

	PublicPoolRatePPM   int64 `json:"public_pool_rate_ppm"`
	ProfitPoolRatePPM   int64 `json:"profit_pool_rate_ppm"`
	AnchorReturnRatePPM int64 `json:"anchor_return_rate_ppm"`
	ColdStartPoolCoins  int64 `json:"cold_start_pool_coins"`

	LowWaterThresholdCoins  int64 `json:"low_water_threshold_coins"`
	HighWaterThresholdCoins int64 `json:"high_water_threshold_coins"`
	LowWaterFactorPPM       int64 `json:"low_water_factor_ppm"`
	HighWaterFactorPPM      int64 `json:"high_water_factor_ppm"`
	RechargeFactorPPM       int64 `json:"recharge_factor_ppm"`
	RechargeBoostStartMS    int64 `json:"recharge_boost_start_ms"`
	RechargeBoostEndMS      int64 `json:"recharge_boost_end_ms"`
	// A zero threshold disables the rolling control for historical rules. When enabled, FactorPPM
	// is the retained ordinary winning weight (700000 means keep 70%, not subtract 70 points).
	User24HourRTPThresholdPPM      int64 `json:"user_24h_rtp_threshold_ppm"`
	User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`

	MissProtectionZeroDraws  int64 `json:"miss_protection_zero_draws"`
	DailyJackpotLimit        int64 `json:"daily_jackpot_limit"`
	MilestoneSpendCoins      int64 `json:"milestone_spend_coins"`
	JackpotRateLimitWindowMS int64 `json:"jackpot_rate_limit_window_ms"`
	JackpotRateLimit         int64 `json:"jackpot_rate_limit"`

	JackpotMechanism1Enabled bool  `json:"jackpot_mechanism_1_enabled"`
	JackpotMechanism2Enabled bool  `json:"jackpot_mechanism_2_enabled"`
	GlobalRTPMaxPPM          int64 `json:"global_rtp_max_ppm"`
	UserRoundRTPMaxPPM       int64 `json:"user_round_rtp_max_ppm"`
	User48HourRTPMaxPPM      int64 `json:"user_48h_rtp_max_ppm"`
	// SettledRoundRTPEligibility switches production V3 to the closed-window qualification flow.
	// False preserves the isolated legacy kernel API used by fixed historical simulations; runtime V3 always sets true.
	SettledRoundRTPEligibility bool `json:"settled_round_rtp_eligibility"`
	// Deprecated aliases are read only by the legacy kernel branch.
	UserDayRTPMaxPPM    int64 `json:"user_day_rtp_max_ppm"`
	User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"`

	RechargeStages []StrategyRechargeStage `json:"recharge_stages"`
}

// StrategyRTP stores the auditable numerator and denominator. Supplying a derived
// ratio alone would lose the crucial denominator=0 distinction and could trigger a
// compensating jackpot for a user or platform with no settled history.
type StrategyRTP struct {
	WagerCoins  int64 `json:"wager_coins"`
	PayoutCoins int64 `json:"payout_coins"`
}

// StrategyState is the locked, persisted snapshot consumed by one draw. A repository
// must update it atomically with the draw record; Version is provided for CAS/optimistic
// locking but the kernel itself intentionally performs no I/O.
type StrategyState struct {
	PoolBalanceCoins     int64 `json:"pool_balance_coins"`
	ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"`
	// PendingMilestoneTokens is the legacy pure-kernel field. Production dynamic_v3 never reads it:
	// the separate field below prevents tokens created by the retired implementation from becoming
	// a jackpot promise after the dual-strategy rollout.
	PendingMilestoneTokens    int64 `json:"pending_milestone_tokens"`
	PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
	// PendingSpendJackpotRuleVersion makes a token promise meaningful only under the immutable rule
	// that created it. A rule switch expires the old backlog instead of repricing it at a new threshold.
	PendingSpendJackpotRuleVersion int64 `json:"pending_spend_jackpot_rule_version"`
	UserDailyJackpotWins           int64 `json:"user_daily_jackpot_wins"`
	// RecentJackpotWins is the app+pool global count in the configured rolling window, loaded while
	// the pool row is locked and advanced after each subdraw in the current batch.
	RecentJackpotWins int64 `json:"recent_jackpot_wins"`
	UserDaySpendCoins int64 `json:"user_day_spend_coins"`
	// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is
	// deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
	SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
	GlobalRTP      StrategyRTP `json:"global_rtp"`
	UserRoundRTP   StrategyRTP `json:"user_round_rtp"`
	User48HourRTP  StrategyRTP `json:"user_48h_rtp"`
	// User24HourRTP is loaded once at batch entry and advanced in memory after each subdraw. The
	// current subdraw therefore cannot downweight itself, while the next subdraw sees its outcome.
	User24HourRTP           StrategyRTP `json:"user_24h_rtp"`
	SettledRoundPending     bool        `json:"settled_round_pending"`
	QualificationClosedAtMS int64       `json:"qualification_closed_at_ms"`
	UserRegisteredAtMS      int64       `json:"user_registered_at_ms"`
	// Deprecated aliases are updated only by the legacy kernel branch.
	UserDayRTP    StrategyRTP `json:"user_day_rtp"`
	User72HourRTP StrategyRTP `json:"user_72h_rtp"`
	Version       int64       `json:"version"`
}

// StrategyRiskCapacity contains the six payout ceilings from the product rule.
// Enabled=false means the caller has not configured this gate. Once enabled, zero is
// a real zero capacity (not “unlimited”). SkipDeviceDay is the only dimension-level
// exception: legacy clients without a trusted device claim keep all other ceilings while
// intentionally omitting the device ceiling instead of inventing a resettable scope.
type StrategyRiskCapacity struct {
	Enabled         bool  `json:"enabled"`
	SkipDeviceDay   bool  `json:"skip_device_day,omitempty"`
	SingleDrawCoins int64 `json:"single_draw_coins"`
	UserHourCoins   int64 `json:"user_hour_coins"`
	UserDayCoins    int64 `json:"user_day_coins"`
	DeviceDayCoins  int64 `json:"device_day_coins"`
	RoomHourCoins   int64 `json:"room_hour_coins"`
	AnchorDayCoins  int64 `json:"anchor_day_coins"`
}

// StrategyInput contains current-request facts. PoolContributionCoins is credited
// before P/W comparison, matching the transaction order “successful gift -> split ->
// draw”. Recharge boost uses [start,end), guarded by HasRechargeFact.
type StrategyInput struct {
	GiftPriceCoins        int64                `json:"gift_price_coins"`
	PoolContributionCoins int64                `json:"pool_contribution_coins"`
	NowMS                 int64                `json:"now_ms"`
	HasRechargeFact       bool                 `json:"has_recharge_fact"`
	LastRechargeAtMS      int64                `json:"last_recharge_at_ms"`
	Recharge7DCoins       int64                `json:"recharge_7d_coins"`
	Recharge30DCoins      int64                `json:"recharge_30d_coins"`
	RiskCapacity          StrategyRiskCapacity `json:"risk_capacity"`
}

// StrategyWeightFactor records why a non-zero tier changed. Factors are multiplied,
// not added, so low-water+recharge is exactly 0.7*1.1 rather than an ambiguous 0.8.
type StrategyWeightFactor struct {
	Name      string `json:"name"`
	FactorPPM int64  `json:"factor_ppm"`
}

type StrategyWeightTrace struct {
	TierID               string                 `json:"tier_id"`
	MultiplierPPM        int64                  `json:"multiplier_ppm"`
	BaseWeightPPM        int64                  `json:"base_weight_ppm"`
	RawAdjustedWeightPPM int64                  `json:"raw_adjusted_weight_ppm"`
	AdjustedWeightPPM    int64                  `json:"adjusted_weight_ppm"`
	Factors              []StrategyWeightFactor `json:"factors"`
}

type StrategyDrawTrace struct {
	Sequence      int    `json:"sequence"`
	Kind          string `json:"kind"`
	TierID        string `json:"tier_id"`
	MultiplierPPM int64  `json:"multiplier_ppm"`
	WeightPPM     int64  `json:"weight_ppm"`
	PayoutCoins   int64  `json:"payout_coins"`
	RemovedReason string `json:"removed_reason,omitempty"`
}

type StrategyRemovalTrace struct {
	TierID string `json:"tier_id"`
	Reason string `json:"reason"`
}

type StrategyConditionTrace struct {
	Name        string `json:"name"`
	Numerator   int64  `json:"numerator,omitempty"`
	Denominator int64  `json:"denominator,omitempty"`
	RatioPPM    int64  `json:"ratio_ppm,omitempty"`
	LimitPPM    int64  `json:"limit_ppm,omitempty"`
	FactorPPM   int64  `json:"factor_ppm,omitempty"`
	Passed      bool   `json:"passed"`
	Reason      string `json:"reason"`
}

// DecisionTrace is deliberately verbose enough to replay an incident without
// reconstructing transient weights from the latest (possibly changed) config.
type DecisionTrace struct {
	Stage                      string                   `json:"stage"`
	PoolBeforeCoins            int64                    `json:"pool_before_coins"`
	PoolContributionCoins      int64                    `json:"pool_contribution_coins"`
	AvailablePoolCoins         int64                    `json:"available_pool_coins"`
	EffectiveRiskCapacityCoins int64                    `json:"effective_risk_capacity_coins"`
	Weights                    []StrategyWeightTrace    `json:"weights"`
	Draws                      []StrategyDrawTrace      `json:"draws"`
	Removed                    []StrategyRemovalTrace   `json:"removed"`
	Conditions                 []StrategyConditionTrace `json:"conditions,omitempty"`
	OriginalTierID             string                   `json:"original_tier_id,omitempty"`
	RedrawTierIDs              []string                 `json:"redraw_tier_ids,omitempty"`
	FinalReason                string                   `json:"final_reason"`
	BlockedReason              string                   `json:"blocked_reason,omitempty"`
	JackpotMechanism           string                   `json:"jackpot_mechanism,omitempty"`
	MilestoneTokenConsumed     bool                     `json:"spend_milestone_token_consumed"`
	MilestoneTokenRetained     bool                     `json:"spend_milestone_token_retained"`
	MilestoneTokensEarned      int64                    `json:"spend_milestone_tokens_earned"`
	// The exact before/after balance is persisted for every draw, including a successful 48h
	// compensation that deliberately leaves an existing spend token untouched. The boolean fields
	// above keep their event meaning; these counters make multi-token incident replay unambiguous.
	SpendMilestoneTokensBefore int64 `json:"pending_spend_jackpot_tokens_before"`
	SpendMilestoneTokensAfter  int64 `json:"pending_spend_jackpot_tokens_after"`
}

type StrategyDecision struct {
	SelectedTier           StrategyTier  `json:"selected_tier"`
	PayoutCoins            int64         `json:"payout_coins"`
	PoolAfterCoins         int64         `json:"pool_after_coins"`
	Stage                  string        `json:"stage"`
	Jackpot                bool          `json:"jackpot"`
	JackpotMechanism       string        `json:"jackpot_mechanism,omitempty"`
	ConsumedMilestoneToken bool          `json:"consumed_milestone_token"`
	NextState              StrategyState `json:"next_state"`
	Trace                  DecisionTrace `json:"trace"`
}

type StrategyFundSplit struct {
	PublicPoolCoins   int64 `json:"public_pool_coins"`
	ProfitPoolCoins   int64 `json:"profit_pool_coins"`
	AnchorReturnCoins int64 `json:"anchor_return_coins"`
}

type weightedStrategyTier struct {
	Tier   StrategyTier
	Weight int64
	// MissProtectionWeight freezes the ordinary probability after the existing water/recharge
	// normalization but before the optional 24-hour RTP downweight. Protection is a hard product
	// guarantee: integer apportioning may round a small retained tier to zero, but that rounding must
	// not make an otherwise payable ordinary prize ineligible once the miss threshold is reached.
	MissProtectionWeight int64
}

// DefaultLuckyGiftStrategyConfig is a financially safe production baseline: its
// ordinary table has exactly 98% nominal EV. The deliberately unsafe 2650% table in
// the supplied image belongs only to strategy-sim and must never become a runtime
// fallback merely because a rule version is missing.
func DefaultLuckyGiftStrategyConfig() StrategyConfig {
	return StrategyConfig{
		Tiers: []StrategyTier{
			{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 50_000, Enabled: true},
			{ID: "0.5x", MultiplierPPM: 500_000, BaseWeightPPM: 40_000, Enabled: true},
			{ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 860_000, Enabled: true},
			{ID: "2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 50_000, Enabled: true},
			{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 4, Enabled: true},
			{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
			{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 1, Enabled: true},
		},
		PublicPoolRatePPM: 980_000, ProfitPoolRatePPM: 10_000, AnchorReturnRatePPM: 10_000,
		ColdStartPoolCoins:     0,
		LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000,
		LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000,
		RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000,
		User24HourRTPThresholdPPM: 1_500_000, User24HourOrdinaryWinFactorPPM: 700_000,
		// 累计消费门槛是不可变规则版本的动态金币配置；0 表示该内核默认值尚未配置。
		MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0,
		JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true,
		GlobalRTPMaxPPM: 980_000, UserDayRTPMaxPPM: 960_000, User72HourRTPMaxPPM: 960_000,
		RechargeStages: []StrategyRechargeStage{
			// 三个 0/0 仅用于未配置内核默认值；可运行规则必须由发布层注入 normal/advanced 门槛。
			{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
			{Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
			{Name: StageAdvanced, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
		},
	}
}

func InitialLuckyGiftStrategyState(config StrategyConfig) StrategyState {
	return StrategyState{PoolBalanceCoins: config.ColdStartPoolCoins}
}

// SplitLuckyGiftStrategyFunds applies the 98/1/1 (or configured) split without
// floating point. Public and anchor buckets use floor; all indivisible residue goes
// to profit. This is intentionally conservative: a 10-coin draw becomes 9/1/0, never
// 10/0/0, so integer rounding cannot inflate the public payout budget above 98%.
func SplitLuckyGiftStrategyFunds(amount int64, config StrategyConfig) (StrategyFundSplit, error) {
	if amount < 0 {
		return StrategyFundSplit{}, fmt.Errorf("%w: split amount cannot be negative", ErrStrategyInput)
	}
	rates := []int64{config.PublicPoolRatePPM, config.ProfitPoolRatePPM, config.AnchorReturnRatePPM}
	var sum int64
	for _, rate := range rates {
		if rate < 0 || rate > StrategyPPMScale {
			return StrategyFundSplit{}, fmt.Errorf("%w: split rate must be within [0,100%%]", ErrStrategyConfig)
		}
		var err error
		sum, err = safeAdd(sum, rate)
		if err != nil {
			return StrategyFundSplit{}, err
		}
	}
	if sum != StrategyPPMScale {
		return StrategyFundSplit{}, fmt.Errorf("%w: split rates sum=%d want=%d", ErrStrategyConfig, sum, StrategyPPMScale)
	}
	public := mulDiv(amount, config.PublicPoolRatePPM, StrategyPPMScale)
	anchor := mulDiv(amount, config.AnchorReturnRatePPM, StrategyPPMScale)
	if public < 0 || anchor < 0 || public > amount-anchor {
		return StrategyFundSplit{}, fmt.Errorf("%w: split arithmetic overflow", ErrStrategyInput)
	}
	return StrategyFundSplit{PublicPoolCoins: public, ProfitPoolCoins: amount - public - anchor, AnchorReturnCoins: anchor}, nil
}

// MilestoneTokensEarned computes only newly crossed whole milestones. This pure
// delta is safe to use in a periodic persisted-token job and does not grant the same
// configured cumulative-spend threshold again after a retry.
func MilestoneTokensEarned(previousSpend, currentSpend, milestone int64) int64 {
	if milestone <= 0 || previousSpend < 0 || currentSpend <= previousSpend {
		return 0
	}
	return currentSpend/milestone - previousSpend/milestone
}

// SelectLuckyGiftRechargeStage 只用 normal/advanced 的版本化门槛向上提档；novice 是无条件兜底，
// 其 0/0 仅为协议和存储 sentinel。两维必须同时达到才能提档，所以任一维低于 normal 都是 novice，等于门槛则通过该维。
func SelectLuckyGiftRechargeStage(config StrategyConfig, recharge7D, recharge30D int64) string {
	stages := make(map[string]StrategyRechargeStage, len(config.RechargeStages))
	for _, stage := range config.RechargeStages {
		stages[stage.Name] = stage
	}
	if rechargeStageConfiguredAndReached(stages[StageAdvanced], recharge7D, recharge30D) {
		return StageAdvanced
	}
	if rechargeStageConfiguredAndReached(stages[StageNormal], recharge7D, recharge30D) {
		return StageNormal
	}
	return StageNovice
}

func rechargeStageConfiguredAndReached(stage StrategyRechargeStage, recharge7D, recharge30D int64) bool {
	// normal/advanced 的 0/0 表示 disabled 草稿尚未配置，不能因为任意非负值都 >=0 而意外提档。
	if stage.MinRecharge7DCoins == 0 && stage.MinRecharge30DCoins == 0 {
		return false
	}
	return recharge7D >= stage.MinRecharge7DCoins && recharge30D >= stage.MinRecharge30DCoins
}

// PreviewLuckyGiftStrategyWeights exposes exactly the same weight builder used by
// DecideLuckyGiftStrategy, allowing admin preview and offline simulation to stay in
// lockstep with production decisions.
func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState, input StrategyInput) (string, []StrategyWeightTrace, error) {
	if err := validateStrategy(config, state, input, false); err != nil {
		return "", nil, err
	}
	available, err := safeAdd(state.PoolBalanceCoins, input.PoolContributionCoins)
	if err != nil {
		return "", nil, err
	}
	stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins)
	_, traces, err := buildStrategyWeights(config, state, stage, available, input)
	return stage, traces, err
}

// DecideLuckyGiftStrategy is the shared production/simulation kernel. Production V3 evaluates the
// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and
// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all
// failed global-RTP, daily-limit, pool and risk checks leave it available for a later paid draw.
func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) {
	if random == nil {
		return StrategyDecision{}, ErrStrategyRandomSource
	}
	if err := validateStrategy(config, state, input, true); err != nil {
		return StrategyDecision{}, err
	}
	available, err := safeAdd(state.PoolBalanceCoins, input.PoolContributionCoins)
	if err != nil {
		return StrategyDecision{}, err
	}
	stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins)
	weighted, weightTrace, err := buildStrategyWeights(config, state, stage, available, input)
	if err != nil {
		return StrategyDecision{}, err
	}
	trace := DecisionTrace{
		Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins,
		PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available,
		EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace,
		SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state),
		SpendMilestoneTokensAfter:  pendingSpendJackpotTokens(config, state),
	}
	// Record the personal rolling condition before either jackpot path runs. It is intentionally
	// observational for jackpots: only buildStrategyWeights consumes the active flag.
	condition, _ := user24HourRTPDownweightCondition(config, state.User24HourRTP)
	trace.Conditions = append(trace.Conditions, condition)

	if config.SettledRoundRTPEligibility {
		decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace)
		if err != nil {
			return StrategyDecision{}, err
		}
		if decided {
			return decision, nil
		}
		decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace)
		if err != nil {
			return StrategyDecision{}, err
		}
		if decided {
			return decision, nil
		}
	} else {
		// The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3
		// always takes the branch above and therefore never reads PendingMilestoneTokens.
		decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace)
		if err != nil {
			return StrategyDecision{}, err
		}
		if decided {
			return decision, nil
		}
		decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace)
		if err != nil {
			return StrategyDecision{}, err
		}
		if decided {
			return decision, nil
		}
	}

	tier, payout, ordinaryTrace, err := selectOrdinaryTier(config, state, input, available, weighted, random)
	if err != nil {
		return StrategyDecision{}, err
	}
	trace.Draws = append(trace.Draws, ordinaryTrace.Draws...)
	trace.Removed = append(trace.Removed, ordinaryTrace.Removed...)
	trace.OriginalTierID = ordinaryTrace.OriginalTierID
	trace.RedrawTierIDs = append(trace.RedrawTierIDs, ordinaryTrace.RedrawTierIDs...)
	trace.FinalReason = ordinaryTrace.FinalReason
	if ordinaryTrace.BlockedReason != "" {
		trace.BlockedReason = ordinaryTrace.BlockedReason
	}
	return finalizeStrategyDecision(config, state, input, available, tier, payout, "", false, trace)
}

func tryRTPCompensationJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, trace *DecisionTrace) (StrategyDecision, bool, error) {
	if !config.JackpotMechanism1Enabled {
		return StrategyDecision{}, false, nil
	}
	conditions, eligible := mechanismOneConditions(config, state)
	trace.Conditions = append(trace.Conditions, conditions...)
	if !eligible {
		return StrategyDecision{}, false, nil
	}
	tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
	if err != nil {
		return StrategyDecision{}, false, err
	}
	trace.Removed = append(trace.Removed, removed...)
	trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
	if !ok {
		if trace.BlockedReason == "" {
			trace.BlockedReason = reason
		}
		return StrategyDecision{}, false, nil
	}
	trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation
	trace.FinalReason = StrategyReasonRTPCompensationJackpot
	trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
	decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, *trace)
	return decision, err == nil, err
}

func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, requireSettledGlobal bool, trace *DecisionTrace) (StrategyDecision, bool, error) {
	if !config.JackpotMechanism2Enabled || pendingSpendJackpotTokens(config, state) <= 0 {
		return StrategyDecision{}, false, nil
	}
	if requireSettledGlobal {
		ratio, valid := state.SpendGlobalRTP.RatioPPM()
		globalPassed := valid && rtpAtOrBelowExact(state.SpendGlobalRTP, config.GlobalRTPMaxPPM)
		trace.Conditions = append(trace.Conditions, rtpAtOrBelowConditionTrace(StrategyConditionSpendGlobalRTP, state.SpendGlobalRTP, ratio, config.GlobalRTPMaxPPM, valid, globalPassed))
		dailyPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
		trace.Conditions = append(trace.Conditions, StrategyConditionTrace{
			Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit,
			Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit),
		})
		rateCondition, ratePassed := jackpotRateLimitCondition(config, state)
		trace.Conditions = append(trace.Conditions, rateCondition)
		if !globalPassed || !dailyPassed || !ratePassed {
			trace.MilestoneTokenRetained = true
			if trace.BlockedReason == "" {
				trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked
				if globalPassed && !dailyPassed {
					trace.BlockedReason = StrategyReasonDailyJackpotLimit
				} else if globalPassed && dailyPassed {
					trace.BlockedReason = StrategyReasonJackpotRateLimit
				}
			}
			return StrategyDecision{}, false, nil
		}
	}
	tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
	if err != nil {
		return StrategyDecision{}, false, err
	}
	trace.Removed = append(trace.Removed, removed...)
	trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
	if !ok {
		trace.MilestoneTokenRetained = true
		if trace.BlockedReason == "" {
			trace.BlockedReason = reason
		}
		return StrategyDecision{}, false, nil
	}
	trace.JackpotMechanism = StrategyJackpotMechanismMilestone
	trace.MilestoneTokenConsumed = true
	trace.FinalReason = StrategyReasonMilestoneJackpot
	trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout})
	decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, *trace)
	return decision, err == nil, err
}

func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 {
	if config.SettledRoundRTPEligibility {
		if config.RuleVersion > 0 && state.PendingSpendJackpotRuleVersion != config.RuleVersion {
			return 0
		}
		return state.PendingSpendJackpotTokens
	}
	return state.PendingMilestoneTokens
}

// jackpotRateLimitCondition is shared by both jackpot mechanisms. A 0/0 pair is the rolling-upgrade
// sentinel for immutable rules published before this gate existed; new drafts carry an explicit window.
func jackpotRateLimitCondition(config StrategyConfig, state StrategyState) (StrategyConditionTrace, bool) {
	enabled := config.JackpotRateLimitWindowMS > 0 && config.JackpotRateLimit > 0
	passed := !enabled || state.RecentJackpotWins < config.JackpotRateLimit
	reason := "disabled_for_historical_rule"
	if enabled {
		reason = boolReason(passed, "below_limit", StrategyReasonJackpotRateLimit)
	}
	return StrategyConditionTrace{
		Name: StrategyConditionJackpotRateLimit, Numerator: state.RecentJackpotWins,
		Denominator: config.JackpotRateLimitWindowMS, LimitPPM: config.JackpotRateLimit,
		Passed: passed, Reason: reason,
	}, passed
}

func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error {
	if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.PendingSpendJackpotRuleVersion < 0 || state.UserDailyJackpotWins < 0 || state.RecentJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
		return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput)
	}
	for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.User24HourRTP, state.UserDayRTP, state.User72HourRTP} {
		if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 {
			return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput)
		}
	}
	if input.GiftPriceCoins <= 0 || input.PoolContributionCoins < 0 || input.Recharge7DCoins < 0 || input.Recharge30DCoins < 0 {
		return fmt.Errorf("%w: gift price must be positive and input money cannot be negative", ErrStrategyInput)
	}
	if input.NowMS < 0 || (input.HasRechargeFact && input.LastRechargeAtMS < 0) {
		return fmt.Errorf("%w: request and recharge timestamps cannot be negative", ErrStrategyInput)
	}
	if config.LowWaterThresholdCoins < 0 || config.HighWaterThresholdCoins < config.LowWaterThresholdCoins || config.LowWaterFactorPPM <= 0 || config.HighWaterFactorPPM <= 0 || config.RechargeFactorPPM <= 0 {
		return fmt.Errorf("%w: water thresholds or dynamic factors are invalid", ErrStrategyConfig)
	}
	user24HourFactor, _ := normalizedUser24HourOrdinaryWinFactor(config)
	if config.User24HourRTPThresholdPPM < 0 || user24HourFactor <= 0 || user24HourFactor > StrategyPPMScale ||
		(config.User24HourRTPThresholdPPM == 0 && user24HourFactor != StrategyPPMScale) {
		return fmt.Errorf("%w: user 24h RTP threshold/factor must be disabled as 0/100%% or use a positive threshold and retained factor within (0,100%%]", ErrStrategyConfig)
	}
	if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 {
		return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig)
	}
	if (config.JackpotRateLimitWindowMS == 0) != (config.JackpotRateLimit == 0) || config.JackpotRateLimitWindowMS < 0 || config.JackpotRateLimit < 0 {
		return fmt.Errorf("%w: jackpot rolling-window duration and hit limit must both be zero or positive", ErrStrategyConfig)
	}
	if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale ||
		config.UserRoundRTPMaxPPM < 0 || config.UserRoundRTPMaxPPM > StrategyPPMScale ||
		config.User48HourRTPMaxPPM < 0 || config.User48HourRTPMaxPPM > StrategyPPMScale ||
		config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale ||
		config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale {
		return fmt.Errorf("%w: RTP jackpot limits must be within [0,100%%]", ErrStrategyConfig)
	}
	var splitRateTotal int64
	for _, rate := range []int64{config.PublicPoolRatePPM, config.ProfitPoolRatePPM, config.AnchorReturnRatePPM} {
		if rate < 0 || rate > StrategyPPMScale {
			return fmt.Errorf("%w: fund split rates must be within [0,100%%]", ErrStrategyConfig)
		}
		splitRateTotal += rate
	}
	if splitRateTotal != StrategyPPMScale {
		return fmt.Errorf("%w: fund split rates must sum to 100%%", ErrStrategyConfig)
	}
	if len(config.RechargeStages) != 3 {
		return fmt.Errorf("%w: exactly three recharge stages are required", ErrStrategyConfig)
	}
	seenStages := map[string]bool{}
	for index, stage := range config.RechargeStages {
		if strings.TrimSpace(stage.Name) == "" || stage.MinRecharge7DCoins < 0 || stage.MinRecharge30DCoins < 0 || seenStages[stage.Name] {
			return fmt.Errorf("%w: recharge stages must have unique names and non-negative floors", ErrStrategyConfig)
		}
		if index > 0 && (stage.MinRecharge7DCoins < config.RechargeStages[index-1].MinRecharge7DCoins || stage.MinRecharge30DCoins < config.RechargeStages[index-1].MinRecharge30DCoins) {
			return fmt.Errorf("%w: recharge stage floors must be monotonic", ErrStrategyConfig)
		}
		seenStages[stage.Name] = true
	}
	if !requireTiers && len(config.Tiers) == 0 {
		return nil
	}
	seen := map[string]bool{}
	zeroCount := 0
	for _, tier := range config.Tiers {
		if !tier.Enabled {
			continue
		}
		if strings.TrimSpace(tier.ID) == "" || seen[tier.ID] || tier.MultiplierPPM < 0 || tier.BaseWeightPPM < 0 || tier.JackpotWeight < 0 {
			return fmt.Errorf("%w: enabled tiers require unique IDs and non-negative multiplier/weights", ErrStrategyConfig)
		}
		// 大奖身份属于受 RTP 条件控制的独立路径，不能同时携带普通随机权重；
		// 同倍率普通奖应使用另一个 Jackpot=false tier，避免每日 cap 和审计来源再次耦合。
		if tier.Jackpot && tier.BaseWeightPPM > 0 {
			return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary base weight", ErrStrategyConfig, tier.ID)
		}
		seen[tier.ID] = true
		if tier.MultiplierPPM == 0 {
			zeroCount++
		}
		for stage, weight := range tier.StageWeightPPM {
			if !seenStages[stage] || weight < 0 {
				return fmt.Errorf("%w: tier %s has unknown stage or negative stage weight", ErrStrategyConfig, tier.ID)
			}
			if tier.Jackpot && weight > 0 {
				return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary stage weight", ErrStrategyConfig, tier.ID)
			}
		}
	}
	if len(seen) == 0 || zeroCount != 1 {
		return fmt.Errorf("%w: enabled tiers require exactly one zero multiplier", ErrStrategyConfig)
	}
	if input.RiskCapacity.Enabled {
		for _, capacity := range input.RiskCapacity.values() {
			if capacity < 0 {
				return fmt.Errorf("%w: enabled risk capacities cannot be negative", ErrStrategyInput)
			}
		}
	}
	return nil
}

func buildStrategyWeights(config StrategyConfig, state StrategyState, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) {
	tiers := make([]weightedStrategyTier, 0, len(config.Tiers))
	traces := make([]StrategyWeightTrace, 0, len(config.Tiers))
	positiveIndexes := make([]int, 0, len(config.Tiers))
	ordinaryPositiveIndexes := make([]int, 0, len(config.Tiers))
	var positiveSum int64
	zeroIndex := -1
	for _, tier := range config.Tiers {
		if !tier.Enabled {
			continue
		}
		base := tier.BaseWeightPPM
		if stageWeight, ok := tier.StageWeightPPM[stage]; ok {
			base = stageWeight
		}
		trace := StrategyWeightTrace{TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, BaseWeightPPM: base}
		weight := base
		if tier.MultiplierPPM == 0 {
			zeroIndex = len(tiers)
			trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "zero_remainder", FactorPPM: StrategyPPMScale})
		} else {
			if available < config.LowWaterThresholdCoins {
				weight = mulPPM(weight, config.LowWaterFactorPPM)
				trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "low_water", FactorPPM: config.LowWaterFactorPPM})
			} else if available > config.HighWaterThresholdCoins {
				weight = mulPPM(weight, config.HighWaterFactorPPM)
				trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "high_water", FactorPPM: config.HighWaterFactorPPM})
			}
			if rechargeBoostActive(config, input) {
				weight = mulPPM(weight, config.RechargeFactorPPM)
				trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "recent_recharge", FactorPPM: config.RechargeFactorPPM})
			}
			if len(trace.Factors) == 0 {
				trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "identity", FactorPPM: StrategyPPMScale})
			}
			if weight < 0 {
				return nil, nil, fmt.Errorf("%w: adjusted tier weight overflow", ErrStrategyConfig)
			}
			var err error
			positiveSum, err = safeAdd(positiveSum, weight)
			if err != nil {
				return nil, nil, err
			}
			positiveIndexes = append(positiveIndexes, len(tiers))
			if !tier.Jackpot {
				ordinaryPositiveIndexes = append(ordinaryPositiveIndexes, len(tiers))
			}
		}
		trace.RawAdjustedWeightPPM = weight
		tiers = append(tiers, weightedStrategyTier{Tier: tier, Weight: weight})
		traces = append(traces, trace)
	}
	if zeroIndex < 0 {
		return nil, nil, fmt.Errorf("%w: zero tier not found", ErrStrategyConfig)
	}
	if positiveSum <= StrategyPPMScale {
		tiers[zeroIndex].Weight = StrategyPPMScale - positiveSum
	} else {
		rates := make([]int64, 0, len(positiveIndexes))
		for _, index := range positiveIndexes {
			rates = append(rates, tiers[index].Weight)
		}
		normalized, err := apportion(StrategyPPMScale, rates, positiveSum)
		if err != nil {
			return nil, nil, err
		}
		for i, index := range positiveIndexes {
			tiers[index].Weight = normalized[i]
		}
		tiers[zeroIndex].Weight = 0
	}
	// Snapshot the complete ordinary table at the exact point where the historical algorithm ended.
	// The 24-hour control is applied below only to normal random selection; miss protection restores
	// these weights so its eligibility cannot be weakened by the later retained-mass rounding.
	for index := range tiers {
		if tiers[index].Tier.MultiplierPPM > 0 && !tiers[index].Tier.Jackpot {
			tiers[index].MissProtectionWeight = tiers[index].Weight
		}
	}
	_, downweightActive := user24HourRTPDownweightCondition(config, state.User24HourRTP)
	if downweightActive {
		// Water-level and recharge factors first shape the relative ordinary table. Only after the
		// normal 1,000,000-PPM normalization do we shrink the complete non-zero ordinary mass, so a
		// table previously normalized to 100% becomes exactly 70% when the retained factor is 70%.
		factor, _ := normalizedUser24HourOrdinaryWinFactor(config)
		var ordinaryPositiveSum int64
		ordinaryWeights := make([]int64, 0, len(ordinaryPositiveIndexes))
		for _, index := range ordinaryPositiveIndexes {
			ordinaryWeights = append(ordinaryWeights, tiers[index].Weight)
			var err error
			ordinaryPositiveSum, err = safeAdd(ordinaryPositiveSum, tiers[index].Weight)
			if err != nil {
				return nil, nil, err
			}
		}
		if ordinaryPositiveSum > 0 {
			targetPositiveSum := mulPPM(ordinaryPositiveSum, factor)
			// PPM is discrete. Preserve one selectable unit for any non-empty configured table so miss
			// protection can still guarantee a payable ordinary award even at a very small factor.
			if targetPositiveSum == 0 {
				targetPositiveSum = 1
			}
			downweighted, err := apportion(targetPositiveSum, ordinaryWeights, ordinaryPositiveSum)
			if err != nil {
				return nil, nil, err
			}
			for offset, index := range ordinaryPositiveIndexes {
				tiers[index].Weight = downweighted[offset]
				traces[index].Factors = append(traces[index].Factors, StrategyWeightFactor{
					Name: StrategyConditionUser24HourRTPDownweight, FactorPPM: factor,
				})
			}
			tiers[zeroIndex].Weight = StrategyPPMScale - targetPositiveSum
		}
	}
	for index := range tiers {
		traces[index].AdjustedWeightPPM = tiers[index].Weight
	}
	return tiers, traces, nil
}

func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool {
	if !input.HasRechargeFact || input.LastRechargeAtMS > input.NowMS {
		return false
	}
	age := input.NowMS - input.LastRechargeAtMS
	return age >= config.RechargeBoostStartMS && age < config.RechargeBoostEndMS
}

func normalizedUser24HourOrdinaryWinFactor(config StrategyConfig) (int64, bool) {
	factor := config.User24HourOrdinaryWinFactorPPM
	// Pure-kernel callers compiled before this field existed still produce 0/0 struct literals. The
	// published-rule adapter canonicalizes them to 0/100%, while this guard preserves source-level
	// compatibility without turning a missing factor into “remove every win”.
	if config.User24HourRTPThresholdPPM == 0 && factor == 0 {
		factor = StrategyPPMScale
	}
	enabled := config.User24HourRTPThresholdPPM > 0 && factor > 0 && factor < StrategyPPMScale
	return factor, enabled
}

func user24HourRTPDownweightCondition(config StrategyConfig, rtp StrategyRTP) (StrategyConditionTrace, bool) {
	factor, enabled := normalizedUser24HourOrdinaryWinFactor(config)
	ratio, valid := rtp.RatioPPM()
	active := enabled && valid && compareRTPExact(rtp, config.User24HourRTPThresholdPPM) > 0
	reason := "at_or_below_threshold_normal_weight"
	switch {
	case !enabled:
		reason = "disabled_or_identity_factor"
	case !valid:
		reason = "denominator_zero_normal_weight"
	case active:
		reason = "above_threshold_downweighted"
	}
	return StrategyConditionTrace{
		Name:      StrategyConditionUser24HourRTPDownweight,
		Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins,
		RatioPPM: ratio, LimitPPM: config.User24HourRTPThresholdPPM, FactorPPM: factor,
		Passed: active, Reason: reason,
	}, active
}

func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
	if config.SettledRoundRTPEligibility {
		return settledRoundJackpotConditions(config, state)
	}
	// Historical pure-kernel callers used opportunistic current/day/72h checks. Keeping this branch
	// source-compatible does not expose it to dynamic_v3: the production adapter always enables the
	// settled-round flag and disables the independent milestone mechanism.
	checks := []struct {
		name  string
		rtp   StrategyRTP
		limit int64
	}{
		{name: StrategyConditionGlobalRTP, rtp: state.GlobalRTP, limit: config.GlobalRTPMaxPPM},
		{name: StrategyConditionUserDayRTP, rtp: state.UserDayRTP, limit: config.UserDayRTPMaxPPM},
		{name: StrategyConditionUser72HourRTP, rtp: state.User72HourRTP, limit: config.User72HourRTPMaxPPM},
	}
	traces := make([]StrategyConditionTrace, 0, len(checks)+2)
	eligible := true
	for _, check := range checks {
		ratio, valid := check.rtp.RatioPPM()
		passed := valid && ratio <= check.limit
		reason := "within_limit"
		if !valid {
			reason = "denominator_zero"
		} else if !passed {
			reason = "above_limit"
		}
		traces = append(traces, StrategyConditionTrace{Name: check.name, Numerator: check.rtp.PayoutCoins, Denominator: check.rtp.WagerCoins, RatioPPM: ratio, LimitPPM: check.limit, Passed: passed, Reason: reason})
		eligible = eligible && passed
	}
	limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
	traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)})
	rateCondition, ratePassed := jackpotRateLimitCondition(config, state)
	traces = append(traces, rateCondition)
	return traces, eligible && limitPassed && ratePassed
}

func settledRoundJackpotConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
	traces := make([]StrategyConditionTrace, 0, 7)
	pending := state.SettledRoundPending
	traces = append(traces, StrategyConditionTrace{
		Name: StrategyConditionSettledRound, Passed: pending,
		Reason: boolReason(pending, "closed_round_pending", "no_closed_round_pending"),
	})
	eligible := pending

	// The platform permits equality at 98%, while both user gates are deliberately strict: an exact
	// 96% user ratio is not “losing more than two points” and must not receive compensation.
	globalRatio, globalValid := state.GlobalRTP.RatioPPM()
	globalPassed := globalValid && rtpAtOrBelowExact(state.GlobalRTP, config.GlobalRTPMaxPPM)
	traces = append(traces, rtpConditionTrace(StrategyConditionGlobalRTP, state.GlobalRTP, globalRatio, config.GlobalRTPMaxPPM, globalValid, globalPassed))
	eligible = eligible && globalPassed

	roundRatio, roundValid := state.UserRoundRTP.RatioPPM()
	roundPassed := roundValid && rtpBelowExact(state.UserRoundRTP, config.UserRoundRTPMaxPPM)
	traces = append(traces, rtpConditionTrace(StrategyConditionUserRoundRTP, state.UserRoundRTP, roundRatio, config.UserRoundRTPMaxPPM, roundValid, roundPassed))
	eligible = eligible && roundPassed

	const maturityMS = int64(48 * 60 * 60 * 1000)
	ageMS := int64(0)
	if state.UserRegisteredAtMS > 0 && state.QualificationClosedAtMS >= state.UserRegisteredAtMS {
		ageMS = state.QualificationClosedAtMS - state.UserRegisteredAtMS
	}
	mature := state.UserRegisteredAtMS > 0 && ageMS >= maturityMS
	traces = append(traces, StrategyConditionTrace{
		Name: StrategyConditionUser48HourMaturity, Numerator: ageMS, Denominator: maturityMS,
		Passed: mature, Reason: boolReason(mature, "registered_48h", "registered_under_48h_or_unknown"),
	})
	eligible = eligible && mature

	rollingRatio, rollingValid := state.User48HourRTP.RatioPPM()
	rollingPassed := rollingValid && rtpBelowExact(state.User48HourRTP, config.User48HourRTPMaxPPM)
	traces = append(traces, rtpConditionTrace(StrategyConditionUser48HourRTP, state.User48HourRTP, rollingRatio, config.User48HourRTPMaxPPM, rollingValid, rollingPassed))
	eligible = eligible && rollingPassed

	limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
	traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)})
	rateCondition, ratePassed := jackpotRateLimitCondition(config, state)
	traces = append(traces, rateCondition)
	return traces, eligible && limitPassed && ratePassed
}

func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
	reason := "within_limit"
	if !valid {
		reason = "denominator_zero"
	} else if !passed {
		reason = "above_or_equal_limit"
	}
	return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}

func rtpAtOrBelowConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
	reason := "within_limit"
	if !valid {
		reason = "denominator_zero"
	} else if !passed {
		reason = "above_limit"
	}
	return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}

func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool {
	return compareRTPExact(rtp, limitPPM) < 0
}

func rtpAtOrBelowExact(rtp StrategyRTP, limitPPM int64) bool {
	return compareRTPExact(rtp, limitPPM) <= 0
}

func compareRTPExact(rtp StrategyRTP, limitPPM int64) int {
	// Cross multiplication keeps the configured mathematical boundary exact even when payout*1e6
	// exceeds int64. RatioPPM remains a display/audit value and is never trusted for eligibility.
	left := new(big.Int).Mul(big.NewInt(rtp.PayoutCoins), big.NewInt(StrategyPPMScale))
	right := new(big.Int).Mul(big.NewInt(rtp.WagerCoins), big.NewInt(limitPPM))
	return left.Cmp(right)
}

func selectPayableJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource) (StrategyTier, int64, bool, string, []StrategyRemovalTrace, error) {
	if state.UserDailyJackpotWins >= config.DailyJackpotLimit {
		return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil
	}
	if _, passed := jackpotRateLimitCondition(config, state); !passed {
		return StrategyTier{}, 0, false, StrategyReasonJackpotRateLimit, nil, nil
	}
	candidates := make([]weightedStrategyTier, 0, len(config.Tiers))
	removed := make([]StrategyRemovalTrace, 0, len(config.Tiers))
	for _, tier := range config.Tiers {
		if !tier.Enabled || !tier.Jackpot || tier.MultiplierPPM <= 0 {
			continue
		}
		payout, err := strategyPayout(input.GiftPriceCoins, tier.MultiplierPPM)
		if err != nil {
			return StrategyTier{}, 0, false, "", nil, err
		}
		if payout > available {
			removed = append(removed, StrategyRemovalTrace{TierID: tier.ID, Reason: StrategyReasonPoolInsufficient})
			continue
		}
		if payout > input.RiskCapacity.capacity() {
			removed = append(removed, StrategyRemovalTrace{TierID: tier.ID, Reason: StrategyReasonRiskCapacity})
			continue
		}
		candidates = append(candidates, weightedStrategyTier{Tier: tier, Weight: jackpotWeight(tier)})
	}
	if len(candidates) == 0 {
		return StrategyTier{}, 0, false, StrategyReasonNoPayableJackpot, removed, nil
	}
	tier, _, err := drawWeightedStrategyTier(candidates, random)
	if err != nil {
		return StrategyTier{}, 0, false, "", removed, err
	}
	payout, err := strategyPayout(input.GiftPriceCoins, tier.MultiplierPPM)
	return tier, payout, true, "payable_jackpot_selected", removed, err
}

func selectOrdinaryTier(config StrategyConfig, state StrategyState, input StrategyInput, available int64, weighted []weightedStrategyTier, random StrategyRandomSource) (StrategyTier, int64, DecisionTrace, error) {
	trace := DecisionTrace{}
	zeroTier := zeroStrategyTier(weighted)
	candidates := append([]weightedStrategyTier(nil), weighted...)
	if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws {
		trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalMissProtection})
		// Protection deliberately rebuilds only ordinary positive candidates from their pre-downweight
		// weights. A 1-PPM retained mass can round some tiers to zero, but if any such tier is payable the
		// guarantee still has to award one; jackpot tiers remain exclusively controlled by jackpot gates.
		protected := make([]weightedStrategyTier, 0, len(candidates))
		for _, candidate := range candidates {
			if candidate.Tier.MultiplierPPM <= 0 || candidate.Tier.Jackpot || candidate.MissProtectionWeight <= 0 {
				continue
			}
			candidate.Weight = candidate.MissProtectionWeight
			protected = append(protected, candidate)
		}
		candidates = protected
		if !hasPayableOrdinaryTier(config, state, input, available, candidates) {
			trace.FinalReason = StrategyReasonMissProtectionBlocked
			trace.BlockedReason = StrategyReasonMissProtectionBlocked
			return zeroTier, 0, trace, nil
		}
	}
	sequence := 0
	redraw := false
	for len(candidates) > 0 {
		if !hasPositiveStrategyWeight(candidates) {
			break
		}
		selected, weight, err := drawWeightedStrategyTier(candidates, random)
		if err != nil {
			return StrategyTier{}, 0, trace, err
		}
		sequence++
		payout, err := strategyPayout(input.GiftPriceCoins, selected.MultiplierPPM)
		if err != nil {
			return StrategyTier{}, 0, trace, err
		}
		draw := StrategyDrawTrace{Sequence: sequence, Kind: StrategyDrawKindOriginal, TierID: selected.ID, MultiplierPPM: selected.MultiplierPPM, WeightPPM: weight, PayoutCoins: payout}
		if redraw {
			draw.Kind = StrategyDrawKindRedraw
			trace.RedrawTierIDs = append(trace.RedrawTierIDs, selected.ID)
		} else {
			trace.OriginalTierID = selected.ID
		}
		if selected.MultiplierPPM == 0 {
			trace.Draws = append(trace.Draws, draw)
			trace.FinalReason = StrategyReasonZeroSelected
			return selected, 0, trace, nil
		}
		reason := ordinaryBlockedReason(config, state, input, available, selected, payout)
		if reason == "" {
			trace.Draws = append(trace.Draws, draw)
			trace.FinalReason = StrategyReasonPaid
			return selected, payout, trace, nil
		}
		draw.RemovedReason = reason
		trace.Draws = append(trace.Draws, draw)
		trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: selected.ID, Reason: reason})
		candidates = withoutStrategyTier(candidates, selected.ID)
		// Product P/W semantics remove 0 together with the first unaffordable winner;
		// subsequent rolls therefore search only the remaining positive tiers.
		if reason == StrategyReasonPoolInsufficient && containsStrategyTier(candidates, zeroTier.ID) {
			candidates = withoutStrategyTier(candidates, zeroTier.ID)
			trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalPWRedrawZero})
		}
		redraw = true
	}
	trace.FinalReason = StrategyReasonNoPayableTier
	trace.BlockedReason = StrategyReasonNoPayableTier
	return zeroTier, 0, trace, nil
}

func ordinaryBlockedReason(config StrategyConfig, state StrategyState, input StrategyInput, available int64, tier StrategyTier, payout int64) string {
	if payout > available {
		return StrategyReasonPoolInsufficient
	}
	if payout > input.RiskCapacity.capacity() {
		return StrategyReasonRiskCapacity
	}
	if tier.Jackpot && state.UserDailyJackpotWins >= config.DailyJackpotLimit {
		return StrategyReasonDailyJackpotLimit
	}
	return ""
}

func hasPayableOrdinaryTier(config StrategyConfig, state StrategyState, input StrategyInput, available int64, candidates []weightedStrategyTier) bool {
	for _, candidate := range candidates {
		if candidate.Weight <= 0 || candidate.Tier.MultiplierPPM <= 0 {
			continue
		}
		payout, err := strategyPayout(input.GiftPriceCoins, candidate.Tier.MultiplierPPM)
		if err == nil && ordinaryBlockedReason(config, state, input, available, candidate.Tier, payout) == "" {
			return true
		}
	}
	return false
}

func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input StrategyInput, available int64, tier StrategyTier, payout int64, mechanism string, consumeToken bool, trace DecisionTrace) (StrategyDecision, error) {
	if payout < 0 || payout > available {
		return StrategyDecision{}, fmt.Errorf("%w: payout=%d exceeds available pool=%d", ErrStrategyInput, payout, available)
	}
	next := state
	if config.SettledRoundRTPEligibility {
		// A closed-round qualification is exactly one attempt. Pool/risk rejection falls through to the
		// ordinary draw in this same call, but the qualification can never repeat on a later paid draw.
		next.SettledRoundPending = false
	}
	next.PoolBalanceCoins = available - payout
	if next.PoolBalanceCoins < 0 {
		return StrategyDecision{}, fmt.Errorf("%w: strategy would make pool negative", ErrStrategyInput)
	}
	if payout == 0 {
		next.ConsecutiveZeroDraws++
	} else {
		next.ConsecutiveZeroDraws = 0
	}
	if consumeToken {
		if pendingSpendJackpotTokens(config, next) <= 0 {
			return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput)
		}
		if config.SettledRoundRTPEligibility {
			next.PendingSpendJackpotTokens--
		} else {
			next.PendingMilestoneTokens--
		}
	}
	jackpot := payout > 0 && tier.Jackpot
	if jackpot {
		next.UserDailyJackpotWins++
		next.RecentJackpotWins++
	}
	var err error
	previousDaySpend := next.UserDaySpendCoins
	if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil {
		return StrategyDecision{}, err
	}
	// A threshold crossed by this draw is persisted after selection, so the newly earned token can
	// affect only the next locked subdraw and never the current one. Production V3 stores it in the
	// dedicated field; the retired legacy token is neither read nor migrated into the new strategy.
	earnedTokens := int64(0)
	if config.JackpotMechanism2Enabled {
		earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
	}
	if earnedTokens > 0 {
		if config.SettledRoundRTPEligibility {
			// Production tokens are repriced only by the immutable rule that creates them. The repository
			// expires a prior version before decision; assigning here makes newly earned tokens auditable.
			next.PendingSpendJackpotRuleVersion = config.RuleVersion
			next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens)
		} else {
			next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
		}
		if err != nil {
			return StrategyDecision{}, err
		}
		trace.MilestoneTokensEarned = earnedTokens
	}
	// Set the final balance after both the optional consumption and threshold-crossing accrual.
	// This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with
	// one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed.
	trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next)
	if _, enabled := normalizedUser24HourOrdinaryWinFactor(config); enabled {
		// The caller supplies the pre-draw rolling snapshot. Advancing it only after selection makes
		// this draw visible to the next item in the same gift_count batch, never to its own decision.
		if next.User24HourRTP, err = next.User24HourRTP.add(input.GiftPriceCoins, payout); err != nil {
			return StrategyDecision{}, err
		}
	}
	if !config.SettledRoundRTPEligibility {
		if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil {
			return StrategyDecision{}, err
		}
		if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil {
			return StrategyDecision{}, err
		}
		if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil {
			return StrategyDecision{}, err
		}
	}
	if next.Version == math.MaxInt64 {
		return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput)
	}
	next.Version++
	trace.FinalReason = strings.TrimSpace(trace.FinalReason)
	trace.JackpotMechanism = mechanism
	trace.MilestoneTokenConsumed = consumeToken
	return StrategyDecision{
		SelectedTier: tier, PayoutCoins: payout, PoolAfterCoins: next.PoolBalanceCoins,
		Stage: trace.Stage, Jackpot: jackpot, JackpotMechanism: mechanism,
		ConsumedMilestoneToken: consumeToken, NextState: next, Trace: trace,
	}, nil
}

func (r StrategyRTP) RatioPPM() (int64, bool) {
	if r.WagerCoins <= 0 || r.PayoutCoins < 0 {
		return 0, false
	}
	ratio := mulDiv(r.PayoutCoins, StrategyPPMScale, r.WagerCoins)
	return ratio, ratio >= 0
}

func (r StrategyRTP) add(wager, payout int64) (StrategyRTP, error) {
	if wager < 0 || payout < 0 {
		return StrategyRTP{}, fmt.Errorf("%w: RTP deltas cannot be negative", ErrStrategyInput)
	}
	var err error
	if r.WagerCoins, err = safeAdd(r.WagerCoins, wager); err != nil {
		return StrategyRTP{}, err
	}
	if r.PayoutCoins, err = safeAdd(r.PayoutCoins, payout); err != nil {
		return StrategyRTP{}, err
	}
	return r, nil
}

func (c StrategyRiskCapacity) values() []int64 {
	values := []int64{c.SingleDrawCoins, c.UserHourCoins, c.UserDayCoins, c.RoomHourCoins, c.AnchorDayCoins}
	if !c.SkipDeviceDay {
		values = append(values, c.DeviceDayCoins)
	}
	return values
}

func (c StrategyRiskCapacity) capacity() int64 {
	if !c.Enabled {
		return math.MaxInt64
	}
	capacity := int64(math.MaxInt64)
	for _, value := range c.values() {
		if value < capacity {
			capacity = value
		}
	}
	if capacity < 0 {
		return 0
	}
	return capacity
}

func drawWeightedStrategyTier(candidates []weightedStrategyTier, random StrategyRandomSource) (StrategyTier, int64, error) {
	var total int64
	for _, candidate := range candidates {
		if candidate.Weight < 0 {
			return StrategyTier{}, 0, fmt.Errorf("%w: negative candidate weight", ErrStrategyConfig)
		}
		if candidate.Weight > 0 {
			var err error
			total, err = safeAdd(total, candidate.Weight)
			if err != nil {
				return StrategyTier{}, 0, err
			}
		}
	}
	if total <= 0 {
		return StrategyTier{}, 0, fmt.Errorf("%w: no positive candidate weight", ErrStrategyInput)
	}
	index, err := random.Int63n(total)
	if err != nil {
		return StrategyTier{}, 0, err
	}
	if index < 0 || index >= total {
		return StrategyTier{}, 0, fmt.Errorf("%w: random index=%d outside [0,%d)", ErrStrategyInput, index, total)
	}
	for _, candidate := range candidates {
		if candidate.Weight <= 0 {
			continue
		}
		if index < candidate.Weight {
			return candidate.Tier, candidate.Weight, nil
		}
		index -= candidate.Weight
	}
	return StrategyTier{}, 0, fmt.Errorf("%w: weighted selection exhausted", ErrStrategyInput)
}

func strategyPayout(price, multiplierPPM int64) (int64, error) {
	if price <= 0 || multiplierPPM < 0 {
		return 0, fmt.Errorf("%w: price/multiplier invalid", ErrStrategyInput)
	}
	result := mulDiv(price, multiplierPPM, StrategyPPMScale)
	if result < 0 {
		return 0, fmt.Errorf("%w: payout overflow", ErrStrategyInput)
	}
	return result, nil
}

func jackpotWeight(tier StrategyTier) int64 {
	if tier.JackpotWeight > 0 {
		return tier.JackpotWeight
	}
	return 1
}

func zeroStrategyTier(candidates []weightedStrategyTier) StrategyTier {
	for _, candidate := range candidates {
		if candidate.Tier.MultiplierPPM == 0 {
			return candidate.Tier
		}
	}
	return StrategyTier{ID: StrategyDefaultZeroTierID, Enabled: true}
}

func containsStrategyTier(candidates []weightedStrategyTier, tierID string) bool {
	for _, candidate := range candidates {
		if candidate.Tier.ID == tierID {
			return true
		}
	}
	return false
}

func withoutStrategyTier(candidates []weightedStrategyTier, tierID string) []weightedStrategyTier {
	out := make([]weightedStrategyTier, 0, len(candidates))
	for _, candidate := range candidates {
		if candidate.Tier.ID != tierID {
			out = append(out, candidate)
		}
	}
	return out
}

func hasPositiveStrategyWeight(candidates []weightedStrategyTier) bool {
	for _, candidate := range candidates {
		if candidate.Weight > 0 {
			return true
		}
	}
	return false
}

func boolReason(ok bool, yes, no string) string {
	if ok {
		return yes
	}
	return no
}

func mulPPM(value, factor int64) int64 {
	return mulDiv(value, factor, StrategyPPMScale)
}

func mulDiv(a, b, divisor int64) int64 {
	if a < 0 || b < 0 || divisor <= 0 {
		return -1
	}
	n := new(big.Int).Mul(big.NewInt(a), big.NewInt(b))
	n.Quo(n, big.NewInt(divisor))
	if !n.IsInt64() {
		return -1
	}
	return n.Int64()
}

func safeAdd(a, b int64) (int64, error) {
	if b > 0 && a > math.MaxInt64-b {
		return 0, fmt.Errorf("%w: integer overflow", ErrStrategyInput)
	}
	if b < 0 && a < math.MinInt64-b {
		return 0, fmt.Errorf("%w: integer underflow", ErrStrategyInput)
	}
	return a + b, nil
}

// apportion distributes integer total by non-negative weights using the largest
// remainder method. It is used both by fund splitting and >100% dynamic-weight
// normalization, so both paths conserve their exact integer total.
func apportion(total int64, weights []int64, denominator int64) ([]int64, error) {
	if total < 0 || denominator <= 0 || len(weights) == 0 {
		return nil, fmt.Errorf("%w: apportion arguments invalid", ErrStrategyInput)
	}
	type remainder struct {
		index int
		value *big.Int
	}
	parts := make([]int64, len(weights))
	remainders := make([]remainder, 0, len(weights))
	var allocated int64
	den := big.NewInt(denominator)
	for index, weight := range weights {
		if weight < 0 {
			return nil, fmt.Errorf("%w: apportion weight cannot be negative", ErrStrategyInput)
		}
		numerator := new(big.Int).Mul(big.NewInt(total), big.NewInt(weight))
		quotient, rem := new(big.Int), new(big.Int)
		quotient.QuoRem(numerator, den, rem)
		if !quotient.IsInt64() {
			return nil, fmt.Errorf("%w: apportion overflow", ErrStrategyInput)
		}
		parts[index] = quotient.Int64()
		allocated += parts[index]
		remainders = append(remainders, remainder{index: index, value: new(big.Int).Set(rem)})
	}
	left := total - allocated
	if left < 0 || left > int64(len(weights)) {
		return nil, fmt.Errorf("%w: apportion denominator does not cover weights", ErrStrategyInput)
	}
	sort.SliceStable(remainders, func(i, j int) bool {
		cmp := remainders[i].value.Cmp(remainders[j].value)
		if cmp == 0 {
			return remainders[i].index < remainders[j].index
		}
		return cmp > 0
	})
	for index := int64(0); index < left; index++ {
		parts[remainders[index].index]++
	}
	return parts, nil
}
