package mysql

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"time"

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

type luckyDynamicBatchState struct {
	PublicIn     int64
	ProfitIn     int64
	AnchorIn     int64
	Payout       int64
	DrawIDs      []string
	Records      []luckyDrawRecordInput
	Results      []domain.DrawResult
	BasePool     luckyPool
	GlobalWindow luckyRTPWindow
	GiftWindow   luckyRTPWindow
	// *Windows 保存本批已经滚满的窗口；当前窗口单独保留，事务尾统一按绝对快照写回。
	GlobalWindows []luckyRTPWindow
	GiftWindows   []luckyRTPWindow
	UserState     luckyUserState
	RiskCounters  map[string]luckyDynamicRiskCounter
	DayState      luckyUserStrategyDay
	HourState     luckyUserRTPHour
	UserWindow    luckyUserRTPWindow
	UserWindows   []luckyUserRTPWindow
	// QualificationWindow is the one closed round consumed by the next personal subdraw. It is
	// persisted after decision so pool/risk rejection cannot leave an endlessly reusable promise.
	QualificationWindow *luckyUserRTPWindow
	Strategy            domain.StrategyState
}

// luckyDynamicPersistence 把内部钱包结算与外部 App 自行结算区分开。
// 外部模式仍推进同一套奖池/RTP/风控状态并保留 N 条子抽审计，但不能生成 HyApp wallet 返奖 outbox。
type luckyDynamicPersistence struct {
	ExternalRequestID string
	ExternalUserID    string
}

func (p luckyDynamicPersistence) external() bool {
	return p.ExternalRequestID != "" || p.ExternalUserID != ""
}

