package mysql

import (
	"context"
	"crypto/sha1"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"

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

const (
	luckyPPMScale          int64 = 1_000_000
	luckyBasePoolScopeType       = "pool"
	// dynamic_v3 与 fixed_v2 的资金算法和启动规则不同，必须使用独立主键空间；
	// 任何兼容读取都不能把两种策略重新折叠到同一口 pool 账本。
	luckyDynamicPoolScopeType = "pool_dynamic_v3"
)

type luckyRTPWindow struct {
	ScopeType         string
	ScopeID           string
	WindowIndex       int64
	TargetRTPPPM      int64
	ControlDraws      int64
	PaidDraws         int64
	WagerCoins        int64
	TargetPayoutCoins int64
	ActualPayoutCoins int64
	CarryPPM          int64
	Status            string
	// StartedAtMS/LastPaidAtMS/ClosedAtMS are business-time boundaries from the wallet payment fact.
	// created_at_ms remains an infrastructure audit timestamp and must not be used for RTP eligibility.
	StartedAtMS  int64
	LastPaidAtMS int64
	ClosedAtMS   int64
}

func (w luckyRTPWindow) withCurrentWager(spent int64) luckyRTPWindow {
	targetMicros := spent*w.TargetRTPPPM + w.CarryPPM
	w.WagerCoins += spent
	w.TargetPayoutCoins += targetMicros / luckyPPMScale
	w.CarryPPM = targetMicros % luckyPPMScale
	return w
}

type luckyPool struct {
	ScopeType         string
	ScopeID           string
	Balance           int64
	ReserveFloor      int64
	TotalIn           int64
	TotalOut          int64
	ManualCreditTotal int64
	ManualDebitTotal  int64
	// V2BoostActive 是 fixed_v2 高水位返奖加权的滞回标志：水位越过阈值置位，跌破恢复线才清零。
	// 阈值区间内（恢复线~阈值）是否加权取决于历史轨迹，因此必须随奖池行持久化，不能按余额推导。
	V2BoostActive bool
}

func (p luckyPool) capacity() int64 {
	capacity := p.Balance - p.ReserveFloor
	if capacity < 0 {
		return 0
	}
	return capacity
}

type luckyUserState struct {
	PaidDraws            int64
	CumulativeWagerCoins int64
	EquivalentDraws      int64
	LossStreak           int64
	// PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid
	// transaction can clear it; its value must never become a new spend jackpot qualification.
	PendingJackpotTokens int64
	// PendingSpendJackpotTokens belongs to one immutable lucky-gift rule version. A token blocked by
	// risk controls can cross UTC midnight, but publishing a new rule invalidates the old backlog.
	PendingSpendJackpotTokens      int64
	PendingSpendJackpotRuleVersion int64
}

type luckyCandidate struct {
	TierID         string
	Pool           string
	Weight         int64
	BaseReward     int64
	MultiplierPPM  int64
	Presentation   bool
	HighMultiplier bool
}

func (c luckyCandidate) effectiveReward() int64 {
	return c.BaseReward
}

// luckyDrawRecordInput 是“待插入的抽奖事实快照”，不是运行态规则。
// 批量抽奖会先在内存中逐抽推进 RTP 和奖池，再把每一抽的审计快照集中写入 lucky_draw_records。
// 这里保留单抽时刻的窗口、奖池和候选信息，方便后续排查某一抽为什么命中或为什么被过滤到 0 倍。
type luckyDrawRecordInput struct {
	AppCode        string
	DrawID         string
	Command        domain.DrawCommand
	Config         domain.Config
	ExperiencePool string
	Candidate      luckyCandidate
	Limited        map[string]bool
	StageFeedback  bool
	GlobalWindow   luckyRTPWindow
	GiftWindow     luckyRTPWindow
	BasePool       luckyPool
	// StrategyDecision 只在 dynamic_v3 存在；完整权重、动态因子、P/W 重抽和大奖门槛会随 draw fact 固化，
	// 后台以后修改规则版本也不会让历史结果失去可解释性。
	StrategyDecision *domain.StrategyDecision
	FundSplit        domain.StrategyFundSplit
	UserRoundRTP     domain.StrategyRTP
	User48HourRTP    domain.StrategyRTP
	RewardStatus     string
	NowMS            int64
}

// luckyBatchDrawState 是批量送礼事务里的聚合账本。
// 设计目标是把 N 次抽奖必须逐次变化的随机过程留在内存循环里，
// 把规则读取、行锁、奖池写入、RTP 窗口写入和 outbox 写入收敛到事务首尾。
type luckyBatchDrawState struct {
	// BaseIn/BaseOut 只记录本批单池净变化；最后用一次 UPDATE 写回，避免 gift_count=777 时重复打数据库。
	BaseIn          int64
	BaseOut         int64
	EffectiveReward int64
	// 只有钱包结算会影响 reward_status=pending；展示层副作用不进入可靠 outbox。
	NeedsRewardSettlement bool
	StageFeedback         bool
	Presentation          bool
	HighMultiplier        bool
	// Final* 是用户体验状态的最终值；批量抽奖不能每抽 UPDATE lucky_user_states。
	FinalLossStreak int64
	FinalCumulative int64
	FinalEquivalent int64
	// DrawIDs 进入聚合 outbox，worker 用它一次性收敛整批 draw 记录。
	DrawIDs []string
	Records []luckyDrawRecordInput
	// Window/Pool 都是 FOR UPDATE 读出的当前行副本，循环内只改内存副本。
	GlobalWindow luckyRTPWindow
	GiftWindow   luckyRTPWindow
	BasePool     luckyPool
	// V2BoostLoaded 记录加锁读出的滞回标志原值；批量结束时与内存最终值比较，翻转才写回。
	V2BoostLoaded      bool
	AggregateResult    domain.DrawResult
	AggregateStatus    string
	AggregateCommandID string
}

func (r *Repository) CheckLuckyGift(ctx context.Context, cmd domain.CheckCommand) (domain.CheckResult, error) {
	if r == nil || r.db == nil {
		return domain.CheckResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	poolID := luckyPoolID(cmd.PoolID)
	// Check 与 Draw 必须解析到同一份配置：room saga 会固化 Check 返回的 strategy_version，恢复阶段不重新 Check。
	ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, r.db, appcode.FromContext(ctx), poolID, domain.ExperimentUserKey(cmd.UserID), false)
	if err != nil {
		return domain.CheckResult{}, err
	}
	if !exists {
		return domain.CheckResult{Enabled: false, Reason: "not_configured", PoolID: poolID, GiftID: cmd.GiftID}, nil
	}
	config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
	if err != nil {
		return domain.CheckResult{}, err
	}
	state, err := r.getLuckyUserState(ctx, appcode.FromContext(ctx), cmd.UserID, config.GiftID)
	if err != nil {
		return domain.CheckResult{}, err
	}
	return domain.CheckResult{
		Enabled:         config.Enabled,
		Reason:          luckyEnabledReason(config.Enabled),
		PoolID:          config.PoolID,
		GiftID:          cmd.GiftID,
		GiftPrice:       config.GiftPrice,
		RuleVersion:     config.RuleVersion,
		StrategyVersion: config.StrategyVersion,
		TargetRTPPPM:    config.TargetRTPPPM,
		ExperiencePool:  luckyExperiencePool(state.EquivalentDraws, config),
	}, nil
}

// ExecuteLuckyGiftDraw 是线上抽奖主事务：随机前过滤规则、单池水位和用户阶段，随机后只落可审计事实。
func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) {
	if r == nil || r.db == nil {
		return domain.DrawResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.DrawResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	results, err := r.executeLuckyGiftDrawBatch(ctx, tx, cmd, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.DrawResult{}, err
	}
	return luckyAggregateDrawResults(cmd, results), nil
}

func (r *Repository) ExecuteLuckyGiftDrawBatch(ctx context.Context, cmds []domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if len(cmds) == 0 {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift batch draw commands are required")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	results := make([]domain.DrawResult, 0, len(cmds))
	for _, cmd := range cmds {
		// 多目标送礼必须在 activity 内按请求顺序推进同一用户、奖池和 RTP 锁；这里不做并发，避免 room-service 自己制造锁竞争。
		draws, err := r.executeLuckyGiftDrawBatch(ctx, tx, cmd, nowMS)
		if err != nil {
			return nil, err
		}
		results = append(results, luckyAggregateDrawResults(cmd, draws))
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return results, nil
}

func (r *Repository) executeLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
	appCode := appcode.FromContext(ctx)
	// lucky_draw_records 的唯一键只能拒绝重复写，无法让两个“结果尚不存在”的首次请求都成功：
	// 两个事务可能同时读到空结果，后到者最终只能撞唯一键回滚。原始 command_id 锁行把首次执行
	// 串行化；等待者拿到锁后会重新走下方事实探测并完整回读首次结果，绝不重新随机或推进资金状态。
	if err := r.lockLuckyGiftCommand(ctx, tx, appCode, cmd.CommandID, nowMS); err != nil {
		return nil, err
	}
	// gift_count 是抽奖次数，coin_spent 是本次送礼总扣费；批量路径会把总扣费拆成 N 份单抽金额。
	// 单抽仍走原来的严谨路径，避免为了批量优化扩大普通送礼的行为面。
	drawCount := cmd.GiftCount
	if drawCount <= 0 {
		drawCount = 1
	}
	if drawCount == 1 {
		result, err := r.executeSingleLuckyGiftDraw(ctx, tx, cmd, nowMS)
		if err != nil {
			return nil, err
		}
		return []domain.DrawResult{result}, nil
	}
	if drawCount > 1 {
		// 旧实现把整笔送礼作为一次抽奖并使用原始 command_id 落库；批量拆分上线后遇到这类重试必须直接返回旧事实，不能追加子抽奖造成重复返奖。
		if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
			if err != nil {
				return nil, err
			}
			return []domain.DrawResult{existing}, nil
		}
	}
	// 新批量路径用 command_id#序号 做每一抽的幂等键；先探测第一条即可判断整批是否已经成功提交。
	// 如果第一条存在但后续缺失，说明数据被外部破坏，只能报冲突，不能补写造成账务二次发放。
	firstSubCommandID := luckyDrawSubCommandID(cmd.CommandID, 1, drawCount)
	if _, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, firstSubCommandID, true); err != nil || exists {
		if err != nil {
			return nil, err
		}
		return r.collectLuckyBatchDrawsByCommand(ctx, tx, appCode, cmd, drawCount)
	}
	return r.executeOptimizedLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, nowMS)
}