// executeDynamicLuckyGiftDrawBatch 在已经锁定规则、完成 command_id 幂等探测后执行 dynamic_v3。
// 所有共享行只锁一次，gift_count=N 在内存里严格推进 N 次，事务尾再批量落 draw facts 和聚合钱包 outbox。
func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, rule domain.RuleConfig, persistence luckyDynamicPersistence, nowMS int64) ([]domain.DrawResult, error) {
	if cmd.PaidAtMS <= 0 {
		// dynamic_v3 的充值后五分钟、UTC 日/小时和精确滚动 48h 都必须归属 wallet owner 的扣费事实时间。
		// 外部调用也必须传它自己的扣费事实；缺失时绝不能用 lucky 收到请求或 room saga/recovery 时钟补齐。
		return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required")
	}
	// Account creation is an eligibility-only owner fact: an unknown value or a
	// timestamp later than this payment cannot describe the paying account. Keep
	// ordinary drawing available, but freeze 0 into the round so compensation
	// remains fail-closed even if that malformed timestamp would age past 48 hours
	// before the platform window eventually settles.
	if cmd.UserRegisteredAtMS <= 0 || cmd.UserRegisteredAtMS > cmd.PaidAtMS {
		cmd.UserRegisteredAtMS = 0
	}
	// 可信 device_id 存在时才启用设备日额度。旧 JWT 缺少该 claim 时保留空值并跳过这一维，
	// 但用户、房间、主播、单次等其余上限仍照常生效；绝不能用 session_id/command_id 伪造设备作用域。
	deviceID, err := normalizeOptionalLuckyDynamicDeviceID(cmd.DeviceID)
	if err != nil {
		return nil, err
	}
	cmd.DeviceID = deviceID
	appCode := appcode.FromContext(ctx)
	config, err := luckyRuntimeConfigFromRuleConfig(rule)
	if err != nil {
		return nil, err
	}
	if !luckyUsesDynamicStrategy(config) {
		return nil, xerr.New(xerr.Internal, "dynamic lucky gift path received a fixed strategy")
	}
	strategyConfig := luckyDynamicStrategyConfig(config)
	referencePrice := config.GiftPrice
	paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()

	userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
	if err != nil {
		return nil, err
	}
	if userState.PendingSpendJackpotRuleVersion != config.RuleVersion {
		// 旧行 version=0 与任何历史版本 backlog 都不能进入新规则。只在本次用户主键事务中
		// 懒清零，避免发布时全表 UPDATE；若本抽重新跨过阈值，会立刻生成当前版本 token。
		userState.PendingSpendJackpotTokens = 0
		userState.PendingSpendJackpotRuleVersion = config.RuleVersion
	}
	windowScopeID := luckyRuleWindowScopeID(config)
	globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
	if err != nil {
		return nil, err
	}
	// Consumption milestones use the newest fully closed platform sample, independent of whether
	// this user participated in that round. Missing history deliberately leaves a zero denominator.
	spendGlobalWindow, hasSpendGlobalWindow, err := r.getLatestClosedLuckyDynamicRTPWindow(ctx, tx, appCode, windowScopeID, globalWindow.WindowIndex)
	if err != nil {
		return nil, err
	}
	giftWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.SettlementWindowWager, config.GiftWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
	if err != nil {
		return nil, err
	}
	// dynamic_v3 永远从独立的零余额账本启动；运营启动资金必须走人工 credit，不能由规则版本发布隐式注资。
	basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyDynamicPoolScopeType, luckyBasePoolScopeID(config), 0, 0, nowMS)
	if err != nil {
		return nil, err
	}
	var recentJackpotWins int64
	if config.JackpotRateLimitWindowMinutes > 0 || config.MaxJackpotHitsPerWindow > 0 {
		if config.JackpotRateLimitWindowMinutes <= 0 || config.MaxJackpotHitsPerWindow <= 0 {
			return nil, xerr.New(xerr.Internal, "dynamic lucky gift jackpot rate limit is incomplete")
		}
		recentJackpotWins, err = r.countLuckyJackpotHitsInWindow(
			ctx, tx, appCode, config.PoolID, paidAtMS, config.JackpotRateLimitWindowMinutes,
		)
		if err != nil {
			return nil, err
		}
	}
	riskCounters, err := r.getLuckyDynamicRiskCounters(ctx, tx, appCode, config.PoolID, cmd, nowMS)
	if err != nil {
		return nil, err
	}
	dayState, err := r.getOrCreateLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, config.RuleVersion, cmd.UserID, cmd, nowMS)
	if err != nil {
		return nil, err
	}
	hourState, err := r.getOrCreateLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
	if err != nil {
		return nil, err
	}
	rolling24HourRTP := domain.StrategyRTP{}
	if strategyConfig.User24HourRTPThresholdPPM > 0 &&
		strategyConfig.User24HourOrdinaryWinFactorPPM > 0 &&
		strategyConfig.User24HourOrdinaryWinFactorPPM < domain.StrategyPPMScale {
		// One exact [paid_at+1ms-24h, paid_at+1ms) read covers the whole gift_count batch. Existing
		// persisted events at paid_at_ms are included, while this batch is not inserted until commit;
		// finalizeStrategyDecision advances the snapshot after every subdraw entirely in memory.
		rolling24HourRTP, err = r.getLuckyUserRolling24HourRTP(
			ctx, tx, appCode, config.PoolID, cmd.UserID, luckyExclusiveEndMS(paidAtMS),
		)
		if err != nil {
			// Failing closed here rolls back the complete draw transaction. Silently using zero would
			// let a user bypass the control whenever the aggregate source is unavailable.
			return nil, err
		}
	}
	userWindow, err := r.getOrCreateLuckyUserRTPWindow(ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS)
	if err != nil {
		return nil, err
	}
	qualificationWindow, qualificationGlobal, hasQualification, err := r.getLatestPendingLuckyUserRTPWindow(
		ctx, tx, appCode, config.PoolID, config.RuleVersion, globalWindow.WindowIndex, cmd.UserID, windowScopeID, nowMS,
	)
	if err != nil {
		return nil, err
	}
	fundSplit, err := luckyDynamicFundSplit(cmd.CoinSpent, cmd.HostPeriodDiamondAdded, strategyConfig)
	if err != nil {
		return nil, err
	}
	unitSplits, err := luckyDynamicUnitFundSplits(cmd.CoinSpent, drawCount, fundSplit)
	if err != nil {
		return nil, err
	}
	state := luckyDynamicBatchState{
		DrawIDs:       make([]string, 0, drawCount),
		Records:       make([]luckyDrawRecordInput, 0, drawCount),
		Results:       make([]domain.DrawResult, 0, drawCount),
		BasePool:      basePool,
		GlobalWindow:  globalWindow,
		GiftWindow:    giftWindow,
		GlobalWindows: make([]luckyRTPWindow, 0, 2),
		GiftWindows:   make([]luckyRTPWindow, 0, 2),
		UserState:     userState,
		RiskCounters:  riskCounters,
		DayState:      dayState,
		HourState:     hourState,
		UserWindow:    userWindow,
		UserWindows:   make([]luckyUserRTPWindow, 0, 2),
		Strategy: domain.StrategyState{
			PoolBalanceCoins:     basePool.Balance,
			ConsecutiveZeroDraws: userState.LossStreak,
			// The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same
			// transaction while only the new column can authorize the restored spend strategy.
			PendingSpendJackpotTokens:      userState.PendingSpendJackpotTokens,
			PendingSpendJackpotRuleVersion: userState.PendingSpendJackpotRuleVersion,
			RecentJackpotWins:              recentJackpotWins,
			UserDailyJackpotWins:           dayState.JackpotHits,
			UserDaySpendCoins:              dayState.SpendCoins,
			UserRegisteredAtMS:             cmd.UserRegisteredAtMS,
			UserDayRTP:                     domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
			User24HourRTP:                  rolling24HourRTP,
			Version:                        userState.PaidDraws,
		},
	}
	if hasSpendGlobalWindow {
		state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: spendGlobalWindow.WagerCoins, PayoutCoins: spendGlobalWindow.ActualPayoutCoins}
	}
	if hasQualification {
		if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, qualificationGlobal, qualificationWindow, &state, nowMS); err != nil {
			return nil, err
		}
	}

	for index := int32(1); index <= drawCount; index++ {
		unitCommand := cmd
		unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
		unitCommand.GiftCount = 1
		unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
		// gift_count=N 的每一份都必须像独立命令一样跨 RTP 窗口；若上一抽正好填满，
		// 本抽先创建下一窗口并把刚闭窗的用户快照冻结成一次性资格，不能等整批结束后才结算。
		if err := r.rollLuckyDynamicWindows(ctx, tx, appCode, config, unitCommand, &state, nowMS); err != nil {
			return nil, err
		}
		unitSplit := unitSplits[index-1]
		unitCommand.HostPeriodDiamondAdded = unitSplit.AnchorReturnCoins
		if unitCommand.CoinSpent <= 0 {
			return nil, xerr.New(xerr.InvalidArgument, "dynamic lucky gift unit spend must be positive")
		}
		if unitSplit.PublicPoolCoins+unitSplit.ProfitPoolCoins+unitSplit.AnchorReturnCoins != unitCommand.CoinSpent {
			return nil, xerr.New(xerr.Internal, "dynamic lucky gift unit fund split is not conserved")
		}
		decision, err := domain.DecideLuckyGiftStrategy(strategyConfig, state.Strategy, domain.StrategyInput{
			GiftPriceCoins:        unitCommand.CoinSpent,
			PoolContributionCoins: unitSplit.PublicPoolCoins,
			NowMS:                 luckyPaidTime(unitCommand, nowMS).UnixMilli(),
			HasRechargeFact:       unitCommand.LastRechargedAtMS > 0,
			LastRechargeAtMS:      unitCommand.LastRechargedAtMS,
			Recharge7DCoins:       unitCommand.Recharge7DCoins,
			Recharge30DCoins:      unitCommand.Recharge30DCoins,
			RiskCapacity:          luckyDynamicRiskCapacity(config, state.RiskCounters),
		}, luckyCryptoStrategyRandom{})
		if err != nil {
			return nil, err
		}
		if err := r.stageLuckyDynamicDecision(appCode, config, referencePrice, unitCommand, unitSplit, decision, nowMS, &state); err != nil {
			return nil, err
		}
	}
	if persistence.external() {
		if persistence.ExternalRequestID == "" || persistence.ExternalUserID == "" {
			return nil, xerr.New(xerr.InvalidArgument, "external dynamic lucky gift persistence identity is incomplete")
		}
		for index := range state.Records {
			state.Records[index].RewardStatus = domain.StatusGranted
			state.Results[index].RewardStatus = domain.StatusGranted
		}
	}
	if err := r.persistLuckyDynamicBatch(ctx, tx, appCode, cmd, config, state, persistence, nowMS); err != nil {
		return nil, err
	}
	return state.Results, nil
}