// lockLuckyGiftCommand 为内部钱包已扣费命令创建稳定的事务互斥点。
// SELECT ... FOR UPDATE 不能锁住尚不存在的 draw record；幂等插入锁行后，InnoDB 会让同一
// app_code+command_id 的并发事务等待首事务提交或回滚，再由显式 FOR UPDATE 固定后续锁顺序。
func (r *Repository) lockLuckyGiftCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, nowMS int64) error {
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_gift_command_locks (app_code, command_id, created_at_ms)
		VALUES (?, ?, ?)
		ON DUPLICATE KEY UPDATE created_at_ms = created_at_ms`, appCode, commandID, nowMS,
	); err != nil {
		return err
	}
	var lockedCommandID string
	return tx.QueryRowContext(ctx, `
		SELECT command_id FROM lucky_gift_command_locks
		WHERE app_code = ? AND command_id = ? FOR UPDATE`, appCode, commandID,
	).Scan(&lockedCommandID)
}

// collectLuckyBatchDrawsByCommand 只服务批量命令重试：完整读回已提交的子抽奖，再由外层聚合成响应。
// 这里不重新计算随机结果，避免同一个扣费命令因重试得到不同中奖结果。
func (r *Repository) collectLuckyBatchDrawsByCommand(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, drawCount int32) ([]domain.DrawResult, error) {
	results := make([]domain.DrawResult, 0, drawCount)
	for index := int32(1); index <= drawCount; index++ {
		commandID := luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
		result, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, commandID, true)
		if err != nil {
			return nil, err
		}
		if !exists {
			// 正常事务会一次提交全部子抽奖；如果只读到部分子命令，说明上一次写入被手工破坏，继续补写会造成重复返奖。
			return nil, xerr.New(xerr.Conflict, "lucky gift batch draw is partially persisted")
		}
		results = append(results, result)
	}
	return results, nil
}

// executeOptimizedLuckyGiftDrawBatch 是 gift_count > 1 的主路径。
// 性能关键点：所有共享行先 FOR UPDATE 一次，循环中只推进内存副本，事务尾部再批量写回。
// 业务关键点：抽奖仍然按 N 次独立随机，发奖和 IM 表现按“一次送礼命令”聚合成一条 outbox。
func (r *Repository) executeOptimizedLuckyGiftDrawBatch(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, drawCount int32, nowMS int64) ([]domain.DrawResult, error) {
	appCode := appcode.FromContext(ctx)
	poolID := luckyPoolID(cmd.PoolID)

	// 规则配置是本批抽奖的不可变快照；同一批内不允许因为后台改配置导致前后两抽规则不同。
	// 实验期按用户钉住组内版本，同一用户在实验生命周期内的解析结果确定。
	ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, appCode, poolID, domain.ExperimentUserKey(cmd.UserID), true)
	if err != nil {
		return nil, err
	}
	if !exists || !ruleConfig.Enabled {
		return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
	}
	if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
		// dynamic_v3 必须在同一个事务里推进 P/W 重抽、充值层、大奖资格和六维风控；
		// 不能先落入 fixed_v2 再在随机后修结果，否则审计和资金状态都会分叉。
		return r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, drawCount, ruleConfig, luckyDynamicPersistence{}, nowMS)
	}
	if cmd.PaidAtMS <= 0 {
		// fixed_v2 在旧 room/wallet 滚动发布期间允许使用 owner 收到请求的 UTC 时间兼容；
		// dynamic_v3 在上面的专用路径严格拒绝缺失 wallet paid_at，不能走这条降级。
		cmd.PaidAtMS = nowMS
	}
	baseConfig, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
	if err != nil {
		return nil, err
	}
	referencePrice := baseConfig.GiftPrice
	// 用户状态锁必须覆盖整批抽奖，否则同一用户并发送礼会交错推进 equivalent_draws 和 loss_streak。
	userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, baseConfig.GiftID, nowMS)
	if err != nil {
		return nil, err
	}

	windowScopeID := luckyRuleWindowScopeID(baseConfig)
	// RTP 窗口只做观察和结算口径，不用 RTP 缺口改概率；批量路径仍要累计每一抽的 wager 和 base payout。
	globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", windowScopeID, baseConfig.GlobalWindowDraws, baseConfig.TargetRTPPPM, nowMS)
	if err != nil {
		return nil, err
	}
	giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, baseConfig.GiftWindowDraws, baseConfig.TargetRTPPPM, nowMS)
	if err != nil {
		return nil, err
	}
	// 单一基础奖池是本批所有子抽共享的支付能力；锁一次后在内存里扣减，保证批量高倍奖不会透支 reserve。
	basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(baseConfig), baseConfig.InitialBasePool, baseConfig.BasePoolReserve, nowMS)
	if err != nil {
		return nil, err
	}
	state := luckyBatchDrawState{
		GlobalWindow:    globalWindow,
		GiftWindow:      giftWindow,
		BasePool:        basePool,
		V2BoostLoaded:   basePool.V2BoostActive,
		FinalLossStreak: userState.LossStreak,
		FinalCumulative: userState.CumulativeWagerCoins,
		FinalEquivalent: userState.EquivalentDraws,
		DrawIDs:         make([]string, 0, drawCount),
		Records:         make([]luckyDrawRecordInput, 0, drawCount),
	}
	results := make([]domain.DrawResult, 0, drawCount)
	for index := int32(1); index <= drawCount; index++ {
		// 每个礼物数量是一抽；金额按总扣费拆分，余数落在前几抽，保证所有子抽 coin_spent 之和等于扣费事实。
		unitCommand := cmd
		unitCommand.CommandID = luckyDrawSubCommandID(cmd.CommandID, index, drawCount)
		unitCommand.GiftCount = 1
		unitCommand.CoinSpent = luckyDrawUnitSpend(cmd.CoinSpent, drawCount, index)
		if unitCommand.CoinSpent <= 0 {
			return nil, xerr.New(xerr.InvalidArgument, "lucky gift unit spend must be positive")
		}
		result, err := r.stageLuckyBatchDraw(appCode, baseConfig, referencePrice, unitCommand, nowMS, &state)
		if err != nil {
			return nil, err
		}
		results = append(results, result)
	}

	aggregate := luckyAggregateDrawResults(cmd, results)
	// 响应里的倍数/金额用聚合值，审计表仍保留每一抽的明细；客户端看到的是“一次送礼的总中奖结果”。
	state.AggregateResult = aggregate
	state.AggregateStatus = aggregate.RewardStatus
	state.AggregateCommandID = cmd.CommandID
	if state.NeedsRewardSettlement {
		// 批量命令只需要一次钱包发放；所有子抽奖共享这条 settlement outbox 的最终收敛状态。
		state.AggregateResult.RewardStatus = domain.StatusPending
	} else {
		state.AggregateResult.RewardStatus = domain.StatusGranted
	}
	if err := r.persistLuckyBatchDraw(ctx, tx, appCode, cmd, baseConfig, drawCount, state, nowMS); err != nil {
		return nil, err
	}
	return results, nil
}

// stageLuckyBatchDraw 执行“单个子抽”的纯业务推进。
// 这个函数不写数据库：它只读取并修改 luckyBatchDrawState，确保 777 抽不会变成 777 轮 SQL 往返。
func (r *Repository) stageLuckyBatchDraw(appCode string, baseConfig domain.Config, referencePrice int64, cmd domain.DrawCommand, nowMS int64, state *luckyBatchDrawState) (domain.DrawResult, error) {
	// 奖励金额按本子抽实际金币缩放，但阶段阈值仍按累计流水 / 参考价格折算等价抽数。
	config := luckyRuntimeConfig(baseConfig, cmd.CoinSpent)
	state.FinalCumulative += cmd.CoinSpent
	state.FinalEquivalent = luckyEquivalentDraws(state.FinalCumulative, referencePrice)
	experiencePool := luckyExperiencePool(state.FinalEquivalent, config)
	stageFeedback := luckyStageFeedback(state.FinalEquivalent, config)
	state.GlobalWindow = state.GlobalWindow.withCurrentWager(cmd.CoinSpent)
	state.GiftWindow = state.GiftWindow.withCurrentWager(cmd.CoinSpent)

	// 先把本抽入池计入单池内存水位，再做候选过滤；这样同一批内前后抽的支付能力与真实资金流一致。
	baseIn := luckyBasePoolIn(config, cmd.CoinSpent)
	creditLuckyPoolInMemory(&state.BasePool, baseIn)
	state.BaseIn += baseIn

	// fixed_v2 高水位加权按子抽粒度评估：入池后的最新内存水位推进滞回状态，命中后放大本抽中奖返奖。
	boosted := advanceLuckyV2HighWaterBoost(config, &state.BasePool)
	if boosted {
		config = luckyV2BoostedConfig(config)
	}

	// 候选过滤使用已经被本批前序子抽消耗过的内存池，避免批量同时命中超过容量的高倍奖。
	candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, state.BasePool)
	if err != nil {
		return domain.DrawResult{}, err
	}
	if boosted {
		// 审计标记进入 candidate_tiers_json.limited，复盘时能区分“原始档位”与“高水位加权后的档位”。
		limited["v2_high_water_boost"] = true
	}
	// 随机后只落事实，不再改候选；这里扣减内存容量，让后续子抽立即感知本抽成本。
	debitLuckyPoolInMemory(&state.BasePool, candidate.BaseReward)
	state.BaseOut += candidate.BaseReward
	state.EffectiveReward += candidate.effectiveReward()
	if candidate.effectiveReward() > 0 {
		state.FinalLossStreak = 0
	} else {
		state.FinalLossStreak++
	}
	state.StageFeedback = state.StageFeedback || stageFeedback
	state.Presentation = state.Presentation || candidate.Presentation
	state.HighMultiplier = state.HighMultiplier || candidate.HighMultiplier
	if luckyDrawNeedsRewardSettlementOutbox(candidate) {
		state.NeedsRewardSettlement = true
	}

	// 每个子抽必须有独立 draw_id，保证审计、列表和重试读回都能定位到具体一次随机结果。
	drawID := idgen.New("lucky_draw")
	rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
	record := luckyDrawRecordInput{
		AppCode:        appCode,
		DrawID:         drawID,
		Command:        cmd,
		Config:         config,
		ExperiencePool: experiencePool,
		Candidate:      candidate,
		Limited:        limited,
		StageFeedback:  stageFeedback,
		GlobalWindow:   state.GlobalWindow,
		GiftWindow:     state.GiftWindow,
		BasePool:       state.BasePool,
		RewardStatus:   rewardStatus,
		NowMS:          nowMS,
	}
	state.DrawIDs = append(state.DrawIDs, drawID)
	state.Records = append(state.Records, record)
	// 返回给上层的单抽结果只用于聚合响应和聚合 outbox；真正的 DB 写入来自 state.Records。
	return domain.DrawResult{
		DrawID:               drawID,
		CommandID:            cmd.CommandID,
		PoolID:               config.PoolID,
		GiftID:               cmd.GiftID,
		RuleVersion:          config.RuleVersion,
		ExperiencePool:       experiencePool,
		SelectedTierID:       candidate.TierID,
		MultiplierPPM:        candidate.MultiplierPPM,
		BaseRewardCoins:      candidate.BaseReward,
		EffectiveRewardCoins: candidate.effectiveReward(),
		RewardStatus:         rewardStatus,
		RTPWindowIndex:       state.GlobalWindow.WindowIndex,
		GiftRTPWindowIndex:   state.GiftWindow.WindowIndex,
		GlobalBaseRTPPPM:     luckyRTPPPM(state.GlobalWindow.WagerCoins, state.GlobalWindow.ActualPayoutCoins+state.BaseOut),
		GiftBaseRTPPPM:       luckyRTPPPM(state.GiftWindow.WagerCoins, state.GiftWindow.ActualPayoutCoins+state.BaseOut),
		StageFeedback:        stageFeedback,
		HighMultiplier:       candidate.HighMultiplier,
		CreatedAtMS:          nowMS,
	}, nil
}

// persistLuckyBatchDraw 把批量内存账本一次性落库。
// 写入顺序保持“窗口/奖池/用户状态 -> 抽奖事实 -> outbox”，让 outbox 永远指向已经存在的 draw records。
func (r *Repository) persistLuckyBatchDraw(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, config domain.Config, drawCount int32, state luckyBatchDrawState, nowMS int64) error {
	// RTP 窗口按本批 drawCount 增加抽数，按本批总 wager/base payout 增加结算口径。
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_rtp_windows
		SET paid_draws = paid_draws + ?,
		    wager_coins = ?,
		    target_payout_coins = ?,
		    actual_base_payout_coins = actual_base_payout_coins + ?,
		    carry_ppm = ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
		drawCount, state.GlobalWindow.WagerCoins, state.GlobalWindow.TargetPayoutCoins, state.BaseOut, state.GlobalWindow.CarryPPM, nowMS,
		appCode, state.GlobalWindow.ScopeType, state.GlobalWindow.ScopeID, state.GlobalWindow.WindowIndex,
	); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_rtp_windows
		SET paid_draws = paid_draws + ?,
		    wager_coins = ?,
		    target_payout_coins = ?,
		    actual_base_payout_coins = actual_base_payout_coins + ?,
		    carry_ppm = ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
		drawCount, state.GiftWindow.WagerCoins, state.GiftWindow.TargetPayoutCoins, state.BaseOut, state.GiftWindow.CarryPPM, nowMS,
		appCode, state.GiftWindow.ScopeType, state.GiftWindow.ScopeID, state.GiftWindow.WindowIndex,
	); err != nil {
		return err
	}
	if err := r.updateLuckyPoolNet(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), state.BaseIn, state.BaseOut, nowMS); err != nil {
		return err
	}
	if state.BasePool.V2BoostActive != state.V2BoostLoaded {
		// 滞回标志只在翻转时写回；批量内多次翻转只需要最终值，中间轨迹已固化在每条 draw record 的审计快照里。
		if err := r.persistLuckyPoolV2BoostFlag(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), state.BasePool.V2BoostActive, nowMS); err != nil {
			return err
		}
	}
	// 用户体验状态只取本批最终值；中间值已经体现在每条 draw record 的 experience_pool 快照里。
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_states
		SET paid_draws = paid_draws + ?,
		    cumulative_wager_coins = ?,
		    equivalent_draws = ?,
		    loss_streak = ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
		drawCount, state.FinalCumulative, state.FinalEquivalent, state.FinalLossStreak, nowMS, appCode, cmd.UserID, config.GiftID,
	); err != nil {
		return err
	}
	if err := r.insertLuckyDrawRecords(ctx, tx, state.Records); err != nil {
		return err
	}
	if state.NeedsRewardSettlement {
		// 主事务只写返奖结算 outbox；后台统计由低频快照任务从 lucky_draw_records 增量刷新。
		return r.insertLuckyAggregateRewardSettlementOutbox(ctx, tx, appCode, cmd, state, nowMS)
	}
	return nil
}

// updateLuckyPoolNet 用一条 SQL 同时写入本批入池和出池，避免循环里 credit/debit 分别 UPDATE。
func (r *Repository) updateLuckyPoolNet(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, amountIn, amountOut, nowMS int64) error {
	if amountIn <= 0 && amountOut <= 0 {
		return nil
	}
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_pools
		SET balance = balance + ? - ?,
		    total_in = total_in + ?,
		    total_out = total_out + ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
		amountIn, amountOut, amountIn, amountOut, nowMS, appCode, scopeType, scopeID,
	)
	return err
}

// insertLuckyDrawRecords 批量写入审计明细。
// 仍然保留每一抽一条记录，因为后台统计“抽奖次数”和问题复盘都需要单抽粒度；优化点是批量 INSERT 而不是 N 次 INSERT。
func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error {
	if len(records) == 0 {
		return nil
	}
	const chunkSize = 200
	for start := 0; start < len(records); start += chunkSize {
		end := start + chunkSize
		if end > len(records) {
			end = len(records)
		}
		batch := records[start:end]
		var sqlBuilder strings.Builder
		sqlBuilder.WriteString(`
			INSERT INTO lucky_draw_records (
				app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, visible_region_id, pool_id, gift_id,
				coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
				selected_tier_id, base_reward_coins, effective_reward_coins, stage_feedback, high_multiplier,
				candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
				paid_at_ms, created_at_ms, updated_at_ms
			) VALUES `)
		args := make([]any, 0, len(batch)*29)
		rowPlaceholders := "(" + luckySQLPlaceholders(29) + ")"
		for index, record := range batch {
			if index > 0 {
				sqlBuilder.WriteString(",")
			}
			sqlBuilder.WriteString(rowPlaceholders)
			args = append(args, luckyDrawRecordArgs(record)...)
		}
		if _, err := tx.ExecContext(ctx, sqlBuilder.String(), args...); err != nil {
			return err
		}
	}
	// 画像事件与抽奖事实同事务提交；后台页面因此不依赖下一轮历史回灌，同时事件唯一键仍允许回灌安全补旧数据。
	facts := make([]luckyUserProfileFact, 0, len(records))
	for _, record := range records {
		userID := record.Command.UserID
		facts = append(facts, luckyUserProfileFactFromRecord(record, luckyUserProfileSourceInternal,
			domain.UserProfileIdentityInternal, fmt.Sprintf("%d", userID), "", userID))
	}
	return r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, records[len(records)-1].NowMS)
}

type luckyDrawPoolStatKey struct {
	AppCode string
	PoolID  string
	GiftID  string
}

type luckyDrawPoolStatDelta struct {
	TotalDraws       int64
	TotalSpentCoins  int64
	TotalRewardCoins int64
	BaseRewardCoins  int64
	PendingDraws     int64
	GrantedDraws     int64
	FailedDraws      int64
	UserIDs          map[int64]struct{}
	RoomIDs          map[string]struct{}
}

func (r *Repository) insertLuckyDrawStatUsers(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, userIDs map[int64]struct{}, nowMS int64) (int64, error) {
	var inserted int64
	for userID := range userIDs {
		result, err := tx.ExecContext(ctx, `
			INSERT IGNORE INTO lucky_draw_pool_stat_users (app_code, pool_id, gift_id, user_id, created_at_ms)
			VALUES (?, ?, ?, ?, ?)`,
			key.AppCode, key.PoolID, key.GiftID, userID, nowMS,
		)
		if err != nil {
			return 0, err
		}
		rows, _ := result.RowsAffected()
		inserted += rows
	}
	return inserted, nil
}

func (r *Repository) insertLuckyDrawStatRooms(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, roomIDs map[string]struct{}, nowMS int64) (int64, error) {
	var inserted int64
	for roomID := range roomIDs {
		result, err := tx.ExecContext(ctx, `
			INSERT IGNORE INTO lucky_draw_pool_stat_rooms (app_code, pool_id, gift_id, room_id, created_at_ms)
			VALUES (?, ?, ?, ?, ?)`,
			key.AppCode, key.PoolID, key.GiftID, roomID, nowMS,
		)
		if err != nil {
			return 0, err
		}
		rows, _ := result.RowsAffected()
		inserted += rows
	}
	return inserted, nil
}

func (r *Repository) upsertLuckyDrawPoolStatsSnapshotDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolStatKey, delta luckyDrawPoolStatDelta, uniqueUsers int64, uniqueRooms int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_draw_pool_stats (
			app_code, pool_id, gift_id, total_draws, unique_users, unique_rooms, total_spent_coins,
			total_reward_coins, base_reward_coins,
			pending_draws, granted_draws, failed_draws, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			total_draws = total_draws + VALUES(total_draws),
			unique_users = unique_users + VALUES(unique_users),
			unique_rooms = unique_rooms + VALUES(unique_rooms),
			total_spent_coins = total_spent_coins + VALUES(total_spent_coins),
			total_reward_coins = total_reward_coins + VALUES(total_reward_coins),
			base_reward_coins = base_reward_coins + VALUES(base_reward_coins),
			pending_draws = pending_draws + VALUES(pending_draws),
			granted_draws = granted_draws + VALUES(granted_draws),
			failed_draws = failed_draws + VALUES(failed_draws),
			updated_at_ms = VALUES(updated_at_ms)`,
		key.AppCode, key.PoolID, key.GiftID, delta.TotalDraws, uniqueUsers, uniqueRooms, delta.TotalSpentCoins,
		delta.TotalRewardCoins, delta.BaseRewardCoins, delta.PendingDraws, delta.GrantedDraws, delta.FailedDraws, nowMS, nowMS,
	)
	return err
}

type luckyDrawStatsCursor struct {
	LastDrawCreatedAtMS int64
	LastDrawID          string
}

type luckyDrawStatsRecord struct {
	AppCode          string
	DrawID           string
	PoolID           string
	GiftID           string
	VisibleRegionID  int64
	UserID           int64
	RoomID           string
	TotalSpentCoins  int64
	TotalRewardCoins int64
	BaseRewardCoins  int64
	RewardStatus     string
	CreatedAtMS      int64
	// RuleVersion 只在 Databi 日汇总扫描时填充，用于把抽奖归因到策略族。
	RuleVersion int64
}

func (r *Repository) RefreshLuckyGiftAdminStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) {
	if r == nil || r.db == nil {
		return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		batchSize = 5000
	}
	if batchSize > 50000 {
		batchSize = 50000
	}
	appCode := appcode.FromContext(ctx)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()

	cursor, initialized, err := r.lockLuckyDrawStatsCursor(ctx, tx, appCode, nowMS)
	if err != nil {
		return 0, err
	}
	if initialized {
		if err := tx.Commit(); err != nil {
			return 0, err
		}
		return 0, nil
	}

	rows, err := tx.QueryContext(ctx, `
		SELECT app_code, draw_id, pool_id, gift_id, user_id, room_id, coin_spent,
		       effective_reward_coins, base_reward_coins, reward_status,
		       created_at_ms
		FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
		WHERE app_code = ?
		  AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?))
		ORDER BY created_at_ms ASC, draw_id ASC
		LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize)
	if err != nil {
		return 0, err
	}
	defer rows.Close()
	records := make([]luckyDrawStatsRecord, 0, batchSize)
	for rows.Next() {
		var record luckyDrawStatsRecord
		if err := rows.Scan(&record.AppCode, &record.DrawID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID,
			&record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins, &record.RewardStatus, &record.CreatedAtMS); err != nil {
			return 0, err
		}
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return 0, err
	}
	if len(records) == 0 {
		if err := tx.Commit(); err != nil {
			return 0, err
		}
		return 0, nil
	}
	grouped := map[luckyDrawPoolStatKey]*luckyDrawPoolStatDelta{}
	last := records[len(records)-1]
	for _, record := range records {
		keys := []luckyDrawPoolStatKey{{AppCode: record.AppCode, PoolID: record.PoolID, GiftID: ""}}
		if giftID := strings.TrimSpace(record.GiftID); giftID != "" {
			keys = append(keys, luckyDrawPoolStatKey{AppCode: record.AppCode, PoolID: record.PoolID, GiftID: giftID})
		}
		for _, key := range keys {
			delta := grouped[key]
			if delta == nil {
				delta = &luckyDrawPoolStatDelta{UserIDs: map[int64]struct{}{}, RoomIDs: map[string]struct{}{}}
				grouped[key] = delta
			}
			delta.TotalDraws++
			delta.TotalSpentCoins += record.TotalSpentCoins
			delta.TotalRewardCoins += record.TotalRewardCoins
			delta.BaseRewardCoins += record.BaseRewardCoins
			switch record.RewardStatus {
			case domain.StatusPending:
				delta.PendingDraws++
			case domain.StatusGranted:
				delta.GrantedDraws++
			case domain.StatusFailed:
				delta.FailedDraws++
			}
			if record.UserID > 0 {
				delta.UserIDs[record.UserID] = struct{}{}
			}
			if roomID := strings.TrimSpace(record.RoomID); roomID != "" {
				delta.RoomIDs[roomID] = struct{}{}
			}
		}
	}
	for key, delta := range grouped {
		uniqueUsers, err := r.insertLuckyDrawStatUsers(ctx, tx, key, delta.UserIDs, nowMS)
		if err != nil {
			return 0, err
		}
		uniqueRooms, err := r.insertLuckyDrawStatRooms(ctx, tx, key, delta.RoomIDs, nowMS)
		if err != nil {
			return 0, err
		}
		if err := r.upsertLuckyDrawPoolStatsSnapshotDelta(ctx, tx, key, *delta, uniqueUsers, uniqueRooms, nowMS); err != nil {
			return 0, err
		}
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_draw_pool_stat_cursors
		SET last_draw_created_at_ms = ?, last_draw_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND cursor_name = 'draw_records'`,
		last.CreatedAtMS, last.DrawID, nowMS, appCode,
	); err != nil {
		return 0, err
	}
	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return len(records), nil
}

type luckyDrawPoolDayStatKey struct {
	AppCode         string
	StatDay         string
	VisibleRegionID int64
	PoolID          string
	GiftID          string
	StrategyVersion string
}

type luckyDrawPoolDayStatDelta struct {
	DrawCount      int64
	TurnoverCoin   int64
	PayoutCoin     int64
	BaseRewardCoin int64
}

// RefreshLuckyGiftDatabiStatsSnapshots 用独立游标把抽奖事实小批量转成 Databi 日维度汇总。
// Databi 页面只读 lucky_draw_pool_day_stats，不直接扫 lucky_draw_records，避免页面访问把事实表打成查询热点。
func (r *Repository) RefreshLuckyGiftDatabiStatsSnapshots(ctx context.Context, nowMS int64, batchSize int) (int, error) {
	if r == nil || r.db == nil {
		return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		batchSize = 5000
	}
	if batchSize > 50000 {
		batchSize = 50000
	}
	appCode := appcode.FromContext(ctx)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()

	cursor, err := r.lockLuckyDrawDatabiStatsCursor(ctx, tx, appCode, nowMS)
	if err != nil {
		return 0, err
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT app_code, draw_id, visible_region_id, pool_id, gift_id, user_id, room_id, coin_spent,
		       effective_reward_coins, base_reward_coins, reward_status,
		       created_at_ms, rule_version
		FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
		WHERE app_code = ?
		  AND (created_at_ms > ? OR (created_at_ms = ? AND draw_id > ?))
		ORDER BY created_at_ms ASC, draw_id ASC
		LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize)
	if err != nil {
		return 0, err
	}
	defer rows.Close()
	records := make([]luckyDrawStatsRecord, 0, batchSize)
	for rows.Next() {
		var record luckyDrawStatsRecord
		if err := rows.Scan(&record.AppCode, &record.DrawID, &record.VisibleRegionID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID,
			&record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins, &record.RewardStatus, &record.CreatedAtMS, &record.RuleVersion); err != nil {
			return 0, err
		}
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return 0, err
	}
	if len(records) == 0 {
		if err := tx.Commit(); err != nil {
			return 0, err
		}
		return 0, nil
	}
	strategies, err := r.luckyRuleStrategyVersions(ctx, tx, appCode, records)
	if err != nil {
		return 0, err
	}
	grouped := map[luckyDrawPoolDayStatKey]*luckyDrawPoolDayStatDelta{}
	last := records[len(records)-1]
	for _, record := range records {
		regionID := record.VisibleRegionID
		if regionID < 0 {
			regionID = 0
		}
		day := luckyDrawStatDay(record.CreatedAtMS)
		strategy := strategies[luckyPoolRuleVersionKey{PoolID: record.PoolID, RuleVersion: record.RuleVersion}]
		if strategy == "" {
			// 找不到规则行的历史抽奖（含 rule_version=0 的早期记录）与 rule_versions 列默认一致，按 fixed_v2 归因。
			strategy = domain.StrategyFixedV2
		}
		keys := []luckyDrawPoolDayStatKey{{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: "", StrategyVersion: strategy}}
		if giftID := strings.TrimSpace(record.GiftID); giftID != "" {
			keys = append(keys, luckyDrawPoolDayStatKey{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: giftID, StrategyVersion: strategy})
		}
		for _, key := range keys {
			delta := grouped[key]
			if delta == nil {
				delta = &luckyDrawPoolDayStatDelta{}
				grouped[key] = delta
			}
			delta.DrawCount++
			delta.TurnoverCoin += record.TotalSpentCoins
			delta.PayoutCoin += record.TotalRewardCoins
			delta.BaseRewardCoin += record.BaseRewardCoins
		}
	}
	for key, delta := range grouped {
		if err := r.upsertLuckyDrawPoolDayStatsDelta(ctx, tx, key, *delta, nowMS); err != nil {
			return 0, err
		}
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_draw_pool_stat_cursors
		SET last_draw_created_at_ms = ?, last_draw_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND cursor_name = 'draw_records_databi_day'`,
		last.CreatedAtMS, last.DrawID, nowMS, appCode,
	); err != nil {
		return 0, err
	}
	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return len(records), nil
}

func (r *Repository) upsertLuckyDrawPoolDayStatsDelta(ctx context.Context, tx *sql.Tx, key luckyDrawPoolDayStatKey, delta luckyDrawPoolDayStatDelta, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_draw_pool_day_stats (
			app_code, stat_day, visible_region_id, pool_id, gift_id, strategy_version, draw_count, turnover_coin, payout_coin,
			base_reward_coin, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			draw_count = draw_count + VALUES(draw_count),
			turnover_coin = turnover_coin + VALUES(turnover_coin),
			payout_coin = payout_coin + VALUES(payout_coin),
			base_reward_coin = base_reward_coin + VALUES(base_reward_coin),
			updated_at_ms = VALUES(updated_at_ms)`,
		key.AppCode, key.StatDay, key.VisibleRegionID, key.PoolID, key.GiftID, key.StrategyVersion, delta.DrawCount, delta.TurnoverCoin, delta.PayoutCoin,
		delta.BaseRewardCoin, nowMS, nowMS,
	)
	return err
}

type luckyPoolRuleVersionKey struct {
	PoolID      string
	RuleVersion int64
}

// luckyRuleStrategyVersions 按批次取涉及奖池的全部规则版本→策略族映射；
// 版本表是发布次数级小表，按奖池整取比 (pool, version) 行构造 IN 更贴合 PK 前缀。
func (r *Repository) luckyRuleStrategyVersions(ctx context.Context, tx *sql.Tx, appCode string, records []luckyDrawStatsRecord) (map[luckyPoolRuleVersionKey]string, error) {
	poolSet := map[string]struct{}{}
	pools := make([]string, 0, 4)
	for _, record := range records {
		if _, ok := poolSet[record.PoolID]; !ok {
			poolSet[record.PoolID] = struct{}{}
			pools = append(pools, record.PoolID)
		}
	}
	out := make(map[luckyPoolRuleVersionKey]string, len(pools)*8)
	if len(pools) == 0 {
		return out, nil
	}
	args := make([]any, 0, len(pools)+1)
	args = append(args, appCode)
	for _, pool := range pools {
		args = append(args, pool)
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT pool_id, rule_version, strategy_version
		FROM lucky_gift_rule_versions
		WHERE app_code = ? AND pool_id IN (`+luckySQLPlaceholders(len(pools))+`)`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var key luckyPoolRuleVersionKey
		var strategy string
		if err := rows.Scan(&key.PoolID, &key.RuleVersion, &strategy); err != nil {
			return nil, err
		}
		out[key] = strategy
	}
	return out, rows.Err()
}

// luckyDayStatZone 与 statistics-service 的 Asia/Shanghai 口径一致；用 FixedZone 规避容器缺 tzdata。
// lucky_draw_pool_day_stats 的 stat_day 从上线起就是北京日（迁移 003 由 statistics 同口径旧表导入）。
var luckyDayStatZone = time.FixedZone("Asia/Shanghai", 8*60*60)

func luckyDrawStatDay(ms int64) string {
	return time.UnixMilli(ms).In(luckyDayStatZone).Format("2006-01-02")
}

// ListLuckyGiftDailyStats 只读 lucky_draw_pool_day_stats 的奖池汇总行（gift_id=”），
// 不触碰抽奖事实表；数据新鲜度受后台刷新游标节奏限制，不保证到秒级实时。
func (r *Repository) ListLuckyGiftDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where := []string{"app_code = ?", "gift_id = ''", "stat_day >= ?", "stat_day <= ?"}
	args := []any{query.AppCode, luckyDrawStatDay(query.StartMS), luckyDrawStatDay(query.EndMS - 1)}
	if query.PoolID != "" {
		where = append(where, "pool_id = ?")
		args = append(args, query.PoolID)
	}
	if query.StrategyVersion != "" {
		where = append(where, "strategy_version = ?")
		args = append(args, query.StrategyVersion)
	}
	if len(query.RegionIDs) > 0 {
		where = append(where, "visible_region_id IN ("+luckySQLPlaceholders(len(query.RegionIDs))+")")
		for _, regionID := range query.RegionIDs {
			args = append(args, regionID)
		}
	}
	// DATE_FORMAT 直接取日字符串，避免 DATE 列的扫描类型随 DSN parseTime 参数漂移。
	rows, err := r.db.QueryContext(ctx, `
		SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), SUM(draw_count), SUM(turnover_coin), SUM(payout_coin), SUM(base_reward_coin)
		FROM lucky_draw_pool_day_stats
		WHERE `+strings.Join(where, " AND ")+`
		GROUP BY stat_day
		ORDER BY stat_day ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	stats := make([]domain.DailyStat, 0, 32)
	for rows.Next() {
		var stat domain.DailyStat
		if err := rows.Scan(&stat.StatDay, &stat.DrawCount, &stat.TurnoverCoin, &stat.PayoutCoin, &stat.BaseRewardCoin); err != nil {
			return nil, err
		}
		stats = append(stats, stat)
	}
	return stats, rows.Err()
}

func (r *Repository) lockLuckyDrawStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, bool, error) {
	var cursor luckyDrawStatsCursor
	err := tx.QueryRowContext(ctx, `
		SELECT last_draw_created_at_ms, last_draw_id
		FROM lucky_draw_pool_stat_cursors
		WHERE app_code = ? AND cursor_name = 'draw_records'
		FOR UPDATE`, appCode,
	).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID)
	if err == nil {
		return cursor, false, nil
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return luckyDrawStatsCursor{}, false, err
	}
	if err := tx.QueryRowContext(ctx, `
		SELECT COALESCE(created_at_ms, 0), COALESCE(draw_id, '')
		FROM lucky_draw_records FORCE INDEX (idx_lucky_draw_created)
		WHERE app_code = ?
		ORDER BY created_at_ms DESC, draw_id DESC
		LIMIT 1`, appCode,
	).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID); err != nil && !errors.Is(err, sql.ErrNoRows) {
		return luckyDrawStatsCursor{}, false, err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_draw_pool_stat_cursors (
			app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms
		) VALUES (?, 'draw_records', ?, ?, ?, ?)`,
		appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, nowMS, nowMS,
	); err != nil {
		return luckyDrawStatsCursor{}, false, err
	}
	return cursor, true, nil
}

func (r *Repository) lockLuckyDrawDatabiStatsCursor(ctx context.Context, tx *sql.Tx, appCode string, nowMS int64) (luckyDrawStatsCursor, error) {
	var cursor luckyDrawStatsCursor
	err := tx.QueryRowContext(ctx, `
		SELECT last_draw_created_at_ms, last_draw_id
		FROM lucky_draw_pool_stat_cursors
		WHERE app_code = ? AND cursor_name = 'draw_records_databi_day'
		FOR UPDATE`, appCode,
	).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID)
	if err == nil {
		return cursor, nil
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return luckyDrawStatsCursor{}, err
	}
	// Databi 日汇总是后加的统计口径，首次启动必须从 0 游标小批量回补；不要像后台全量汇总那样跳到最新记录。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_draw_pool_stat_cursors (
			app_code, cursor_name, last_draw_created_at_ms, last_draw_id, created_at_ms, updated_at_ms
		) VALUES (?, 'draw_records_databi_day', 0, '', ?, ?)`,
		appCode, nowMS, nowMS,
	); err != nil {
		return luckyDrawStatsCursor{}, err
	}
	return cursor, nil
}

// luckyDrawRecordArgs 固定 lucky_draw_records 的列顺序；集中维护可以避免批量 INSERT 和单条 INSERT 语义漂移。
func luckyDrawRecordArgs(input luckyDrawRecordInput) []any {
	candidateSnapshot, poolSnapshot, rtpSnapshot := luckyDrawRecordSnapshots(input)
	return []any{
		input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
		input.Command.RoomID, input.Command.AnchorID, input.Command.VisibleRegionID, input.Config.PoolID, input.Command.GiftID, input.Command.CoinSpent,
		input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
		input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.effectiveReward(), input.StageFeedback, input.Candidate.HighMultiplier,
		candidateSnapshot, poolSnapshot, rtpSnapshot, input.RewardStatus, "", "",
		input.Command.PaidAtMS, input.NowMS, input.NowMS,
	}
}