func normalizeLuckyDynamicDeviceID(value string) (string, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is required")
	}
	if len(value) > 128 {
		return "", xerr.New(xerr.InvalidArgument, "dynamic lucky gift device_id is too long")
	}
	return value, nil
}

func normalizeOptionalLuckyDynamicDeviceID(value string) (string, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return "", nil
	}
	return normalizeLuckyDynamicDeviceID(value)
}

func (r *Repository) rollLuckyDynamicWindows(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, state *luckyDynamicBatchState, nowMS int64) error {
	if config.SettlementWindowWager <= 0 {
		return xerr.New(xerr.Internal, "dynamic lucky gift RTP window wager must be positive")
	}
	paidAtMS := luckyPaidTime(cmd, nowMS).UnixMilli()
	// 图中“结算窗口流水”是金币阈值，不是抽数。越过阈值的完整单抽归属旧窗口；窗口已在
	// 上一抽结束时冻结，本抽先创建下一窗口并消费刚结算用户轮次的一次性资格。
	if state.GlobalWindow.Status == "closed" || state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
		closed := state.GlobalWindow
		if closed.Status != "closed" {
			closed.Status = "closed"
			closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
		}
		// The next subdraw in this batch must immediately see the just-closed platform sample. The draw
		// that completed the window was already selected before this assignment and cannot use it.
		state.Strategy.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: closed.WagerCoins, PayoutCoins: closed.ActualPayoutCoins}
		closedUser := state.UserWindow
		if err := r.prepareLuckySettledRoundQualification(ctx, tx, appCode, config, cmd, closed, closedUser, state, nowMS); err != nil {
			return err
		}
		state.GlobalWindows = append(state.GlobalWindows, closed)
		state.UserWindows = append(state.UserWindows, closedUser)
		next, err := r.createLuckyRTPWindowAt(
			ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
			closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS,
		)
		if err != nil {
			return err
		}
		state.GlobalWindow = next
		state.UserWindow, err = r.getOrCreateLuckyUserRTPWindow(
			ctx, tx, appCode, config.PoolID, config.RuleVersion, next.WindowIndex, cmd.UserID, cmd.UserRegisteredAtMS, nowMS,
		)
		if err != nil {
			return err
		}
	}
	if state.GiftWindow.Status == "closed" || state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
		closed := state.GiftWindow
		if closed.Status != "closed" {
			closed.Status = "closed"
			closed.ClosedAtMS = maxInt64(closed.LastPaidAtMS, paidAtMS)
		}
		state.GiftWindows = append(state.GiftWindows, closed)
		next, err := r.createLuckyRTPWindowAt(
			ctx, tx, appCode, closed.ScopeType, closed.ScopeID, closed.WindowIndex+1,
			closed.CarryPPM, closed.ControlDraws, closed.TargetRTPPPM, paidAtMS, nowMS,
		)
		if err != nil {
			return err
		}
		state.GiftWindow = next
	}
	return nil
}

func (r *Repository) prepareLuckySettledRoundQualification(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, cmd domain.DrawCommand, global luckyRTPWindow, user luckyUserRTPWindow, state *luckyDynamicBatchState, nowMS int64) error {
	if global.WindowIndex != user.WindowIndex || user.UserID != cmd.UserID {
		return xerr.New(xerr.Internal, "dynamic lucky gift user RTP window is not aligned with platform settlement")
	}
	rolling := domain.StrategyRTP{WagerCoins: user.Rolling48hWagerCoins, PayoutCoins: user.Rolling48hPayoutCoins}
	if rolling.WagerCoins == 0 && global.ClosedAtMS > 0 {
		rollingEndMS := luckyExclusiveEndMS(global.ClosedAtMS)
		var err error
		rolling, err = r.getLuckyUserRolling48HourRTP(ctx, tx, appCode, config.PoolID, cmd.UserID, rollingEndMS)
		if err != nil {
			return err
		}
		startMS := rollingEndMS - int64(48*time.Hour/time.Millisecond)
		for _, record := range state.Records {
			occurredAtMS := luckyPaidTime(record.Command, nowMS).UnixMilli()
			if occurredAtMS < startMS || occurredAtMS >= rollingEndMS {
				continue
			}
			var addErr error
			rolling.WagerCoins, addErr = luckyCheckedAdd(rolling.WagerCoins, record.Command.CoinSpent)
			if addErr != nil {
				return addErr
			}
			rolling.PayoutCoins, addErr = luckyCheckedAdd(rolling.PayoutCoins, record.Candidate.effectiveReward())
			if addErr != nil {
				return addErr
			}
		}
		user.Rolling48hWagerCoins = rolling.WagerCoins
		user.Rolling48hPayoutCoins = rolling.PayoutCoins
	}
	qualification := user
	state.QualificationWindow = &qualification
	state.Strategy.SettledRoundPending = true
	state.Strategy.GlobalRTP = domain.StrategyRTP{WagerCoins: global.WagerCoins, PayoutCoins: global.ActualPayoutCoins}
	state.Strategy.UserRoundRTP = domain.StrategyRTP{WagerCoins: user.WagerCoins, PayoutCoins: user.PayoutCoins}
	state.Strategy.User48HourRTP = rolling
	state.Strategy.QualificationClosedAtMS = global.ClosedAtMS
	// Maturity is evaluated from the owner snapshot captured while the user participated in the
	// closed round, not a value supplied again on the later draw that consumes the qualification.
	state.Strategy.UserRegisteredAtMS = user.UserRegisteredAtMS
	return nil
}