// luckyDrawRecordSnapshots 保存的是抽中当刻的可解释状态，不要求成为完整候选空间。
// 这里重点记录命中奖档、被过滤原因、结算窗口和当时池水位，足够定位“为什么本抽是 0/1/高倍”。
func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, string) {
	candidateData := map[string]any{
		"base_reward":    input.Candidate.BaseReward,
		"limited":        input.Limited,
		"multiplier_ppm": input.Candidate.MultiplierPPM,
		"weight":         input.Candidate.Weight,
	}
	poolData := map[string]any{
		"base_balance":       input.BasePool.Balance,
		"base_reserve_floor": input.BasePool.ReserveFloor,
		"base_total_in":      input.BasePool.TotalIn,
		"base_total_out":     input.BasePool.TotalOut,
	}
	rtpData := map[string]any{
		"global_window": input.GlobalWindow.WindowIndex,
		"gift_window":   input.GiftWindow.WindowIndex,
	}
	if input.StrategyDecision != nil {
		candidateData["dynamic_v3"] = input.StrategyDecision.Trace
		poolData["fund_split"] = input.FundSplit
		rtpData["user_round"] = input.UserRoundRTP
		rtpData["user_48h"] = input.User48HourRTP
		for _, condition := range input.StrategyDecision.Trace.Conditions {
			if condition.Name != domain.StrategyConditionUser24HourRTPDownweight {
				continue
			}
			// Persist explicit business names rather than forcing incident tooling to reinterpret the
			// generic condition numerator/denominator. This is the pre-draw snapshot; current payout is
			// added only to StrategyDecision.NextState for the next subdraw.
			rtpData["user_24h_downweight"] = map[string]any{
				"wager_coins": condition.Denominator, "payout_coins": condition.Numerator,
				"rtp_ppm": condition.RatioPPM, "threshold_ppm": condition.LimitPPM,
				"ordinary_win_factor_ppm": condition.FactorPPM,
				"active":                  condition.Passed, "reason": condition.Reason,
			}
			break
		}
		// Eligibility uses the just-closed platform snapshot; it is intentionally frozen while the
		// current open window advances independently on this draw.
		rtpData["qualification_global"] = input.StrategyDecision.NextState.GlobalRTP
	}
	candidateSnapshot, _ := json.Marshal(candidateData)
	poolSnapshot, _ := json.Marshal(poolData)
	rtpSnapshot, _ := json.Marshal(rtpData)
	return string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot)
}

// insertLuckyAggregateRewardSettlementOutbox 为整批抽奖写聚合返奖结算事件。
// draw_id 使用聚合响应的首个 draw，draw_ids 保存全部子抽；settlement 用总 effective_reward_coins 发一次奖，再批量 granted。
func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, state luckyBatchDrawState, nowMS int64) error {
	aggregate := state.AggregateResult
	payload, _ := json.Marshal(map[string]any{
		"event_type":                    "lucky_gift_drawn",
		"event_id":                      "lucky_gift_drawn:" + aggregate.DrawID,
		"app_code":                      appCode,
		"draw_id":                       aggregate.DrawID,
		"draw_ids":                      state.DrawIDs,
		"command_id":                    cmd.CommandID,
		"pool_id":                       aggregate.PoolID,
		"user_id":                       cmd.UserID,
		"sender_user_id":                cmd.UserID,
		"target_user_id":                cmd.TargetUserID,
		"sender_name":                   cmd.SenderName,
		"sender_avatar":                 cmd.SenderAvatar,
		"sender_display_user_id":        cmd.SenderDisplayUserID,
		"sender_pretty_display_user_id": cmd.SenderPrettyDisplayUserID,
		"visible_region_id":             cmd.VisibleRegionID,
		"room_locked":                   cmd.RoomLocked,
		"owner_user_id":                 cmd.OwnerUserID,
		"country_id":                    cmd.CountryID,
		"room_id":                       cmd.RoomID,
		"gift_id":                       cmd.GiftID,
		"gift_count":                    cmd.GiftCount,
		"coin_spent":                    cmd.CoinSpent,
		"rule_version":                  aggregate.RuleVersion,
		"experience_pool":               aggregate.ExperiencePool,
		"selected_tier_id":              aggregate.SelectedTierID,
		"multiplier_ppm":                aggregate.MultiplierPPM,
		"base_reward_coins":             aggregate.BaseRewardCoins,
		"effective_reward_coins":        aggregate.EffectiveRewardCoins,
		"stage_feedback":                aggregate.StageFeedback,
		"high_multiplier":               aggregate.HighMultiplier,
		"created_at_ms":                 nowMS,
	})
	return r.insertLuckyRewardSettlementOutbox(ctx, tx, appCode, aggregate.DrawID, payload, nowMS)
}

func (r *Repository) executeSingleLuckyGiftDraw(ctx context.Context, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error) {
	appCode := appcode.FromContext(ctx)
	poolID := luckyPoolID(cmd.PoolID)
	if existing, exists, err := r.getLuckyDrawByCommand(ctx, tx, appCode, cmd.CommandID, true); err != nil || exists {
		if err != nil {
			return domain.DrawResult{}, err
		}
		return existing, nil
	}

	ruleConfig, exists, err := r.resolveLuckyGiftRuleConfigForUser(ctx, tx, appCode, poolID, domain.ExperimentUserKey(cmd.UserID), true)
	if err != nil {
		return domain.DrawResult{}, err
	}
	if !exists || !ruleConfig.Enabled {
		return domain.DrawResult{}, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
	}
	if ruleConfig.StrategyVersion == domain.StrategyDynamicV3 {
		results, err := r.executeDynamicLuckyGiftDrawBatch(ctx, tx, cmd, 1, ruleConfig, luckyDynamicPersistence{}, nowMS)
		if err != nil {
			return domain.DrawResult{}, err
		}
		if len(results) != 1 {
			return domain.DrawResult{}, xerr.New(xerr.Internal, "dynamic lucky gift single draw returned an invalid result count")
		}
		return results[0], nil
	}
	if cmd.PaidAtMS <= 0 {
		// 仅历史 fixed_v2 接受缺失 paid_at 的滚动发布兼容；新 dynamic_v3 已在专用路径 fail-close。
		cmd.PaidAtMS = nowMS
	}
	config, err := luckyRuntimeConfigFromRuleConfig(ruleConfig)
	if err != nil {
		return domain.DrawResult{}, err
	}
	referencePrice := config.GiftPrice
	// 后台配置的 gift_price 只是参考价格；实际单抽按本次子抽奖的 coin_spent 等比缩放倍率奖金额。
	config = luckyRuntimeConfig(config, cmd.CoinSpent)

	userState, err := r.getLuckyUserStateForUpdate(ctx, tx, appCode, cmd.UserID, config.GiftID, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	// 体验池仍按“累计金币流水 / 参考价格”折算等价抽数；批量送礼已在外层拆成单抽，这里只推进当前这一抽。
	nextCumulativeWager := userState.CumulativeWagerCoins + cmd.CoinSpent
	nextEquivalentDraws := luckyEquivalentDraws(nextCumulativeWager, referencePrice)
	experiencePool := luckyExperiencePool(nextEquivalentDraws, config)
	stageFeedback := luckyStageFeedback(nextEquivalentDraws, config)

	windowScopeID := luckyRuleWindowScopeID(config)
	// RTP 窗口按 pool + rule_version 隔离，只记录观察口径；候选随机不再读取缺口，否则会出现一段时间一直亏或一直赚。
	globalWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.GlobalWindowDraws, config.TargetRTPPPM, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	globalWindow = globalWindow.withCurrentWager(cmd.CoinSpent)
	giftWindow, err := r.getOpenLuckyRTPWindow(ctx, tx, appCode, "pool_gift", windowScopeID, config.GiftWindowDraws, config.TargetRTPPPM, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	giftWindow = giftWindow.withCurrentWager(cmd.CoinSpent)

	// 单一基础奖池承接 base_reward_coins；候选只看这口池的可用水位，不再被 platform/room/gift 任一旧分池单独卡住。
	basePool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), config.InitialBasePool, config.BasePoolReserve, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	// 每笔扣费先按入池比例补充单一基础池，再判断本次能否支付，避免同一事务里先抽后补造成水位偏差。
	if err := r.creditLuckyBasePool(ctx, tx, appCode, config, cmd.CoinSpent, &basePool, nowMS); err != nil {
		return domain.DrawResult{}, err
	}

	// fixed_v2 高水位加权：入池后的最新水位推进滞回状态，命中后放大本抽中奖返奖；标志翻转立即落库。
	boostBefore := basePool.V2BoostActive
	boosted := advanceLuckyV2HighWaterBoost(config, &basePool)
	if boosted {
		config = luckyV2BoostedConfig(config)
	}
	if basePool.V2BoostActive != boostBefore {
		if err := r.persistLuckyPoolV2BoostFlag(ctx, tx, appCode, luckyBasePoolScopeType, basePool.ScopeID, basePool.V2BoostActive, nowMS); err != nil {
			return domain.DrawResult{}, err
		}
	}

	// 所有过滤都发生在随机前；随机后只能落事实和副作用 outbox，不能再改成另一个结果。
	candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, basePool)
	if err != nil {
		return domain.DrawResult{}, err
	}
	if boosted {
		limited["v2_high_water_boost"] = true
	}

	drawID := idgen.New("lucky_draw")
	rewardStatus := luckyInitialRewardStatus(candidate, stageFeedback)
	if err := r.applyLuckyDraw(ctx, tx, luckyApplyInput{
		AppCode:                  appCode,
		DrawID:                   drawID,
		Command:                  cmd,
		Config:                   config,
		ExperiencePool:           experiencePool,
		Candidate:                candidate,
		Limited:                  limited,
		StageFeedback:            stageFeedback,
		NextCumulativeWagerCoins: nextCumulativeWager,
		NextEquivalentDraws:      nextEquivalentDraws,
		GlobalWindow:             globalWindow,
		GiftWindow:               giftWindow,
		BasePool:                 basePool,
		RewardStatus:             rewardStatus,
		NowMS:                    nowMS,
	}); err != nil {
		return domain.DrawResult{}, err
	}

	return domain.DrawResult{
		DrawID:               drawID,
		CommandID:            cmd.CommandID,
		PoolID:               config.PoolID,
		GiftID:               cmd.GiftID,
		RuleVersion:          config.RuleVersion,
		ExperiencePool:       experiencePool,
		SelectedTierID:       candidate.TierID,
		MultiplierPPM:        candidate.MultiplierPPM,
		BaseRewardCoins:      candidate.BaseReward,
		EffectiveRewardCoins: candidate.effectiveReward(),
		RewardStatus:         rewardStatus,
		RTPWindowIndex:       globalWindow.WindowIndex,
		GiftRTPWindowIndex:   giftWindow.WindowIndex,
		GlobalBaseRTPPPM:     luckyRTPPPM(globalWindow.WagerCoins, globalWindow.ActualPayoutCoins+candidate.BaseReward),
		GiftBaseRTPPPM:       luckyRTPPPM(giftWindow.WagerCoins, giftWindow.ActualPayoutCoins+candidate.BaseReward),
		StageFeedback:        stageFeedback,
		HighMultiplier:       candidate.HighMultiplier,
		CreatedAtMS:          nowMS,
	}, nil
}

func luckyDrawUnitSpend(totalSpent int64, drawCount int32, drawIndex int32) int64 {
	if drawCount <= 1 {
		return totalSpent
	}
	base := totalSpent / int64(drawCount)
	remainder := totalSpent % int64(drawCount)
	if int64(drawIndex) <= remainder {
		return base + 1
	}
	return base
}

func luckyDrawSubCommandID(commandID string, drawIndex int32, drawCount int32) string {
	if drawCount <= 1 {
		return commandID
	}
	suffix := fmt.Sprintf("#%06d", drawIndex)
	const maxCommandIDLength = 128
	if len(commandID)+len(suffix) <= maxCommandIDLength {
		return commandID + suffix
	}
	sum := sha1.Sum([]byte(commandID))
	hash := hex.EncodeToString(sum[:])[:10]
	hashSuffix := "#" + hash + suffix
	headLen := maxCommandIDLength - len(hashSuffix)
	if headLen < 0 {
		headLen = 0
	}
	if len(commandID) > headLen {
		commandID = commandID[:headLen]
	}
	return commandID + hashSuffix
}

func luckyAggregateDrawResults(cmd domain.DrawCommand, results []domain.DrawResult) domain.DrawResult {
	if len(results) == 0 {
		return domain.DrawResult{}
	}
	aggregate := results[0]
	aggregate.CommandID = cmd.CommandID
	aggregate.DrawIDs = make([]string, 0, len(results))
	aggregate.Hits = make([]domain.DrawHit, 0)
	for index, result := range results {
		if strings.TrimSpace(result.DrawID) != "" {
			aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
		}
		if result.EffectiveRewardCoins > 0 {
			// gift_index 从 1 开始，对齐 Flutter 组合礼物的点击/数量序号；只传中奖项，响应大小由中奖数而不是 gift_count 决定。
			aggregate.Hits = append(aggregate.Hits, domain.DrawHit{
				GiftIndex:      int32(index + 1),
				DrawID:         result.DrawID,
				SelectedTierID: result.SelectedTierID,
				MultiplierPPM:  result.MultiplierPPM,
				RewardCoins:    result.EffectiveRewardCoins,
			})
		}
	}
	if len(results) == 1 {
		return aggregate
	}
	aggregate.SelectedTierID = "batch"
	aggregate.BaseRewardCoins = 0
	aggregate.EffectiveRewardCoins = 0
	aggregate.MultiplierPPM = 0
	aggregate.StageFeedback = false
	aggregate.HighMultiplier = false
	aggregate.RewardStatus = domain.StatusGranted
	for _, result := range results {
		aggregate.RuleVersion = result.RuleVersion
		aggregate.ExperiencePool = result.ExperiencePool
		aggregate.RTPWindowIndex = result.RTPWindowIndex
		aggregate.GiftRTPWindowIndex = result.GiftRTPWindowIndex
		aggregate.GlobalBaseRTPPPM = result.GlobalBaseRTPPPM
		aggregate.GiftBaseRTPPPM = result.GiftBaseRTPPPM
		aggregate.CreatedAtMS = result.CreatedAtMS
		aggregate.BaseRewardCoins += result.BaseRewardCoins
		aggregate.EffectiveRewardCoins += result.EffectiveRewardCoins
		// 批量送礼的“中奖倍数”是用户看到的总命中倍数，不是总返奖/总消耗的平均倍率。
		aggregate.MultiplierPPM += result.MultiplierPPM
		aggregate.StageFeedback = aggregate.StageFeedback || result.StageFeedback
		aggregate.HighMultiplier = aggregate.HighMultiplier || result.HighMultiplier
		if result.RewardStatus == domain.StatusPending {
			aggregate.RewardStatus = domain.StatusPending
		} else if aggregate.RewardStatus != domain.StatusPending && result.RewardStatus == domain.StatusFailed {
			aggregate.RewardStatus = domain.StatusFailed
		}
	}
	return aggregate
}

type luckyApplyInput struct {
	AppCode                  string
	DrawID                   string
	Command                  domain.DrawCommand
	Config                   domain.Config
	ExperiencePool           string
	Candidate                luckyCandidate
	Limited                  map[string]bool
	StageFeedback            bool
	NextCumulativeWagerCoins int64
	NextEquivalentDraws      int64
	GlobalWindow             luckyRTPWindow
	GiftWindow               luckyRTPWindow
	BasePool                 luckyPool
	RewardStatus             string
	NowMS                    int64
}

func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input luckyApplyInput) error {
	// 只有基础返奖写入 RTP 窗口；房间气氛和活动补贴不会污染平台成本口径。
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_rtp_windows
		SET paid_draws = paid_draws + 1,
		    wager_coins = ?,
		    target_payout_coins = ?,
		    actual_base_payout_coins = actual_base_payout_coins + ?,
		    carry_ppm = ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
		input.GlobalWindow.WagerCoins, input.GlobalWindow.TargetPayoutCoins, input.Candidate.BaseReward, input.GlobalWindow.CarryPPM, input.NowMS,
		input.AppCode, input.GlobalWindow.ScopeType, input.GlobalWindow.ScopeID, input.GlobalWindow.WindowIndex,
	); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_rtp_windows
		SET paid_draws = paid_draws + 1,
		    wager_coins = ?,
		    target_payout_coins = ?,
		    actual_base_payout_coins = actual_base_payout_coins + ?,
		    carry_ppm = ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
		input.GiftWindow.WagerCoins, input.GiftWindow.TargetPayoutCoins, input.Candidate.BaseReward, input.GiftWindow.CarryPPM, input.NowMS,
		input.AppCode, input.GiftWindow.ScopeType, input.GiftWindow.ScopeID, input.GiftWindow.WindowIndex,
	); err != nil {
		return err
	}
	// 基础返奖从 app+pool_id 的单一基础池扣减；是否足够已经在候选过滤阶段判断，这里只落账本事实。
	if err := r.debitLuckyPool(ctx, tx, input.AppCode, luckyBasePoolScopeType, input.BasePool.ScopeID, input.Candidate.BaseReward, input.NowMS); err != nil {
		return err
	}
	lossStreakSQL := "loss_streak + 1"
	if input.Candidate.effectiveReward() > 0 {
		lossStreakSQL = "0"
	}
	// 用户体验池推进以累计金币流水为准；paid_draws 只保留审计口径，loss_streak 给后续显式保底或体验提示使用。
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_states
		SET paid_draws = paid_draws + 1,
		    cumulative_wager_coins = ?,
		    equivalent_draws = ?,
		    loss_streak = `+lossStreakSQL+`,
		    updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
		input.NextCumulativeWagerCoins, input.NextEquivalentDraws, input.NowMS, input.AppCode, input.Command.UserID, input.Config.GiftID,
	); err != nil {
		return err
	}
	// 当前审计快照先记录命中候选和过滤原因；后续若要完整复盘随机空间，需要扩展为完整候选表。
	candidateSnapshot, _ := json.Marshal(map[string]any{
		"base_reward":    input.Candidate.BaseReward,
		"limited":        input.Limited,
		"multiplier_ppm": input.Candidate.MultiplierPPM,
		"weight":         input.Candidate.Weight,
	})
	poolSnapshot, _ := json.Marshal(map[string]any{
		"base_balance":       input.BasePool.Balance,
		"base_reserve_floor": input.BasePool.ReserveFloor,
		"base_total_in":      input.BasePool.TotalIn,
		"base_total_out":     input.BasePool.TotalOut,
	})
	rtpSnapshot, _ := json.Marshal(map[string]any{
		"global_window": input.GlobalWindow.WindowIndex,
		"gift_window":   input.GiftWindow.WindowIndex,
	})
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_draw_records (
			app_code, draw_id, command_id, user_id, device_id, room_id, anchor_id, visible_region_id, pool_id, gift_id,
			coin_spent, rule_version, experience_pool, rtp_window_index, gift_rtp_window_index,
			selected_tier_id, base_reward_coins, effective_reward_coins, stage_feedback, high_multiplier,
			candidate_tiers_json, pool_snapshot_json, rtp_snapshot_json, reward_status, reward_transaction_id, reward_failure_reason,
			paid_at_ms, created_at_ms, updated_at_ms
		) VALUES (`+luckySQLPlaceholders(29)+`)`,
		input.AppCode, input.DrawID, input.Command.CommandID, input.Command.UserID, input.Command.DeviceID,
		input.Command.RoomID, input.Command.AnchorID, input.Command.VisibleRegionID, input.Config.PoolID, input.Command.GiftID, input.Command.CoinSpent,
		input.Config.RuleVersion, input.ExperiencePool, input.GlobalWindow.WindowIndex, input.GiftWindow.WindowIndex,
		input.Candidate.TierID, input.Candidate.BaseReward, input.Candidate.effectiveReward(), input.StageFeedback, input.Candidate.HighMultiplier,
		string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), input.RewardStatus, "", "",
		input.Command.PaidAtMS, input.NowMS, input.NowMS,
	); err != nil {
		return err
	}
	profileRecord := luckyDrawRecordInput{
		AppCode: input.AppCode, DrawID: input.DrawID, Command: input.Command, Config: input.Config,
		ExperiencePool: input.ExperiencePool, Candidate: input.Candidate, Limited: input.Limited,
		StageFeedback: input.StageFeedback, GlobalWindow: input.GlobalWindow, GiftWindow: input.GiftWindow,
		BasePool: input.BasePool, RewardStatus: input.RewardStatus, NowMS: input.NowMS,
	}
	if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, []luckyUserProfileFact{
		luckyUserProfileFactFromRecord(profileRecord, luckyUserProfileSourceInternal,
			domain.UserProfileIdentityInternal, fmt.Sprintf("%d", input.Command.UserID), "", input.Command.UserID),
	}, input.NowMS); err != nil {
		return err
	}
	if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) {
		// 同一 owner outbox 先补偿钱包返奖，再在 granted 收敛后发布中奖事实；room/activity 的展示副作用
		// 由各自本地 outbox 独立补偿，不能在抽奖事务里直接调用腾讯云 IM。
		payload, _ := json.Marshal(map[string]any{
			"event_type":                    "lucky_gift_drawn",
			"event_id":                      "lucky_gift_drawn:" + input.DrawID,
			"app_code":                      input.AppCode,
			"draw_id":                       input.DrawID,
			"command_id":                    input.Command.CommandID,
			"pool_id":                       input.Config.PoolID,
			"user_id":                       input.Command.UserID,
			"sender_user_id":                input.Command.UserID,
			"target_user_id":                input.Command.TargetUserID,
			"sender_name":                   input.Command.SenderName,
			"sender_avatar":                 input.Command.SenderAvatar,
			"sender_display_user_id":        input.Command.SenderDisplayUserID,
			"sender_pretty_display_user_id": input.Command.SenderPrettyDisplayUserID,
			"visible_region_id":             input.Command.VisibleRegionID,
			"room_locked":                   input.Command.RoomLocked,
			"owner_user_id":                 input.Command.OwnerUserID,
			"country_id":                    input.Command.CountryID,
			"room_id":                       input.Command.RoomID,
			"gift_id":                       input.Command.GiftID,
			"gift_count":                    input.Command.GiftCount,
			"coin_spent":                    input.Command.CoinSpent,
			"rule_version":                  input.Config.RuleVersion,
			"experience_pool":               input.ExperiencePool,
			"selected_tier_id":              input.Candidate.TierID,
			"multiplier_ppm":                input.Candidate.MultiplierPPM,
			"base_reward_coins":             input.Candidate.BaseReward,
			"effective_reward_coins":        input.Candidate.effectiveReward(),
			"stage_feedback":                input.StageFeedback,
			"high_multiplier":               input.Candidate.HighMultiplier,
			"created_at_ms":                 input.NowMS,
		})
		if err := r.insertLuckyRewardSettlementOutbox(ctx, tx, input.AppCode, input.DrawID, payload, input.NowMS); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) insertLuckyRewardSettlementOutbox(ctx context.Context, tx *sql.Tx, appCode string, drawID string, payload []byte, nowMS int64) error {
	return r.insertLuckyDrawOutbox(ctx, tx, appCode, "lucky_reward_"+drawID, domain.EventTypeLuckyGiftRewardSettlement, payload, nowMS)
}

func (r *Repository) insertLuckyDrawOutbox(ctx context.Context, tx *sql.Tx, appCode string, outboxID string, eventType string, payload []byte, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_gift_outbox (
			app_code, outbox_id, event_type, payload, status, retry_count, next_retry_at_ms,
			locked_by, lock_until_ms, last_error, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 'pending', 0, ?, '', 0, '', ?, ?)`,
		appCode, outboxID, eventType, payload, nowMS, nowMS, nowMS,
	)
	return err
}

// ClaimPendingLuckyGiftOutbox 抢占幸运礼物抽奖副作用。抽奖事实已在同一事务落库，worker 优先补偿影响 reward_status 的结算事件。
func (r *Repository) ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	if batchSize > 500 {
		batchSize = 500
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	events := make([]domain.DrawOutbox, 0, batchSize)
	priorityTypes := []string{
		domain.EventTypeLuckyGiftRewardSettlement,
		domain.EventTypeLuckyGiftDrawn,
		domain.EventTypeExternalLuckyGiftDrawn,
	}
	for _, eventType := range priorityTypes {
		candidates := make([]domain.DrawOutbox, 0, batchSize)
		// pending 和 retryable 分开抢占：IN 跨 status 会让索引无法消除排序，积压时 filesort 并
		// FOR UPDATE 锁住全部匹配行，多实例互相阻塞。单 status 查询按 idx_lucky_gift_outbox_claim_retry
		// (event_type, status, next_retry_at_ms, created_at_ms, outbox_id) 的列序扫描，LIMIT 提前终止；
		// pending 只拿部分额度，给 retryable 保留重试配额避免洪峰饿死补偿。
		for _, claim := range []struct {
			status string
			limit  int
		}{
			{status: "pending", limit: luckyGiftPendingClaimLimit(batchSize)},
			{status: "retryable", limit: batchSize},
		} {
			remaining := batchSize - len(candidates)
			if remaining <= 0 {
				break
			}
			limit := claim.limit
			if limit > remaining {
				limit = remaining
			}
			if limit <= 0 {
				continue
			}
			statusRows, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
				SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
				FROM lucky_gift_outbox
				WHERE event_type = ?
				  AND status = ?
				  AND next_retry_at_ms <= ?
				ORDER BY next_retry_at_ms ASC, created_at_ms ASC, outbox_id ASC
				LIMIT ?
				FOR UPDATE SKIP LOCKED`, limit, eventType, claim.status, nowMS, limit)
			if err != nil {
				return nil, err
			}
			candidates = append(candidates, statusRows...)
		}
		if len(candidates) > 0 {
			events = append(events, candidates...)
			break
		}
	}
	if len(events) == 0 {
		for _, eventType := range priorityTypes {
			// 排序对齐 idx_lucky_gift_outbox_claim_lock (event_type, status, lock_until_ms, created_at_ms, outbox_id)，
			// SKIP LOCKED 让两实例的超时接管互不阻塞。
			retryEvents, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
				SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
				FROM lucky_gift_outbox
				WHERE event_type = ?
				  AND status = 'delivering'
				  AND lock_until_ms <= ?
				ORDER BY lock_until_ms ASC, created_at_ms ASC, outbox_id ASC
				LIMIT ?
				FOR UPDATE SKIP LOCKED`, batchSize, eventType, nowMS, batchSize)
			if err != nil {
				return nil, err
			}
			if len(retryEvents) > 0 {
				events = append(events, retryEvents...)
				break
			}
		}
	}
	lockUntilMS := nowMS + lockTTL.Milliseconds()
	for _, event := range events {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_gift_outbox
			SET status = 'delivering', locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND outbox_id = ?`,
			workerID, lockUntilMS, nowMS, event.AppCode, event.OutboxID,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return events, nil
}

// luckyGiftPendingClaimLimit 是单轮批次里 pending 可占用的额度上限，剩余部分留给 retryable。
func luckyGiftPendingClaimLimit(batchSize int) int {
	if batchSize <= 1 {
		return batchSize
	}
	reserve := batchSize / 10
	if reserve < 1 {
		reserve = 1
	}
	return batchSize - reserve
}

func (r *Repository) queryLuckyGiftOutboxCandidates(ctx context.Context, tx *sql.Tx, query string, capacity int, args ...any) ([]domain.DrawOutbox, error) {
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	events := make([]domain.DrawOutbox, 0, capacity)
	for rows.Next() {
		var event domain.DrawOutbox
		if err := rows.Scan(&event.AppCode, &event.OutboxID, &event.EventType, &event.PayloadJSON, &event.RetryCount, &event.CreatedAtMS); err != nil {
			return nil, err
		}
		events = append(events, event)
	}
	return events, rows.Err()
}

func (r *Repository) MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE lucky_gift_outbox
		SET status = 'delivered', locked_by = '', lock_until_ms = 0, last_error = '', updated_at_ms = ?
		WHERE app_code = ? AND outbox_id = ?`,
		nowMS, appcode.Normalize(event.AppCode), event.OutboxID,
	)
	return err
}

func (r *Repository) MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE lucky_gift_outbox
		SET status = 'retryable', retry_count = ?, next_retry_at_ms = ?, locked_by = '', lock_until_ms = 0,
		    last_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND outbox_id = ?`,
		retryCount, nextRetryAtMS, trimLuckyLastError(lastErr), nowMS, appcode.Normalize(event.AppCode), event.OutboxID,
	)
	return err
}

func (r *Repository) MarkLuckyGiftOutboxFailed(ctx context.Context, event domain.DrawOutbox, retryCount int, lastErr string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE lucky_gift_outbox
		SET status = 'failed', retry_count = ?, locked_by = '', lock_until_ms = 0, last_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND outbox_id = ?`,
		retryCount, trimLuckyLastError(lastErr), nowMS, appcode.Normalize(event.AppCode), event.OutboxID,
	)
	return err
}

// DeleteDeliveredLuckyGiftOutboxBefore 删除一小批 delivered 且 updated_at_ms 早于 cutoffMS 的历史事件，返回本批删除总行数。
// 只清理 delivered 终态：pending/retryable/delivering 仍在投递生命周期内，failed 留给人工补偿排查。
// 抽奖事实在 lucky_draw_records，outbox 仅承载补偿投递，delivered 行没有下游读方。
func (r *Repository) DeleteDeliveredLuckyGiftOutboxBefore(ctx context.Context, cutoffMS int64, limit int) (int64, error) {
	if r == nil || r.db == nil {
		return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if limit <= 0 {
		// 默认批量上限避免调用方传 0 导致单事务删除失控。
		limit = 500
	}
	// 清理跨 App 而 idx_lucky_gift_outbox_retention 以 app_code 开头；先用主键前缀枚举 app_code，
	// 再按 App 走 retention 索引定位最旧一批主键后删除，避免优化器退化成全索引扫描。
	appCodeRows, err := r.db.QueryContext(ctx, `SELECT DISTINCT app_code FROM lucky_gift_outbox`)
	if err != nil {
		return 0, err
	}
	appCodes := make([]string, 0, 4)
	for appCodeRows.Next() {
		var appCode string
		if err := appCodeRows.Scan(&appCode); err != nil {
			_ = appCodeRows.Close()
			return 0, err
		}
		appCodes = append(appCodes, appCode)
	}
	if err := appCodeRows.Err(); err != nil {
		_ = appCodeRows.Close()
		return 0, err
	}
	_ = appCodeRows.Close()

	var total int64
	for _, appCode := range appCodes {
		remaining := limit - int(total)
		if remaining <= 0 {
			break
		}
		outboxIDs, err := r.listDeliveredLuckyGiftOutboxIDs(ctx, appCode, cutoffMS, remaining)
		if err != nil {
			return total, err
		}
		if len(outboxIDs) == 0 {
			continue
		}
		// delivered 是终态不会回流，select 到 delete 之间无需事务；status 条件兜底并发下的重复认领。
		args := make([]any, 0, len(outboxIDs)+1)
		args = append(args, appCode)
		for _, outboxID := range outboxIDs {
			args = append(args, outboxID)
		}
		result, err := r.db.ExecContext(ctx,
			`DELETE FROM lucky_gift_outbox WHERE app_code = ? AND status = 'delivered' AND outbox_id IN (`+luckySQLPlaceholders(len(outboxIDs))+`)`,
			args...,
		)
		if err != nil {
			return total, err
		}
		deleted, err := result.RowsAffected()
		if err != nil {
			return total, err
		}
		total += deleted
	}
	return total, nil
}

func (r *Repository) listDeliveredLuckyGiftOutboxIDs(ctx context.Context, appCode string, cutoffMS int64, limit int) ([]string, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT outbox_id FROM lucky_gift_outbox FORCE INDEX (idx_lucky_gift_outbox_retention)
		WHERE app_code = ? AND status = 'delivered' AND updated_at_ms < ?
		ORDER BY updated_at_ms ASC, outbox_id ASC
		LIMIT ?`, appCode, cutoffMS, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	outboxIDs := make([]string, 0, limit)
	for rows.Next() {
		var outboxID string
		if err := rows.Scan(&outboxID); err != nil {
			return nil, err
		}
		outboxIDs = append(outboxIDs, outboxID)
	}
	return outboxIDs, rows.Err()
}

func (r *Repository) MarkLuckyGiftDrawGranted(ctx context.Context, appCode string, drawID string, walletTransactionID string, nowMS int64) error {
	return r.MarkLuckyGiftDrawsGranted(ctx, appCode, []string{drawID}, walletTransactionID, nowMS)
}

func (r *Repository) GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error) {
	if r == nil || r.db == nil {
		return domain.DrawRewardState{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	drawIDs = normalizeLuckyDrawIDs(drawIDs)
	if len(drawIDs) == 0 {
		return domain.DrawRewardState{}, nil
	}
	args := []any{appcode.Normalize(appCode)}
	for _, drawID := range drawIDs {
		args = append(args, drawID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT reward_status, COALESCE(reward_transaction_id, ''), MAX(updated_at_ms), COUNT(*)
		FROM lucky_draw_records
		WHERE app_code = ? AND draw_id IN (`+luckySQLPlaceholders(len(drawIDs))+`)
		GROUP BY reward_status, reward_transaction_id`, args...)
	if err != nil {
		return domain.DrawRewardState{}, err
	}
	defer rows.Close()
	var total int64
	var granted int64
	walletTransactionID := ""
	rewardGrantedAtMS := int64(0)
	for rows.Next() {
		var status string
		var txID string
		var updatedAtMS int64
		var count int64
		if err := rows.Scan(&status, &txID, &updatedAtMS, &count); err != nil {
			return domain.DrawRewardState{}, err
		}
		total += count
		if status == domain.StatusGranted {
			granted += count
			if walletTransactionID == "" {
				walletTransactionID = txID
			}
			if updatedAtMS > rewardGrantedAtMS {
				rewardGrantedAtMS = updatedAtMS
			}
		}
	}
	if err := rows.Err(); err != nil {
		return domain.DrawRewardState{}, err
	}
	return domain.DrawRewardState{
		AllGranted:          total == int64(len(drawIDs)) && granted == total,
		WalletTransactionID: walletTransactionID,
		RewardGrantedAtMS:   rewardGrantedAtMS,
	}, nil
}