func luckyCheckedAdd(left, right int64) (int64, error) {
	if right < 0 || left > int64(^uint64(0)>>1)-right {
		return 0, xerr.New(xerr.Internal, "dynamic lucky gift RTP aggregate overflow")
	}
	return left + right, nil
}

func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Config, referencePrice int64, cmd domain.DrawCommand, split domain.StrategyFundSplit, decision domain.StrategyDecision, nowMS int64, state *luckyDynamicBatchState) error {
	state.PublicIn += split.PublicPoolCoins
	state.ProfitIn += split.ProfitPoolCoins
	state.AnchorIn += split.AnchorReturnCoins
	state.Payout += decision.PayoutCoins
	state.Strategy = decision.NextState
	state.BasePool.Balance = decision.PoolAfterCoins
	state.BasePool.TotalIn += split.PublicPoolCoins
	state.BasePool.TotalOut += decision.PayoutCoins
	state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
	state.GlobalWindow.PaidDraws++
	state.GlobalWindow.ActualPayoutCoins += decision.PayoutCoins
	state.GlobalWindow.LastPaidAtMS = maxInt64(state.GlobalWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli())
	state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)
	state.GiftWindow.PaidDraws++
	state.GiftWindow.ActualPayoutCoins += decision.PayoutCoins
	state.GiftWindow.LastPaidAtMS = maxInt64(state.GiftWindow.LastPaidAtMS, luckyPaidTime(cmd, nowMS).UnixMilli())
	state.UserWindow.WagerCoins += cmd.CoinSpent
	state.UserWindow.PayoutCoins += decision.PayoutCoins
	// Reaching the configured wager resets the platform RTP immediately at this draw boundary. The
	// complete draw remains in the closed round; the next subdraw is the first member of the new round.
	if state.GlobalWindow.WagerCoins >= config.SettlementWindowWager {
		state.GlobalWindow.Status = "closed"
		state.GlobalWindow.ClosedAtMS = state.GlobalWindow.LastPaidAtMS
	}
	if state.GiftWindow.WagerCoins >= config.SettlementWindowWager {
		state.GiftWindow.Status = "closed"
		state.GiftWindow.ClosedAtMS = state.GiftWindow.LastPaidAtMS
	}
	state.UserState.PaidDraws++
	state.UserState.CumulativeWagerCoins += cmd.CoinSpent
	state.UserState.EquivalentDraws = luckyEquivalentDraws(state.UserState.CumulativeWagerCoins, referencePrice)
	state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
	state.UserState.PendingJackpotTokens = 0
	state.UserState.PendingSpendJackpotTokens = decision.NextState.PendingSpendJackpotTokens
	state.UserState.PendingSpendJackpotRuleVersion = decision.NextState.PendingSpendJackpotRuleVersion
	state.DayState.WagerCoins += cmd.CoinSpent
	state.DayState.PayoutCoins += decision.PayoutCoins
	state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
	state.DayState.JackpotHits = decision.NextState.UserDailyJackpotWins
	state.HourState.WagerCoins += cmd.CoinSpent
	state.HourState.PayoutCoins += decision.PayoutCoins
	for key, counter := range state.RiskCounters {
		counter.Payout += decision.PayoutCoins
		state.RiskCounters[key] = counter
	}

	candidate := luckyCandidate{
		TierID:         decision.SelectedTier.ID,
		Pool:           decision.Stage,
		Weight:         luckyDynamicSelectedWeight(decision),
		BaseReward:     decision.PayoutCoins,
		MultiplierPPM:  decision.SelectedTier.MultiplierPPM,
		HighMultiplier: decision.Jackpot || decision.SelectedTier.MultiplierPPM >= 10_000_000,
	}
	drawID := idgen.New("lucky_draw")
	if state.QualificationWindow != nil {
		qualification := *state.QualificationWindow
		qualification.AttemptedDrawID = drawID
		qualification.EvaluatedAtMS = nowMS
		if luckySettledRoundConditionsPassed(decision.Trace) {
			qualification.QualificationStatus = "attempted"
		} else {
			qualification.QualificationStatus = "ineligible"
		}
		state.UserWindows = append(state.UserWindows, qualification)
		state.QualificationWindow = nil
	}
	rewardStatus := luckyInitialRewardStatus(candidate, false)
	limited := luckyDynamicLimitedReasons(decision.Trace)
	decisionCopy := decision
	poolSnapshot := state.BasePool
	globalSnapshot := state.GlobalWindow
	giftSnapshot := state.GiftWindow
	record := luckyDrawRecordInput{
		AppCode:          appCode,
		DrawID:           drawID,
		Command:          cmd,
		Config:           config,
		ExperiencePool:   decision.Stage,
		Candidate:        candidate,
		Limited:          limited,
		GlobalWindow:     globalSnapshot,
		GiftWindow:       giftSnapshot,
		BasePool:         poolSnapshot,
		StrategyDecision: &decisionCopy,
		FundSplit:        split,
		UserRoundRTP:     domain.StrategyRTP{WagerCoins: state.UserWindow.WagerCoins, PayoutCoins: state.UserWindow.PayoutCoins},
		User48HourRTP:    decision.NextState.User48HourRTP,
		RewardStatus:     rewardStatus,
		NowMS:            nowMS,
	}
	state.DrawIDs = append(state.DrawIDs, drawID)
	state.Records = append(state.Records, record)
	globalRTP := luckyRTPPPM(globalSnapshot.WagerCoins, globalSnapshot.ActualPayoutCoins)
	giftRTP := luckyRTPPPM(giftSnapshot.WagerCoins, giftSnapshot.ActualPayoutCoins)
	state.Results = append(state.Results, domain.DrawResult{
		DrawID:               drawID,
		CommandID:            cmd.CommandID,
		PoolID:               config.PoolID,
		GiftID:               cmd.GiftID,
		RuleVersion:          config.RuleVersion,
		ExperiencePool:       decision.Stage,
		SelectedTierID:       candidate.TierID,
		MultiplierPPM:        candidate.MultiplierPPM,
		BaseRewardCoins:      candidate.BaseReward,
		EffectiveRewardCoins: candidate.effectiveReward(),
		RewardStatus:         rewardStatus,
		RTPWindowIndex:       globalSnapshot.WindowIndex,
		GiftRTPWindowIndex:   giftSnapshot.WindowIndex,
		GlobalBaseRTPPPM:     globalRTP,
		GiftBaseRTPPPM:       giftRTP,
		StageFeedback:        decision.Trace.FinalReason == domain.StrategyReasonPaid && decision.Trace.OriginalTierID == "",
		HighMultiplier:       candidate.HighMultiplier,
		CreatedAtMS:          nowMS,
	})
	return nil
}

func luckySettledRoundConditionsPassed(trace domain.DecisionTrace) bool {
	required := map[string]bool{
		domain.StrategyConditionSettledRound:       false,
		domain.StrategyConditionGlobalRTP:          false,
		domain.StrategyConditionUserRoundRTP:       false,
		domain.StrategyConditionUser48HourMaturity: false,
		domain.StrategyConditionUser48HourRTP:      false,
		domain.StrategyConditionDailyJackpotLimit:  false,
		domain.StrategyConditionJackpotRateLimit:   false,
	}
	seen := make(map[string]bool, len(required))
	for _, condition := range trace.Conditions {
		if _, ok := required[condition.Name]; !ok {
			continue
		}
		seen[condition.Name] = true
		if !condition.Passed {
			return false
		}
	}
	for name := range required {
		if !seen[name] {
			return false
		}
	}
	return true
}

func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, state luckyDynamicBatchState, persistence luckyDynamicPersistence, nowMS int64) error {
	if err := validateLuckyDynamicBatchState(state, cmd.CoinSpent); err != nil {
		return err
	}
	globalWindows := append(append([]luckyRTPWindow(nil), state.GlobalWindows...), state.GlobalWindow)
	giftWindows := append(append([]luckyRTPWindow(nil), state.GiftWindows...), state.GiftWindow)
	for _, window := range append(globalWindows, giftWindows...) {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_rtp_windows
			SET paid_draws = ?, wager_coins = ?, target_payout_coins = ?,
			    actual_base_payout_coins = ?, carry_ppm = ?, status = ?,
			    last_paid_at_ms = ?, closed_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
			window.PaidDraws, window.WagerCoins, window.TargetPayoutCoins, window.ActualPayoutCoins, window.CarryPPM, window.Status,
			window.LastPaidAtMS, window.ClosedAtMS, nowMS,
			appCode, window.ScopeType, window.ScopeID, window.WindowIndex,
		); err != nil {
			return err
		}
	}
	// Values are ordered so a later evaluated copy of the same closed row overwrites its earlier
	// pending snapshot. Multiple UPDATEs stay bounded by subdraw window crossings, never user count.
	for _, userWindow := range append(state.UserWindows, state.UserWindow) {
		if err := r.persistLuckyUserRTPWindow(ctx, tx, appCode, userWindow, nowMS); err != nil {
			return err
		}
	}
	if err := r.updateLuckyDynamicPoolNet(ctx, tx, appCode, luckyBasePoolScopeID(config), state.PublicIn, state.Payout, state.ProfitIn, state.AnchorIn, nowMS); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_states
		SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
		    pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?,
		    pending_spend_jackpot_rule_version = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
		state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
		state.UserState.PendingSpendJackpotTokens, state.UserState.PendingSpendJackpotRuleVersion,
		nowMS, appCode, cmd.UserID, config.GiftID,
	); err != nil {
		return err
	}
	if err := r.addLuckyDynamicRiskPayout(ctx, tx, appCode, state.RiskCounters, state.Payout, nowMS); err != nil {
		return err
	}
	if err := r.persistLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, state.DayState, nowMS); err != nil {
		return err
	}
	if err := r.persistLuckyUserRTPHour(ctx, tx, appCode, config.PoolID, cmd.UserID, state.HourState, nowMS); err != nil {
		return err
	}
	if err := r.insertLuckyUserRTPBoundaryEvents(ctx, tx, appCode, config.PoolID, cmd.UserID, state.Records, nowMS); err != nil {
		return err
	}
	if persistence.external() {
		return r.insertExternalLuckyGiftDrawItems(ctx, tx, appCode, persistence, state.Records, nowMS)
	}
	if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
		return err
	}
	if state.Payout <= 0 {
		return nil
	}
	aggregate := luckyAggregateDrawResults(cmd, state.Results)
	legacyState := luckyBatchDrawState{DrawIDs: state.DrawIDs, AggregateResult: aggregate, NeedsRewardSettlement: true}
	return r.insertLuckyAggregateRewardSettlementOutbox(ctx, tx, appCode, cmd, legacyState, nowMS)
}