// MarkLuckyGiftDrawsGranted 是聚合 outbox 的终态收敛入口。
// 钱包只加币一次，但 draw_records 仍有多条；这里必须批量 granted，避免后台看到大量已发奖但仍 pending 的明细。
func (r *Repository) MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	drawIDs = normalizeLuckyDrawIDs(drawIDs)
	if len(drawIDs) == 0 {
		return nil
	}
	return r.markLuckyGiftDraws(ctx, appCode, drawIDs, domain.StatusGranted, walletTransactionID, "", nowMS, false)
}

func (r *Repository) MarkLuckyGiftDrawFailed(ctx context.Context, appCode string, drawID string, failureReason string, nowMS int64) error {
	return r.MarkLuckyGiftDrawsFailed(ctx, appCode, []string{drawID}, failureReason, nowMS)
}

// MarkLuckyGiftDrawsFailed 只在未返奖且 outbox 达到最大重试时使用。
// 已经加币成功但 IM 失败的场景不能把 draw 标记 failed，否则会把账务事实和表现事实混在一起。
func (r *Repository) MarkLuckyGiftDrawsFailed(ctx context.Context, appCode string, drawIDs []string, failureReason string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	drawIDs = normalizeLuckyDrawIDs(drawIDs)
	if len(drawIDs) == 0 {
		return nil
	}
	return r.markLuckyGiftDraws(ctx, appCode, drawIDs, domain.StatusFailed, "", trimLuckyFailureReason(failureReason), nowMS, true)
}

// markLuckyGiftDraws 按 chunk 批量更新，避免一次 IN 列表过长压垮 MySQL SQL 包或查询优化器。
// onlyPending 用于失败收敛，防止晚到的失败重试覆盖已经 granted 的返奖事实。
func (r *Repository) markLuckyGiftDraws(ctx context.Context, appCode string, drawIDs []string, status string, walletTransactionID string, failureReason string, nowMS int64, onlyPending bool) error {
	const chunkSize = 500
	appCode = appcode.Normalize(appCode)
	for start := 0; start < len(drawIDs); start += chunkSize {
		end := start + chunkSize
		if end > len(drawIDs) {
			end = len(drawIDs)
		}
		chunk := drawIDs[start:end]
		tx, err := r.db.BeginTx(ctx, nil)
		if err != nil {
			return err
		}
		args := []any{status, walletTransactionID, failureReason, nowMS, appCode}
		for _, drawID := range chunk {
			args = append(args, drawID)
		}
		query := `
			UPDATE lucky_draw_records
			SET reward_status = ?, reward_transaction_id = ?, reward_failure_reason = ?, updated_at_ms = ?
			WHERE app_code = ? AND draw_id IN (` + luckySQLPlaceholders(len(chunk)) + `) AND reward_status <> ?`
		args = append(args, status)
		if onlyPending {
			query += ` AND reward_status = ?`
			args = append(args, domain.StatusPending)
		}
		if _, err := tx.ExecContext(ctx, query, args...); err != nil {
			_ = tx.Rollback()
			return err
		}
		if err := r.migrateLuckyGiftUserProfileRewardStatuses(ctx, tx, appCode, chunk, status, onlyPending, nowMS); err != nil {
			_ = tx.Rollback()
			return err
		}
		if err := tx.Commit(); err != nil {
			return err
		}
	}
	return nil
}

// normalizeLuckyDrawIDs 去掉空值和重复值；outbox payload 是补偿输入，必须先清洗再拼 SQL IN。
func normalizeLuckyDrawIDs(drawIDs []string) []string {
	seen := map[string]bool{}
	out := make([]string, 0, len(drawIDs))
	for _, drawID := range drawIDs {
		drawID = strings.TrimSpace(drawID)
		if drawID == "" || seen[drawID] {
			continue
		}
		seen[drawID] = true
		out = append(out, drawID)
	}
	return out
}

func trimLuckyLastError(value string) string {
	value = strings.TrimSpace(value)
	if len(value) > 512 {
		return value[:512]
	}
	return value
}

func trimLuckyFailureReason(value string) string {
	value = strings.TrimSpace(value)
	if len(value) > 255 {
		return value[:255]
	}
	return value
}

func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if query.ExternalOnly || strings.TrimSpace(query.ExternalUserID) != "" {
		return r.listExternalLuckyGiftDraws(ctx, query)
	}
	whereSQL, args := luckyDrawWhereClause(query.AppCode, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM lucky_draw_records WHERE `+whereSQL, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	limit := int(query.PageSize)
	offset := int((query.Page - 1) * query.PageSize)
	args = append(args, limit, offset)
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, draw_id, command_id, user_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
		       COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
		       base_reward_coins, effective_reward_coins, reward_status,
		       COALESCE(reward_transaction_id, ''), rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
		FROM lucky_draw_records
		WHERE `+whereSQL+`
		ORDER BY created_at_ms DESC, draw_id DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]domain.DrawResult, 0, limit)
	for rows.Next() {
		var item domain.DrawResult
		if err := rows.Scan(&item.AppCode, &item.DrawID, &item.CommandID, &item.UserID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
			&item.MultiplierPPM,
			&item.BaseRewardCoins, &item.EffectiveRewardCoins,
			&item.RewardStatus, &item.WalletTransactionID, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
			&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
			return nil, 0, err
		}
		item.DrawIDs = []string{item.DrawID}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

func (r *Repository) GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
	if r == nil || r.db == nil {
		return domain.DrawSummary{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if query.ExternalOnly || strings.TrimSpace(query.ExternalUserID) != "" {
		return r.getExternalLuckyGiftDrawSummary(ctx, query)
	}
	if luckyDrawSummaryCanUseStats(query) {
		return r.getLuckyGiftDrawSummaryFromStats(ctx, query)
	}
	return r.getLuckyGiftDrawSummaryFromRecords(ctx, query)
}

func (r *Repository) ListLuckyGiftPoolBalances(ctx context.Context, query domain.PoolBalanceQuery) ([]domain.PoolBalance, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	// 空 app_code 是后台“全部应用”筛选，不能经过 Normalize 变成默认 lalu；只有明确值才参与租户条件。
	appCode := strings.ToLower(strings.TrimSpace(query.AppCode))
	poolID := strings.TrimSpace(query.PoolID)
	if poolID != "" {
		poolID = luckyPoolID(poolID)
	}
	strategyVersion := strings.ToLower(strings.TrimSpace(query.StrategyVersion))

	// 先读两套真实账本。这个查询不会 FOR UPDATE，也不会调用 getOrCreate，后台列表不能制造奖池行或改变余额。
	// strategy_version 由内部 scope_type 显式映射；固定与动态绝不能再按 app+pool 折叠覆盖。
	where := make([]string, 0, 3)
	args := make([]any, 0, 4)
	if strategyVersion != "" {
		scopeType, err := luckyPoolScopeForStrategy(strategyVersion)
		if err != nil {
			return nil, err
		}
		where = append(where, "scope_type = ?")
		args = append(args, scopeType)
	} else {
		where = append(where, "scope_type IN (?, ?)")
		args = append(args, luckyBasePoolScopeType, luckyDynamicPoolScopeType)
	}
	if appCode != "" {
		where = append(where, "app_code = ?")
		args = append(args, appCode)
	}
	if poolID != "" {
		where = append(where, "scope_id = ?")
		args = append(args, poolID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
		       manual_credit_total, manual_debit_total, updated_at_ms
		FROM lucky_pools
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY app_code ASC, scope_id ASC, scope_type ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	byKey := make(map[string]domain.PoolBalance)
	for rows.Next() {
		var item domain.PoolBalance
		var scopeType string
		if err := rows.Scan(
			&item.AppCode, &scopeType, &item.PoolID, &item.Balance, &item.ReserveFloor, &item.TotalIn, &item.TotalOut,
			&item.ManualCreditTotal, &item.ManualDebitTotal, &item.UpdatedAtMS,
		); err != nil {
			return nil, err
		}
		mappedStrategy, ok := luckyStrategyForPoolScope(scopeType)
		if !ok {
			continue
		}
		item.StrategyVersion = mappedStrategy
		item.Materialized = true
		item.AvailableBalance = luckyAvailableBalance(item.Balance, item.ReserveFloor)
		byKey[luckyPoolBalanceKey(item.AppCode, item.PoolID, item.StrategyVersion)] = item
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	// 再把已发布配置补进来。配置存在但还没有 draw 的池不会有 lucky_pools 行；展示默认水位能让运营看到“这个 pool_id 将以什么水位启动”。
	configs, err := r.ListLuckyGiftRuleConfigs(ctx, appCode)
	if err != nil {
		return nil, err
	}
	for _, config := range configs {
		if poolID != "" && luckyPoolID(config.PoolID) != poolID {
			continue
		}
		configStrategy := strings.ToLower(strings.TrimSpace(config.StrategyVersion))
		if configStrategy == "" {
			configStrategy = domain.StrategyFixedV2
		}
		if strategyVersion != "" && configStrategy != strategyVersion {
			continue
		}
		key := luckyPoolBalanceKey(config.AppCode, config.PoolID, configStrategy)
		if _, exists := byKey[key]; exists {
			continue
		}
		runtimeConfig, err := luckyRuntimeConfigFromRuleConfig(config)
		if err != nil {
			return nil, err
		}
		initialBalance := runtimeConfig.InitialBasePool
		reserveFloor := runtimeConfig.BasePoolReserve
		if configStrategy == domain.StrategyDynamicV3 {
			// V3 规则即使来自迁移前旧数据，也只能展示独立零余额冷启动；配置里的历史 initial_pool_coins 不再形成资金。
			initialBalance = 0
			reserveFloor = 0
		}
		byKey[key] = domain.PoolBalance{
			AppCode:          appcode.Normalize(config.AppCode),
			PoolID:           runtimeConfig.PoolID,
			StrategyVersion:  configStrategy,
			Balance:          initialBalance,
			ReserveFloor:     reserveFloor,
			AvailableBalance: luckyAvailableBalance(initialBalance, reserveFloor),
			Materialized:     false,
			UpdatedAtMS:      config.CreatedAtMS,
		}
	}

	// 没有任何配置的新应用仍然需要在 ops-center 看到 default 池。这里用当前默认规则推导初始水位，但不写库。
	if appCode != "" && (poolID == "" || poolID == domain.DefaultPoolID) {
		defaultStrategy := strategyVersion
		if defaultStrategy == "" {
			defaultStrategy = domain.StrategyDynamicV3
		}
		key := luckyPoolBalanceKey(appCode, domain.DefaultPoolID, defaultStrategy)
		if _, exists := byKey[key]; !exists {
			var defaultBalance, defaultReserve int64
			if defaultStrategy == domain.StrategyFixedV2 {
				const defaultReferencePrice = int64(100)
				defaultBalance = defaultReferencePrice * (950 + 500 + 2_375)
				defaultReserve = defaultReferencePrice * (100 + 10 + 100)
			}
			byKey[key] = domain.PoolBalance{
				AppCode:          appCode,
				PoolID:           domain.DefaultPoolID,
				StrategyVersion:  defaultStrategy,
				Balance:          defaultBalance,
				ReserveFloor:     defaultReserve,
				AvailableBalance: luckyAvailableBalance(defaultBalance, defaultReserve),
				Materialized:     false,
			}
		}
	}

	items := make([]domain.PoolBalance, 0, len(byKey))
	for _, item := range byKey {
		items = append(items, item)
	}
	sort.Slice(items, func(i, j int) bool {
		if items[i].AppCode != items[j].AppCode {
			return items[i].AppCode < items[j].AppCode
		}
		if items[i].PoolID != items[j].PoolID {
			return items[i].PoolID < items[j].PoolID
		}
		return items[i].StrategyVersion < items[j].StrategyVersion
	})
	return items, nil
}

func (r *Repository) getLuckyGiftDrawSummaryFromRecords(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
	whereSQL, args := luckyDrawWhereClause(query.AppCode, query)
	var summary domain.DrawSummary
	summary.PoolID = query.PoolID
	if err := r.db.QueryRowContext(ctx, `
		SELECT
			COUNT(*),
			COUNT(DISTINCT user_id),
			COUNT(DISTINCT room_id),
			COALESCE(SUM(coin_spent), 0),
			COALESCE(SUM(effective_reward_coins), 0),
			COALESCE(SUM(base_reward_coins), 0),
			COALESCE(SUM(CASE WHEN reward_status = 'pending' THEN 1 ELSE 0 END), 0),
			COALESCE(SUM(CASE WHEN reward_status = 'granted' THEN 1 ELSE 0 END), 0),
			COALESCE(SUM(CASE WHEN reward_status = 'failed' THEN 1 ELSE 0 END), 0)
		FROM lucky_draw_records
		WHERE `+whereSQL, args...).Scan(
		&summary.TotalDraws,
		&summary.UniqueUsers,
		&summary.UniqueRooms,
		&summary.TotalSpentCoins,
		&summary.TotalRewardCoins,
		&summary.BaseRewardCoins,
		&summary.PendingDraws,
		&summary.GrantedDraws,
		&summary.FailedDraws,
	); err != nil {
		return domain.DrawSummary{}, err
	}
	if summary.TotalSpentCoins > 0 {
		summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins
	}
	return summary, nil
}

func luckyDrawSummaryCanUseStats(query domain.DrawQuery) bool {
	return query.UserID <= 0 && strings.TrimSpace(query.RoomID) == "" && strings.TrimSpace(query.Status) == ""
}

func luckyPoolBalanceKey(appCode string, poolID string, strategyVersion string) string {
	return appcode.Normalize(appCode) + "\x00" + luckyPoolID(poolID) + "\x00" + strings.ToLower(strings.TrimSpace(strategyVersion))
}

func luckyAvailableBalance(balance int64, reserveFloor int64) int64 {
	available := balance - reserveFloor
	if available < 0 {
		return 0
	}
	return available
}

func (r *Repository) getLuckyGiftDrawSummaryFromStats(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
	var summary domain.DrawSummary
	summary.PoolID = query.PoolID
	where := []string{"pool_id = ?", "gift_id = ?"}
	args := []any{query.PoolID, strings.TrimSpace(query.GiftID)}
	if strings.TrimSpace(query.AppCode) != "" {
		where = append([]string{"app_code = ?"}, where...)
		args = append([]any{appcode.Normalize(query.AppCode)}, args...)
	}
	err := r.db.QueryRowContext(ctx, `
		SELECT COALESCE(SUM(total_draws), 0), COALESCE(SUM(unique_users), 0), COALESCE(SUM(unique_rooms), 0),
		       COALESCE(SUM(total_spent_coins), 0), COALESCE(SUM(total_reward_coins), 0),
		       COALESCE(SUM(base_reward_coins), 0),
		       COALESCE(SUM(pending_draws), 0), COALESCE(SUM(granted_draws), 0), COALESCE(SUM(failed_draws), 0)
		FROM lucky_draw_pool_stats
		WHERE `+strings.Join(where, " AND "), args...,
	).Scan(
		&summary.TotalDraws,
		&summary.UniqueUsers,
		&summary.UniqueRooms,
		&summary.TotalSpentCoins,
		&summary.TotalRewardCoins,
		&summary.BaseRewardCoins,
		&summary.PendingDraws,
		&summary.GrantedDraws,
		&summary.FailedDraws,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return summary, nil
	}
	if err != nil {
		return domain.DrawSummary{}, err
	}
	if summary.TotalSpentCoins > 0 {
		summary.ActualRTPPPM = summary.TotalRewardCoins * luckyPPMScale / summary.TotalSpentCoins
	}
	return summary, nil
}

func (r *Repository) loadLuckyGiftDrawStatusCounts(ctx context.Context, query domain.DrawQuery, summary *domain.DrawSummary) error {
	if summary == nil {
		return nil
	}
	appCode := appcode.FromContext(ctx)
	where := []string{"app_code = ?"}
	args := []any{appCode}
	if strings.TrimSpace(query.PoolID) != "" {
		where = append(where, "pool_id = ?")
		args = append(args, query.PoolID)
	}
	indexName := "idx_lucky_draw_pool_status_summary"
	if strings.TrimSpace(query.GiftID) != "" {
		indexName = "idx_lucky_draw_gift_status_summary"
		where = append(where, "gift_id = ?")
		args = append(args, strings.TrimSpace(query.GiftID))
	}
	if cursor, exists, err := r.getLuckyDrawStatsCursor(ctx, appCode); err != nil {
		return err
	} else if exists && (cursor.LastDrawCreatedAtMS > 0 || cursor.LastDrawID != "") {
		where = append(where, "(created_at_ms < ? OR (created_at_ms = ? AND draw_id <= ?))")
		args = append(args, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT reward_status, COUNT(*)
		FROM lucky_draw_records FORCE INDEX (`+indexName+`)
		WHERE `+strings.Join(where, " AND ")+`
		GROUP BY reward_status`, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var status string
		var count int64
		if err := rows.Scan(&status, &count); err != nil {
			return err
		}
		switch status {
		case domain.StatusPending:
			summary.PendingDraws = count
		case domain.StatusGranted:
			summary.GrantedDraws = count
		case domain.StatusFailed:
			summary.FailedDraws = count
		}
	}
	return rows.Err()
}