func luckyDynamicSelectedWeight(decision domain.StrategyDecision) int64 {
	// 大奖 tier 的 stage weight 按设计恒为 0；审计必须记录大奖集合内的选择权重，
	// 不能从普通概率 trace 读到 0 后误报为“零概率命中”。
	if decision.Jackpot {
		return decision.SelectedTier.JackpotWeight
	}
	for _, item := range decision.Trace.Weights {
		if item.TierID == decision.SelectedTier.ID {
			return item.AdjustedWeightPPM
		}
	}
	return decision.SelectedTier.JackpotWeight
}

func luckyDynamicLimitedReasons(trace domain.DecisionTrace) map[string]bool {
	out := make(map[string]bool, len(trace.Removed)+1)
	for _, removed := range trace.Removed {
		out[removed.Reason] = true
	}
	if trace.BlockedReason != "" {
		out[trace.BlockedReason] = true
	}
	return out
}

func validateLuckyDynamicBatchState(state luckyDynamicBatchState, expectedSpent int64) error {
	if state.BasePool.Balance < 0 {
		return fmt.Errorf("dynamic lucky gift pool became negative")
	}
	if state.PublicIn+state.ProfitIn+state.AnchorIn != expectedSpent {
		return fmt.Errorf("dynamic lucky gift fund split does not conserve spend")
	}
	return nil
}

// insertExternalLuckyGiftDrawItems 给 external gift_count=N 保存 N 条独立决策；聚合响应仍是一行，
// 但 P/W 删除、token、六维风控和每抽池水位不能只藏在一个最终 multiplier 里。
func (r *Repository) insertExternalLuckyGiftDrawItems(ctx context.Context, tx *sql.Tx, appCode string, persistence luckyDynamicPersistence, records []luckyDrawRecordInput, nowMS int64) error {
	const chunkSize = 200
	for start := 0; start < len(records); start += chunkSize {
		end := start + chunkSize
		if end > len(records) {
			end = len(records)
		}
		var query strings.Builder
		query.WriteString(`
			INSERT INTO external_lucky_gift_draw_items (
			  app_code, request_id, item_index, draw_id, command_id, external_user_id, pool_id,
			  rule_version, unit_amount, reward_amount, selected_tier_id, multiplier_ppm, stage,
			  candidate_snapshot_json, pool_snapshot_json, rtp_snapshot_json, reward_status,
			  paid_at_ms, created_at_ms
			) VALUES `)
		args := make([]any, 0, (end-start)*19)
		for offset, record := range records[start:end] {
			if offset > 0 {
				query.WriteByte(',')
			}
			query.WriteString("(" + luckySQLPlaceholders(19) + ")")
			candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(record)
			args = append(args,
				appCode, persistence.ExternalRequestID, start+offset+1, record.DrawID, record.Command.CommandID,
				persistence.ExternalUserID, record.Config.PoolID, record.Config.RuleVersion, record.Command.CoinSpent,
				record.Candidate.effectiveReward(), record.Candidate.TierID, record.Candidate.MultiplierPPM, record.ExperiencePool,
				candidateSnapshot, poolSnapshot, rtpSnapshot, domain.StatusGranted,
				luckyPaidTime(record.Command, nowMS).UnixMilli(), nowMS,
			)
		}
		if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
			return err
		}
	}
	facts := make([]luckyUserProfileFact, 0, len(records))
	strategyUserID := externalLuckyUserID(appCode, persistence.ExternalUserID)
	for _, record := range records {
		facts = append(facts, luckyUserProfileFactFromRecord(record, luckyUserProfileSourceExternalItem,
			domain.UserProfileIdentityExternal, persistence.ExternalUserID, persistence.ExternalUserID, strategyUserID))
	}
	return r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS)
}