func (r *Repository) getLuckyDrawStatsCursor(ctx context.Context, appCode string) (luckyDrawStatsCursor, bool, error) {
	var cursor luckyDrawStatsCursor
	err := r.db.QueryRowContext(ctx, `
		SELECT last_draw_created_at_ms, last_draw_id
		FROM lucky_draw_pool_stat_cursors
		WHERE app_code = ? AND cursor_name = 'draw_records'`, appCode,
	).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID)
	if errors.Is(err, sql.ErrNoRows) {
		return luckyDrawStatsCursor{}, false, nil
	}
	if err != nil {
		return luckyDrawStatsCursor{}, false, err
	}
	return cursor, true, nil
}

func luckyDrawWhereClause(appCode string, query domain.DrawQuery) (string, []any) {
	where := []string{"1=1"}
	args := []any{}
	if strings.TrimSpace(appCode) != "" {
		where = append(where, "app_code = ?")
		args = append(args, appcode.Normalize(appCode))
	}
	if query.PoolID != "" {
		where = append(where, "pool_id = ?")
		args = append(args, query.PoolID)
	}
	if query.GiftID != "" {
		where = append(where, "gift_id = ?")
		args = append(args, query.GiftID)
	}
	if query.UserID > 0 {
		where = append(where, "user_id = ?")
		args = append(args, query.UserID)
	}
	if query.RoomID != "" {
		where = append(where, "room_id = ?")
		args = append(args, query.RoomID)
	}
	if query.Status != "" {
		where = append(where, "reward_status = ?")
		args = append(args, query.Status)
	}
	return strings.Join(where, " AND "), args
}

type luckyScanner interface {
	Scan(...any) error
}

func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Config, error) {
	poolID := luckyPoolID(ruleConfig.PoolID)
	referencePrice := ruleConfig.GiftPriceReference
	if referencePrice <= 0 {
		return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift reference price must be positive")
	}
	// fixed_v2 历史窗口仍按参考价格折算成“等价抽数”；dynamic_v3 另行读取 SettlementWindowWager，按真实 coin_spent 滚动。
	windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice)
	if windowDraws <= 0 {
		windowDraws = 1
	}
	tiers, hasEnabledBaseTier := luckyRuntimeTiersFromRuleStages(ruleConfig.Stages, referencePrice)
	if !hasEnabledBaseTier {
		return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required")
	}
	user24HourOrdinaryWinFactorPPM := ruleConfig.User24HourOrdinaryWinFactorPPM
	if ruleConfig.User24HourRTPThresholdPPM == 0 && user24HourOrdinaryWinFactorPPM == 0 {
		// Rules published before the field existed deserialize as 0/0. Canonicalize that absence to
		// the documented disabled 0/100% sentinel so no legacy rule can become a zero-win table.
		user24HourOrdinaryWinFactorPPM = luckyPPMScale
	}
	// 单池模型把旧 platform/room/gift 三层责任池合并为 app+pool_id 一口基础池；
	// 初始水位和安全水位使用旧三池默认值求和，保证新规则上线后高倍奖支付能力不会因模型收敛而突然降低。
	config := domain.Config{
		AppCode:                        ruleConfig.AppCode,
		GiftID:                         poolID,
		PoolID:                         poolID,
		Enabled:                        ruleConfig.Enabled,
		RuleVersion:                    ruleConfig.RuleVersion,
		StrategyVersion:                ruleConfig.StrategyVersion,
		GiftPrice:                      referencePrice,
		TargetRTPPPM:                   ruleConfig.TargetRTPPPM,
		PoolRatePPM:                    ruleConfig.PoolRatePPM,
		ProfitRatePPM:                  ruleConfig.ProfitRatePPM,
		AnchorRatePPM:                  ruleConfig.AnchorRatePPM,
		ControlBandPPM:                 ruleConfig.ControlBandPPM,
		SettlementWindowWager:          ruleConfig.SettlementWindowWager,
		GlobalWindowDraws:              windowDraws,
		GiftWindowDraws:                windowDraws,
		NoviceMaxEquivalentDraws:       ruleConfig.NoviceMaxEquivalentDraws,
		NormalMaxEquivalentDraws:       ruleConfig.NormalMaxEquivalentDraws,
		HighMultiplier:                 10,
		HighWaterPoolMultiple:          2,
		InitialBasePool:                referencePrice * (950 + 500 + 2_375),
		BasePoolReserve:                referencePrice * (100 + 10 + 100),
		InitialPoolCoins:               ruleConfig.InitialPoolCoins,
		LossStreakGuarantee:            ruleConfig.LossStreakGuarantee,
		LowWatermarkCoins:              ruleConfig.LowWatermarkCoins,
		LowWaterNonzeroFactorPPM:       ruleConfig.LowWaterNonzeroFactorPPM,
		HighWatermarkCoins:             ruleConfig.HighWatermarkCoins,
		HighWaterNonzeroFactorPPM:      ruleConfig.HighWaterNonzeroFactorPPM,
		V2HighWaterBoostThresholdCoins: ruleConfig.V2HighWaterBoostThresholdCoins,
		V2HighWaterBoostFactorPPM:      ruleConfig.V2HighWaterBoostFactorPPM,
		V2HighWaterBoostRecoverCoins:   ruleConfig.V2HighWaterBoostRecoverCoins,
		RechargeBoostWindowMS:          ruleConfig.RechargeBoostWindowMS,
		RechargeBoostFactorPPM:         ruleConfig.RechargeBoostFactorPPM,
		User24HourRTPThresholdPPM:      ruleConfig.User24HourRTPThresholdPPM,
		User24HourOrdinaryWinFactorPPM: user24HourOrdinaryWinFactorPPM,
		JackpotMultiplierPPMs:          append([]int64(nil), ruleConfig.JackpotMultiplierPPMs...),
		JackpotGlobalRTPMaxPPM:         ruleConfig.JackpotGlobalRTPMaxPPM,
		JackpotUserRoundRTPMaxPPM:      ruleConfig.JackpotUserRoundRTPMaxPPM,
		JackpotUser48hRTPMaxPPM:        ruleConfig.JackpotUser48hRTPMaxPPM,
		SettledRoundRTPEligibility:     true,
		JackpotSpendThresholdCoins:     ruleConfig.JackpotSpendThresholdCoins,
		MaxJackpotHitsPerUserDay:       ruleConfig.MaxJackpotHitsPerUserDay,
		JackpotRateLimitWindowMinutes:  ruleConfig.JackpotRateLimitWindowMinutes,
		MaxJackpotHitsPerWindow:        ruleConfig.MaxJackpotHitsPerWindow,
		MaxSinglePayout:                ruleConfig.MaxSinglePayout,
		UserHourlyPayoutCap:            ruleConfig.UserHourlyPayoutCap,
		UserDailyPayoutCap:             ruleConfig.UserDailyPayoutCap,
		DeviceDailyPayoutCap:           ruleConfig.DeviceDailyPayoutCap,
		RoomHourlyPayoutCap:            ruleConfig.RoomHourlyPayoutCap,
		AnchorDailyPayoutCap:           ruleConfig.AnchorDailyPayoutCap,
		Tiers:                          tiers,
		Stages:                         ruleConfig.Stages,
		UpdatedByAdminID:               ruleConfig.CreatedByAdminID,
		CreatedAtMS:                    ruleConfig.CreatedAtMS,
		UpdatedAtMS:                    ruleConfig.CreatedAtMS,
	}
	if config.StrategyVersion == "" {
		config.StrategyVersion = domain.StrategyFixedV2
	}
	if config.StrategyVersion == domain.StrategyDynamicV3 {
		// V3 只能由人工 credit 形成启动资金；旧配置残留的 initial_pool_coins 既不能注入账本，也不能进入策略冷启动状态。
		config.InitialPoolCoins = 0
		config.InitialBasePool = 0
		config.BasePoolReserve = 0
	}
	config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
	return config, nil
}

func luckyRuntimeTiersFromRuleStages(stages []domain.RuleStage, referencePrice int64) ([]domain.Tier, bool) {
	tiers := make([]domain.Tier, 0)
	hasEnabledBaseTier := false
	for _, stage := range stages {
		runtimePool, ok := luckyRuntimePoolFromRuleStage(stage.Stage)
		if !ok {
			// 发布入口已经校验 stage，这里忽略未知阶段可以让脏数据不扩散成线上候选。
			continue
		}
		for _, ruleTier := range stage.Tiers {
			rewardCoins := referencePrice * ruleTier.MultiplierPPM / luckyPPMScale
			tiers = append(tiers, domain.Tier{
				Pool:          runtimePool,
				TierID:        ruleTier.TierID,
				RewardCoins:   rewardCoins,
				MultiplierPPM: ruleTier.MultiplierPPM,
				// v2 概率进入运行候选快照后默认原样参与随机；单池水位只过滤不可支付候选，不负责日常改概率。
				Weight:        ruleTier.BaseWeightPPM,
				HighWaterOnly: ruleTier.HighWaterOnly,
				Enabled:       ruleTier.Enabled,
			})
			if ruleTier.Enabled {
				hasEnabledBaseTier = true
			}
		}
	}
	return tiers, hasEnabledBaseTier
}

func luckyRuntimePoolFromRuleStage(stage string) (string, bool) {
	switch stage {
	case domain.StageNovice:
		return domain.PoolNovice, true
	case domain.StageNormal:
		return domain.PoolIntermediate, true
	case domain.StageAdvanced:
		return domain.PoolAdvanced, true
	default:
		return "", false
	}
}

func luckyCeilDiv(value, divisor int64) int64 {
	if value <= 0 || divisor <= 0 {
		return 0
	}
	return (value + divisor - 1) / divisor
}

func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, userID int64, giftID string) (luckyUserState, error) {
	var state luckyUserState
	err := r.db.QueryRowContext(ctx, `
		SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
		       pending_jackpot_tokens, pending_spend_jackpot_tokens, pending_spend_jackpot_rule_version
		FROM lucky_user_states
		WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
		appCode, userID, giftID,
	).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
		&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens, &state.PendingSpendJackpotRuleVersion)
	if errors.Is(err, sql.ErrNoRows) {
		return luckyUserState{}, nil
	}
	return state, err
}

func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx, appCode string, userID int64, giftID string, nowMS int64) (luckyUserState, error) {
	// 先用幂等 INSERT 建行再 SELECT FOR UPDATE；两个首次抽奖并发时，唯一键会让第二个事务等待，
	// 而不是两边都先读到 no rows 后由其中一个以 duplicate key 失败。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_user_states (
			app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
			pending_jackpot_tokens, pending_spend_jackpot_tokens, pending_spend_jackpot_rule_version, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, userID, giftID, nowMS, nowMS,
	); err != nil {
		return luckyUserState{}, err
	}
	var state luckyUserState
	err := tx.QueryRowContext(ctx, `
		SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
		       pending_jackpot_tokens, pending_spend_jackpot_tokens, pending_spend_jackpot_rule_version
		FROM lucky_user_states
		WHERE app_code = ? AND user_id = ? AND gift_id = ?
		FOR UPDATE`,
		appCode, userID, giftID,
	).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
		&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens, &state.PendingSpendJackpotRuleVersion)
	return state, err
}

func (r *Repository) getOpenLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
	window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
	if err != nil {
		return luckyRTPWindow{}, err
	}
	if !exists {
		return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, 1, 0, windowDraws, targetPPM, nowMS)
	}
	if window.Status == "open" && window.PaidDraws < window.ControlDraws {
		return window, nil
	}
	if window.Status == "open" {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_rtp_windows
			SET status = 'closed', updated_at_ms = ?
			WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
			nowMS, appCode, scopeType, scopeID, window.WindowIndex,
		); err != nil {
			return luckyRTPWindow{}, err
		}
	}
	return r.createLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, windowDraws, targetPPM, nowMS)
}

// getOpenLuckyDynamicRTPWindow 只服务 dynamic_v3：不可变 rule_version scope 已经固定真实流水阈值，
// 因此窗口是否结束只看 wager_coins。control_window_draws 继续保存 fixed_v2 的兼容观测值，不能参与 V3 滚动判断。
func (r *Repository) getOpenLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, windowWager, legacyWindowDraws, targetPPM, paidAtMS, nowMS int64) (luckyRTPWindow, error) {
	if windowWager <= 0 {
		return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift settlement window wager must be positive")
	}
	if paidAtMS <= 0 {
		return luckyRTPWindow{}, xerr.New(xerr.InvalidArgument, "dynamic lucky gift paid_at_ms is required for settlement window")
	}
	window, exists, err := r.getLatestLuckyRTPWindow(ctx, tx, appCode, scopeType, scopeID, true)
	if err != nil {
		return luckyRTPWindow{}, err
	}
	if !exists {
		return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, 1, 0, legacyWindowDraws, targetPPM, paidAtMS, nowMS)
	}
	if window.Status == "open" && window.StartedAtMS > 0 && window.WagerCoins < windowWager {
		return window, nil
	}
	if window.Status == "open" {
		// 006 gives pre-upgrade windows started_at_ms=0 because their exact first payment cannot be
		// reconstructed safely. Close that legacy partial window without manufacturing user-round
		// aggregates; the first post-upgrade draw starts a fully aligned platform/user round.
		closedAtMS := maxInt64(window.LastPaidAtMS, paidAtMS)
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_rtp_windows
			SET status = 'closed', closed_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?`,
			closedAtMS, nowMS, appCode, scopeType, scopeID, window.WindowIndex,
		); err != nil {
			return luckyRTPWindow{}, err
		}
	}
	return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, window.WindowIndex+1, window.CarryPPM, legacyWindowDraws, targetPPM, paidAtMS, nowMS)
}

func (r *Repository) getLatestLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, forUpdate bool) (luckyRTPWindow, bool, error) {
	lockSQL := ""
	if forUpdate {
		lockSQL = " FOR UPDATE"
	}
	row := tx.QueryRowContext(ctx, `
		SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
		       paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
		       started_at_ms, last_paid_at_ms, closed_at_ms
		FROM lucky_rtp_windows
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?
		ORDER BY window_index DESC
		LIMIT 1`+lockSQL,
		appCode, scopeType, scopeID,
	)
	var window luckyRTPWindow
	if err := row.Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
		&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status,
		&window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return luckyRTPWindow{}, false, nil
		}
		return luckyRTPWindow{}, false, err
	}
	return window, true, nil
}

func (r *Repository) createLuckyRTPWindow(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, nowMS int64) (luckyRTPWindow, error) {
	return r.createLuckyRTPWindowAt(ctx, tx, appCode, scopeType, scopeID, index, carry, windowDraws, targetPPM, nowMS, nowMS)
}

func (r *Repository) createLuckyRTPWindowAt(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, index, carry, windowDraws, targetPPM, startedAtMS, nowMS int64) (luckyRTPWindow, error) {
	// 同一 scope 的首次请求可能并发创建同一 window；幂等 INSERT 先以唯一键串行化，
	// 随后必须重新读取实际行，不能在 duplicate 时返回一个全 0 的本地假快照覆盖已提交进度。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_rtp_windows (
			app_code, scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
			paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
			started_at_ms, last_paid_at_ms, closed_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, scopeType, scopeID, index, targetPPM, windowDraws, carry, startedAtMS, nowMS, nowMS,
	); err != nil {
		return luckyRTPWindow{}, err
	}
	var window luckyRTPWindow
	err := tx.QueryRowContext(ctx, `
		SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
		       paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins, carry_ppm, status,
		       started_at_ms, last_paid_at_ms, closed_at_ms
		FROM lucky_rtp_windows
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_index = ?
		FOR UPDATE`, appCode, scopeType, scopeID, index,
	).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
		&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins, &window.CarryPPM, &window.Status,
		&window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS)
	return window, err
}

func luckyExclusiveEndMS(value int64) int64 {
	// RTP storage keeps the owner's actual settlement millisecond for maturity/audit. Range queries are
	// [start,end), so only the query boundary is advanced by one millisecond to include that paid fact.
	if value >= int64(^uint64(0)>>1) {
		return value
	}
	return value + 1
}

func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
	// 初始注资只能由唯一键胜出的事务写一次；第二个首次请求等待后读取真实余额，不能重复 seed。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_pools (
			app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
			profit_total, anchor_income_total, initial_seed_total, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, ?, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, scopeType, scopeID, initialBalance, reserveFloor, initialBalance, nowMS, nowMS,
	); err != nil {
		return luckyPool{}, err
	}
	var pool luckyPool
	err := tx.QueryRowContext(ctx, `
		SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out,
		       manual_credit_total, manual_debit_total, v2_boost_active
		FROM lucky_pools
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?
		FOR UPDATE`,
		appCode, scopeType, scopeID,
	).Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut,
		&pool.ManualCreditTotal, &pool.ManualDebitTotal, &pool.V2BoostActive)
	return pool, err
}

// advanceLuckyV2HighWaterBoost 推进 fixed_v2 高水位返奖加权的滞回状态并返回本抽是否加权。
// 语义：水位（含本抽入池）越过阈值进入加权；进入后保持，直到水位跌破恢复线才退出。
// 配置不合法（阈值/系数/恢复线任一无效）时视为关闭且强制清零滞回标志，发布校验是第一道防线，这里兜底。
func advanceLuckyV2HighWaterBoost(config domain.Config, pool *luckyPool) bool {
	threshold := config.V2HighWaterBoostThresholdCoins
	factor := config.V2HighWaterBoostFactorPPM
	recoverLine := config.V2HighWaterBoostRecoverCoins
	if threshold <= 0 || factor <= luckyPPMScale || recoverLine <= 0 || recoverLine > threshold {
		pool.V2BoostActive = false
		return false
	}
	if pool.V2BoostActive {
		if pool.Balance < recoverLine {
			pool.V2BoostActive = false
		}
	} else if pool.Balance > threshold {
		pool.V2BoostActive = true
	}
	return pool.V2BoostActive
}

// luckyV2BoostedConfig 返回中奖档返奖金额与倍率都乘以加权系数的配置副本，实现 RTP 等比放大。
// 只放大金额、不改概率：概率加权在非零概率接近 100% 的表上会立即饱和，无法达到 150% 的目标倍数。
// 零倍档金额本就是 0，乘法天然跳过；候选的奖池支付能力过滤发生在放大之后，加权奖同样不能透支。
func luckyV2BoostedConfig(config domain.Config) domain.Config {
	factor := config.V2HighWaterBoostFactorPPM
	if factor <= luckyPPMScale {
		return config
	}
	tiers := make([]domain.Tier, 0, len(config.Tiers))
	for _, tier := range config.Tiers {
		tier.RewardCoins = tier.RewardCoins * factor / luckyPPMScale
		tier.MultiplierPPM = tier.MultiplierPPM * factor / luckyPPMScale
		tiers = append(tiers, tier)
	}
	config.Tiers = tiers
	return config
}

// persistLuckyPoolV2BoostFlag 只在滞回标志翻转时调用；奖池行已被本事务 FOR UPDATE 锁定。
func (r *Repository) persistLuckyPoolV2BoostFlag(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, active bool, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_pools
		SET v2_boost_active = ?, updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
		active, nowMS, appCode, scopeType, scopeID,
	)
	return err
}

func (r *Repository) creditLuckyBasePool(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, pool *luckyPool, nowMS int64) error {
	// 单池模型里 pool_rate 的全部入池金额都归属 app+pool_id 这一口基础池；不再为房间或礼物拆责任份额。
	return r.creditLuckyPool(ctx, tx, appCode, luckyBasePoolScopeType, luckyBasePoolScopeID(config), luckyBasePoolIn(config, spent), pool, nowMS)
}

// luckyBasePoolIn 复用单抽入池公式；批量路径只算金额，不在循环里写表。
func luckyBasePoolIn(config domain.Config, spent int64) int64 {
	return spent * config.PoolRatePPM / luckyPPMScale
}

// creditLuckyPoolInMemory 让后续子抽立即看到本抽入池水位，保持“先入池再开奖”的单抽语义。
func creditLuckyPoolInMemory(pool *luckyPool, amount int64) {
	if amount <= 0 {
		return
	}
	pool.Balance += amount
	pool.TotalIn += amount
}

// debitLuckyPoolInMemory 让后续子抽立即感知本抽已经占用的支付能力，避免批量命中同时透支。
func debitLuckyPoolInMemory(pool *luckyPool, amount int64) {
	if amount <= 0 {
		return
	}
	pool.Balance -= amount
	pool.TotalOut += amount
}

func (r *Repository) creditLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, amount int64, pool *luckyPool, nowMS int64) error {
	if amount <= 0 {
		return nil
	}
	pool.Balance += amount
	pool.TotalIn += amount
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_pools
		SET balance = balance + ?, total_in = total_in + ?, updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
		amount, amount, nowMS, appCode, scopeType, scopeID,
	)
	return err
}

func (r *Repository) debitLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, amount, nowMS int64) error {
	if amount <= 0 {
		return nil
	}
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_pools
		SET balance = balance - ?, total_out = total_out + ?, updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
		amount, amount, nowMS, appCode, scopeType, scopeID,
	)
	return err
}

func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, basePool luckyPool) (luckyCandidate, map[string]bool, error) {
	// 单池模型只看 app+pool_id 这一口基础池的可用水位；旧三池的 room/gift 责任限制已经迁移为历史账本。
	poolCapacity := basePool.capacity()
	limited := map[string]bool{}

	baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
	for _, tier := range config.Tiers {
		// 体验池决定当前用户可见奖档；权重必须保持后台配置，RTP 缺口只进入监控口径，不能反向改本次概率。
		if tier.Pool != pool || !tier.Enabled {
			continue
		}
		highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
		if tier.RewardCoins > poolCapacity {
			limited["pool_cap"] = true
			continue
		}
		baseCandidates = append(baseCandidates, luckyCandidate{
			TierID:         tier.TierID,
			Pool:           pool,
			BaseReward:     tier.RewardCoins,
			MultiplierPPM:  tier.MultiplierPPM,
			Weight:         tier.Weight,
			HighMultiplier: highMultiplier,
		})
	}
	// 正常运行只使用后台配置概率；奖池只删除“本事务不能支付”的候选，不插值、不补缺口。
	if len(baseCandidates) == 0 {
		// 全部正奖档都被奖池剪掉时，本次只能落 0 倍；不能强行透支，也不能生成修正档改变概率。
		return luckyCandidate{TierID: "no_payable_tier", Pool: pool}, limited, nil
	}
	engineCandidates := make([]lotteryengine.Candidate, 0, len(baseCandidates))
	candidateByID := make(map[string]luckyCandidate, len(baseCandidates))
	for _, candidate := range baseCandidates {
		engineCandidates = append(engineCandidates, lotteryengine.Candidate{
			ID:            candidate.TierID,
			Weight:        candidate.Weight,
			PrizeKind:     lotteryengine.PrizeKindCoin,
			RTPValueCoins: candidate.BaseReward,
		})
		candidateByID[candidate.TierID] = candidate
	}
	// 使用通用引擎生成随机落点；候选集合已经全部可支付，随机后不再做业务降级。
	selected, err := lotteryengine.SelectWeighted(engineCandidates)
	if err != nil {
		return luckyCandidate{}, limited, err
	}
	if candidate, ok := candidateByID[selected.ID]; ok {
		if candidate.Weight <= 0 {
			// 通用引擎会把全 0 权重脏数据退化为等权；这里同步返回权重快照，方便后台审计看到实际兜底口径。
			candidate.Weight = selected.Weight
		}
		return candidate, limited, nil
	}
	return luckyCandidate{}, limited, xerr.New(xerr.Internal, "lottery engine selected unknown lucky tier")
}

func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appCode, commandID string, forUpdate bool) (domain.DrawResult, bool, error) {
	lockSQL := ""
	if forUpdate {
		lockSQL = " FOR UPDATE"
	}
	row := tx.QueryRowContext(ctx, `
		SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
		       COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
		       base_reward_coins, effective_reward_coins, reward_status,
		       COALESCE(reward_transaction_id, ''), rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
		FROM lucky_draw_records
		WHERE app_code = ? AND command_id = ?`+lockSQL,
		appCode, commandID,
	)
	var item domain.DrawResult
	if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
		&item.MultiplierPPM,
		&item.BaseRewardCoins, &item.EffectiveRewardCoins,
		&item.RewardStatus, &item.WalletTransactionID, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
		&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.DrawResult{}, false, nil
		}
		return domain.DrawResult{}, false, err
	}
	item.DrawIDs = []string{item.DrawID}
	return item, true, nil
}

func luckyExperiencePool(equivalentDraws int64, config domain.Config) string {
	if config.NoviceMaxEquivalentDraws > 0 && equivalentDraws <= config.NoviceMaxEquivalentDraws {
		return domain.PoolNovice
	}
	if config.NormalMaxEquivalentDraws > 0 && equivalentDraws <= config.NormalMaxEquivalentDraws {
		return domain.PoolIntermediate
	}
	return domain.PoolAdvanced
}

func luckyRuntimeConfig(config domain.Config, spent int64) domain.Config {
	referenceCost := config.GiftPrice
	config.GiftPrice = spent
	if referenceCost <= 0 || spent <= 0 || referenceCost == spent {
		return config
	}
	// 同一个 pool 可被不同价格礼物复用；倍率奖金额按真实扣费缩放，配置里的礼物参考金额只决定默认池水位和默认阶段。
	scaledTiers := make([]domain.Tier, 0, len(config.Tiers))
	for _, tier := range config.Tiers {
		tier.RewardCoins = luckyScaleReward(tier.RewardCoins, referenceCost, spent)
		scaledTiers = append(scaledTiers, tier)
	}
	config.Tiers = scaledTiers
	return config
}

func luckyScaleReward(reward, referenceCost, spent int64) int64 {
	if reward <= 0 || referenceCost <= 0 || spent <= 0 {
		return 0
	}
	return reward * spent / referenceCost
}

func luckyStageFeedback(equivalentDraws int64, config domain.Config) bool {
	limit := config.NoviceMaxEquivalentDraws
	if limit <= 0 || equivalentDraws <= 0 || equivalentDraws > limit {
		return false
	}
	// 阶段反馈是表现事件，不是金币保底；是否发币仍由候选预算和 outbox 决定。
	return equivalentDraws == 1 || equivalentDraws == limit/4 || equivalentDraws == limit/2 || equivalentDraws == limit*3/4 || equivalentDraws == limit
}

func luckyEquivalentDraws(cumulativeWagerCoins int64, referencePrice int64) int64 {
	if cumulativeWagerCoins <= 0 || referencePrice <= 0 {
		return 0
	}
	return cumulativeWagerCoins / referencePrice
}

func luckyConfiguredWeightCandidates(candidates []luckyCandidate) []luckyCandidate {
	engineCandidates := make([]lotteryengine.Candidate, 0, len(candidates))
	byID := make(map[string]luckyCandidate, len(candidates))
	for _, candidate := range candidates {
		engineCandidates = append(engineCandidates, lotteryengine.Candidate{
			ID:            candidate.TierID,
			Weight:        candidate.Weight,
			PrizeKind:     lotteryengine.PrizeKindCoin,
			RTPValueCoins: candidate.BaseReward,
		})
		byID[candidate.TierID] = candidate
	}
	normalized, err := lotteryengine.NormalizeCandidates(engineCandidates)
	if err != nil {
		return nil
	}
	out := make([]luckyCandidate, 0, len(normalized))
	for _, candidate := range normalized {
		item := byID[candidate.ID]
		item.Weight = candidate.Weight
		out = append(out, item)
	}
	return out
}

func luckyRuleWindowScopeID(config domain.Config) string {
	// 规则版本是概率和 RTP 参数的不可变快照；窗口也必须绑定版本，否则新配置会继续承接旧配置的 RTP 债务。
	return fmt.Sprintf("%s:v%d", luckyPoolID(config.PoolID), config.RuleVersion)
}

func luckyPoolID(poolID string) string {
	poolID = strings.TrimSpace(poolID)
	if poolID == "" {
		return domain.DefaultPoolID
	}
	return poolID
}

func luckyPoolScopedID(poolID, scopeID string) string {
	return luckyPoolID(poolID) + ":" + strings.TrimSpace(scopeID)
}

func luckyBasePoolScopeID(config domain.Config) string {
	return luckyPoolID(config.PoolID)
}

func luckyMultiplierPPMsFromTiers(tiers []domain.Tier, giftPrice int64) []int64 {
	seen := map[int64]bool{}
	out := make([]int64, 0, len(tiers))
	for _, tier := range tiers {
		multiplier := tier.MultiplierPPM
		if multiplier == 0 && tier.RewardCoins > 0 && giftPrice > 0 {
			multiplier = tier.RewardCoins * luckyPPMScale / giftPrice
		}
		if multiplier < 0 || seen[multiplier] {
			continue
		}
		seen[multiplier] = true
		out = append(out, multiplier)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

func luckyInitialRewardStatus(candidate luckyCandidate, _ bool) string {
	if luckyDrawNeedsRewardSettlementOutbox(candidate) {
		return domain.StatusPending
	}
	return domain.StatusGranted
}

func luckyDrawNeedsRewardSettlementOutbox(candidate luckyCandidate) bool {
	return candidate.effectiveReward() > 0
}

func luckyEnabledReason(enabled bool) string {
	if enabled {
		return "enabled"
	}
	return "disabled"
}

func luckyRTPPPM(wager, payout int64) int64 {
	if wager <= 0 {
		return 0
	}
	return payout * luckyPPMScale / wager
}

func luckySQLPlaceholders(count int) string {
	// database/sql 不支持把 slice 直接绑定到 IN (?)，这里生成受控占位符而不是拼接业务值。
	if count <= 0 {
		return ""
	}
	return strings.TrimSuffix(strings.Repeat("?,", count), ",")
}

func minInt64(a, b int64) int64 {
	if a < b {
		return a
	}
	return b
}

func maxInt64(a, b int64) int64 {
	if a > b {
		return a
	}
	return b
}
