package mysql

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

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

const (
	luckyPPMScale               int64 = 1_000_000
	luckyActivityTotalBudgetDay       = "__total__"
)

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
}

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
}

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

type luckyCounter struct {
	ScopeType  string
	ScopeID    string
	WindowType string
	BucketKey  string
	Payout     int64
}

type luckyUserState struct {
	PaidDraws            int64
	CumulativeWagerCoins int64
	EquivalentDraws      int64
	LossStreak           int64
}

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

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

// 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
	PlatformPool      luckyPool
	RoomPool          luckyPool
	GiftPool          luckyPool
	Atmosphere        luckyPool
	BudgetSourcesJSON string
	RewardStatus      string
	NowMS             int64
}

// luckyBatchDrawState 是批量送礼事务里的聚合账本。
// 设计目标是把 N 次抽奖必须逐次变化的随机过程留在内存循环里，
// 把规则读取、行锁、奖池写入、RTP 窗口写入、风控计数写入和 outbox 写入收敛到事务首尾。
type luckyBatchDrawState struct {
	// In/Out 只记录本批净变化；最后用一次 UPDATE 写回，避免 gift_count=777 时重复打数据库。
	PlatformIn      int64
	PlatformOut     int64
	RoomIn          int64
	RoomOut         int64
	GiftIn          int64
	GiftOut         int64
	AtmosphereIn    int64
	AtmosphereOut   int64
	ActivityReward  int64
	EffectiveReward int64
	// 只有钱包结算会影响 reward_status=pending；IM/广播表现层不进入可靠 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/Counter 都是 FOR UPDATE 读出的当前行副本，循环内只改内存副本。
	GlobalWindow       luckyRTPWindow
	GiftWindow         luckyRTPWindow
	PlatformPool       luckyPool
	RoomPool           luckyPool
	GiftPool           luckyPool
	Atmosphere         luckyPool
	Counters           map[string]luckyCounter
	ActivityDay        string
	AggregateResult    domain.DrawResult
	AggregateSources   string
	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)
	ruleConfig, exists, err := r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), poolID, 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,
		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, tx *sql.Tx, cmd domain.DrawCommand, nowMS int64) ([]domain.DrawResult, error) {
	// 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
	}
	appCode := appcode.FromContext(ctx)
	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)
}

// 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.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
	if err != nil {
		return nil, err
	}
	if !exists || !ruleConfig.Enabled {
		return nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
	}
	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。
	platformPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "platform", baseConfig.GiftID, baseConfig.InitialPlatformPool, baseConfig.PlatformReserve, nowMS)
	if err != nil {
		return nil, err
	}
	roomPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "room", luckyPoolScopedID(baseConfig.GiftID, cmd.RoomID), baseConfig.InitialRoomPool, baseConfig.RoomReserve, nowMS)
	if err != nil {
		return nil, err
	}
	giftPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "gift", baseConfig.GiftID, baseConfig.InitialGiftPool, baseConfig.GiftReserve, nowMS)
	if err != nil {
		return nil, err
	}
	atmosphere, err := r.getOrCreateLuckyAtmosphere(ctx, tx, appCode, luckyPoolScopedID(baseConfig.GiftID, cmd.RoomID), baseConfig.RoomAtmosphereInitial, baseConfig.RoomAtmosphereReserve, nowMS)
	if err != nil {
		return nil, err
	}
	// 活动预算和风控计数也属于共享容量；批量内存扣减后，后面的子抽会看到前面子抽已经消耗的额度。
	_, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, baseConfig, cmd.PaidAtMS, nowMS)
	if err != nil {
		return nil, err
	}
	counters, err := r.getLuckyRiskCounters(ctx, tx, appCode, baseConfig.GiftID, cmd, nowMS)
	if err != nil {
		return nil, err
	}

	state := luckyBatchDrawState{
		GlobalWindow:    globalWindow,
		GiftWindow:      giftWindow,
		PlatformPool:    platformPool,
		RoomPool:        roomPool,
		GiftPool:        giftPool,
		Atmosphere:      atmosphere,
		Counters:        counters,
		ActivityDay:     activityDay,
		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.AggregateSources = aggregate.BudgetSourcesJSON
	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)

	// 先把本抽入池计入内存水位，再做候选过滤；这样同一批内前后抽的支付能力与真实资金流一致。
	platformIn, roomIn, giftIn := luckyBasePoolIn(config, cmd.CoinSpent)
	creditLuckyPoolInMemory(&state.PlatformPool, platformIn)
	creditLuckyPoolInMemory(&state.RoomPool, roomIn)
	creditLuckyPoolInMemory(&state.GiftPool, giftIn)
	atmosphereIn := cmd.CoinSpent * config.RoomAtmosphereRatePPM / luckyPPMScale
	creditLuckyPoolInMemory(&state.Atmosphere, atmosphereIn)
	state.PlatformIn += platformIn
	state.RoomIn += roomIn
	state.GiftIn += giftIn
	state.AtmosphereIn += atmosphereIn

	// 候选过滤使用已经被本批前序子抽消耗过的内存池和风控计数，避免批量同时命中超过容量的高倍奖。
	candidate, limited, err := r.selectLuckyCandidate(config, experiencePool, state.PlatformPool, state.RoomPool, state.GiftPool, state.Counters)
	if err != nil {
		return domain.DrawResult{}, err
	}
	// 随机后只落事实，不再改候选；这里扣减内存容量，让后续子抽立即感知本抽成本。
	platformOut, roomOut, giftOut := luckySplitWeighted(candidate.BaseReward, config)
	debitLuckyPoolInMemory(&state.PlatformPool, platformOut)
	debitLuckyPoolInMemory(&state.RoomPool, roomOut)
	debitLuckyPoolInMemory(&state.GiftPool, giftOut)
	debitLuckyPoolInMemory(&state.Atmosphere, candidate.RoomReward)
	state.PlatformOut += platformOut
	state.RoomOut += roomOut
	state.GiftOut += giftOut
	state.AtmosphereOut += candidate.RoomReward
	state.ActivityReward += candidate.ActivityReward
	state.EffectiveReward += candidate.effectiveReward()
	// 风控按 effective reward 累计，基础 RTP、气氛池和活动补贴都属于用户真实收到的金币成本。
	if candidate.effectiveReward() > 0 {
		incrementLuckyRiskCountersInMemory(state.Counters, candidate.effectiveReward())
		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")
	budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
	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,
		PlatformPool:      state.PlatformPool,
		RoomPool:          state.RoomPool,
		GiftPool:          state.GiftPool,
		Atmosphere:        state.Atmosphere,
		BudgetSourcesJSON: budgetSourcesJSON,
		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,
		RoomAtmosphereRewardCoins: candidate.RoomReward,
		ActivitySubsidyCoins:      candidate.ActivityReward,
		EffectiveRewardCoins:      candidate.effectiveReward(),
		BudgetSourcesJSON:         budgetSourcesJSON,
		RewardStatus:              rewardStatus,
		RTPWindowIndex:            state.GlobalWindow.WindowIndex,
		GiftRTPWindowIndex:        state.GiftWindow.WindowIndex,
		GlobalBaseRTPPPM:          luckyRTPPPM(state.GlobalWindow.WagerCoins, state.GlobalWindow.ActualPayoutCoins+state.PlatformOut+state.RoomOut+state.GiftOut),
		GiftBaseRTPPPM:            luckyRTPPPM(state.GiftWindow.WagerCoins, state.GiftWindow.ActualPayoutCoins+state.PlatformOut+state.RoomOut+state.GiftOut),
		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.PlatformOut+state.RoomOut+state.GiftOut, 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.PlatformOut+state.RoomOut+state.GiftOut, 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, "platform", config.GiftID, state.PlatformIn, state.PlatformOut, nowMS); err != nil {
		return err
	}
	if err := r.updateLuckyPoolNet(ctx, tx, appCode, "room", state.RoomPool.ScopeID, state.RoomIn, state.RoomOut, nowMS); err != nil {
		return err
	}
	if err := r.updateLuckyPoolNet(ctx, tx, appCode, "gift", state.GiftPool.ScopeID, state.GiftIn, state.GiftOut, nowMS); err != nil {
		return err
	}
	if err := r.updateLuckyAtmosphereNet(ctx, tx, appCode, state.Atmosphere.ScopeID, state.AtmosphereIn, state.AtmosphereOut, nowMS); err != nil {
		return err
	}
	// 活动预算只有命中活动补贴时才写；当前主链路活动为 0 时避免无意义 UPDATE。
	if state.ActivityReward > 0 && state.ActivityDay != "" {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_activity_budgets
			SET spent_coins = spent_coins + ?,
			    updated_at_ms = ?
			WHERE app_code = ? AND gift_id = ? AND budget_day = ?`,
			state.ActivityReward, nowMS, appCode, config.GiftID, luckyActivityTotalBudgetDay,
		); err != nil {
			return err
		}
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_activity_budgets
			SET spent_coins = spent_coins + ?,
			    daily_spent_coins = daily_spent_coins + ?,
			    updated_at_ms = ?
			WHERE app_code = ? AND gift_id = ? AND budget_day = ?`,
			state.ActivityReward, state.ActivityReward, nowMS, appCode, config.GiftID, state.ActivityDay,
		); err != nil {
			return err
		}
	}
	if err := r.updateLuckyRiskCounters(ctx, tx, appCode, config.GiftID, cmd, state.EffectiveReward, 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
}

// updateLuckyAtmosphereNet 与基础奖池写法一致，但落在房间气氛池表；气氛池不进入基础 RTP 返奖口径。
func (r *Repository) updateLuckyAtmosphereNet(ctx context.Context, tx *sql.Tx, appCode, roomID string, amountIn, amountOut, nowMS int64) error {
	if amountIn <= 0 && amountOut <= 0 {
		return nil
	}
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_room_atmosphere_pools
		SET balance = balance + ? - ?,
		    total_in = total_in + ?,
		    total_out = total_out + ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND room_id = ?`,
		amountIn, amountOut, amountIn, amountOut, nowMS, appCode, roomID,
	)
	return err
}

// insertLuckyDrawRecords 批量写入审计明细。
// 仍然保留每一抽一条记录，因为后台统计“抽奖次数”和问题复盘都需要单抽粒度；优化点是批量 INSERT 而不是 N 次 INSERT。
func (r *Repository) insertLuckyDrawRecords(ctx context.Context, tx *sql.Tx, records []luckyDrawRecordInput) error {
	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, room_atmosphere_reward_coins, activity_subsidy_coins,
				effective_reward_coins, budget_sources_json, 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)*32)
		rowPlaceholders := "(" + luckySQLPlaceholders(32) + ")"
		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
		}
	}
	return nil
}

type luckyDrawPoolStatKey struct {
	AppCode string
	PoolID  string
	GiftID  string
}

type luckyDrawPoolStatDelta struct {
	TotalDraws                int64
	TotalSpentCoins           int64
	TotalRewardCoins          int64
	BaseRewardCoins           int64
	RoomAtmosphereRewardCoins int64
	ActivitySubsidyCoins      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, room_atmosphere_reward_coins, activity_subsidy_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),
			room_atmosphere_reward_coins = room_atmosphere_reward_coins + VALUES(room_atmosphere_reward_coins),
			activity_subsidy_coins = activity_subsidy_coins + VALUES(activity_subsidy_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.RoomAtmosphereRewardCoins, delta.ActivitySubsidyCoins,
		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
	RoomAtmosphereRewardCoins int64
	ActivitySubsidyCoins      int64
	RewardStatus              string
	CreatedAtMS               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, room_atmosphere_reward_coins, activity_subsidy_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.RoomAtmosphereRewardCoins, &record.ActivitySubsidyCoins, &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
			delta.RoomAtmosphereRewardCoins += record.RoomAtmosphereRewardCoins
			delta.ActivitySubsidyCoins += record.ActivitySubsidyCoins
			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
}

type luckyDrawPoolDayStatDelta struct {
	DrawCount                int64
	TurnoverCoin             int64
	PayoutCoin               int64
	BaseRewardCoin           int64
	RoomAtmosphereRewardCoin int64
	ActivitySubsidyCoin      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, room_atmosphere_reward_coins, activity_subsidy_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.VisibleRegionID, &record.PoolID, &record.GiftID, &record.UserID, &record.RoomID,
			&record.TotalSpentCoins, &record.TotalRewardCoins, &record.BaseRewardCoins,
			&record.RoomAtmosphereRewardCoins, &record.ActivitySubsidyCoins, &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[luckyDrawPoolDayStatKey]*luckyDrawPoolDayStatDelta{}
	last := records[len(records)-1]
	for _, record := range records {
		regionID := record.VisibleRegionID
		if regionID < 0 {
			regionID = 0
		}
		day := luckyDrawStatDay(record.CreatedAtMS)
		keys := []luckyDrawPoolDayStatKey{{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: ""}}
		if giftID := strings.TrimSpace(record.GiftID); giftID != "" {
			keys = append(keys, luckyDrawPoolDayStatKey{AppCode: record.AppCode, StatDay: day, VisibleRegionID: regionID, PoolID: record.PoolID, GiftID: giftID})
		}
		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
			delta.RoomAtmosphereRewardCoin += record.RoomAtmosphereRewardCoins
			delta.ActivitySubsidyCoin += record.ActivitySubsidyCoins
		}
	}
	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, draw_count, turnover_coin, payout_coin,
			base_reward_coin, room_atmosphere_reward_coin, activity_subsidy_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),
			room_atmosphere_reward_coin = room_atmosphere_reward_coin + VALUES(room_atmosphere_reward_coin),
			activity_subsidy_coin = activity_subsidy_coin + VALUES(activity_subsidy_coin),
			updated_at_ms = VALUES(updated_at_ms)`,
		key.AppCode, key.StatDay, key.VisibleRegionID, key.PoolID, key.GiftID, delta.DrawCount, delta.TurnoverCoin, delta.PayoutCoin,
		delta.BaseRewardCoin, delta.RoomAtmosphereRewardCoin, delta.ActivitySubsidyCoin, nowMS, nowMS,
	)
	return err
}

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

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.RoomReward, input.Candidate.ActivityReward,
		input.Candidate.effectiveReward(), input.BudgetSourcesJSON, 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) {
	candidateSnapshot, _ := json.Marshal(map[string]any{
		"base_reward":    input.Candidate.BaseReward,
		"limited":        input.Limited,
		"multiplier_ppm": input.Candidate.MultiplierPPM,
		"source":         input.Candidate.Source,
		"weight":         input.Candidate.Weight,
	})
	poolSnapshot, _ := json.Marshal(map[string]any{
		"platform_balance": input.PlatformPool.Balance,
		"room_balance":     input.RoomPool.Balance,
		"gift_balance":     input.GiftPool.Balance,
		"atmosphere":       input.Atmosphere.Balance,
	})
	rtpSnapshot, _ := json.Marshal(map[string]any{
		"global_window": input.GlobalWindow.WindowIndex,
		"gift_window":   input.GiftWindow.WindowIndex,
	})
	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,
		"visible_region_id":            cmd.VisibleRegionID,
		"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,
		"room_atmosphere_reward_coins": aggregate.RoomAtmosphereRewardCoins,
		"activity_subsidy_coins":       aggregate.ActivitySubsidyCoins,
		"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.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, 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")
	}
	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，任一层容量不足都必须在随机前过滤对应奖档。
	platformPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "platform", config.GiftID, config.InitialPlatformPool, config.PlatformReserve, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	roomPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "room", luckyPoolScopedID(config.GiftID, cmd.RoomID), config.InitialRoomPool, config.RoomReserve, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	giftPool, err := r.getOrCreateLuckyPool(ctx, tx, appCode, "gift", config.GiftID, config.InitialGiftPool, config.GiftReserve, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	// 房间气氛池是额外体验预算，不进入基础 RTP；它独立于 Room Cell 状态。
	atmosphere, err := r.getOrCreateLuckyAtmosphere(ctx, tx, appCode, luckyPoolScopedID(config.GiftID, cmd.RoomID), config.RoomAtmosphereInitial, config.RoomAtmosphereReserve, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	// 每笔扣费先按入池比例补充三层基础池，再判断本次能否支付，避免同一事务里先抽后补造成水位偏差。
	if err := r.creditLuckyBasePools(ctx, tx, appCode, config, cmd.CoinSpent, &platformPool, &roomPool, &giftPool, nowMS); err != nil {
		return domain.DrawResult{}, err
	}
	if err := r.creditLuckyAtmosphere(ctx, tx, appCode, atmosphere.ScopeID, cmd.CoinSpent*config.RoomAtmosphereRatePPM/luckyPPMScale, &atmosphere, nowMS); err != nil {
		return domain.DrawResult{}, err
	}
	// 活动预算和日预算按 UTC 天切分，只作为 effective reward 的额外补贴，不用来补基础 RTP 缺口。
	_, activityDay, err := r.getLuckyActivityRemaining(ctx, tx, appCode, config, cmd.PaidAtMS, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}
	// 风控计数同事务锁定，确保并发抽奖不会同时越过用户、设备、房间或主播上限。
	counters, err := r.getLuckyRiskCounters(ctx, tx, appCode, config.GiftID, cmd, nowMS)
	if err != nil {
		return domain.DrawResult{}, err
	}

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

	drawID := idgen.New("lucky_draw")
	budgetSourcesJSON := luckyBudgetSourcesJSON(candidate)
	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,
		PlatformPool:             platformPool,
		RoomPool:                 roomPool,
		GiftPool:                 giftPool,
		Atmosphere:               atmosphere,
		ActivityDay:              activityDay,
		BudgetSourcesJSON:        budgetSourcesJSON,
		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,
		RoomAtmosphereRewardCoins: candidate.RoomReward,
		ActivitySubsidyCoins:      candidate.ActivityReward,
		EffectiveRewardCoins:      candidate.effectiveReward(),
		BudgetSourcesJSON:         budgetSourcesJSON,
		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))
	for _, result := range results {
		if strings.TrimSpace(result.DrawID) != "" {
			aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
		}
	}
	if len(results) == 1 {
		return aggregate
	}
	aggregate.SelectedTierID = "batch"
	aggregate.BaseRewardCoins = 0
	aggregate.RoomAtmosphereRewardCoins = 0
	aggregate.ActivitySubsidyCoins = 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.RoomAtmosphereRewardCoins += result.RoomAtmosphereRewardCoins
		aggregate.ActivitySubsidyCoins += result.ActivitySubsidyCoins
		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
		}
	}
	aggregate.BudgetSourcesJSON = luckyAggregateBudgetSourcesJSON(results)
	return aggregate
}

func luckyAggregateBudgetSourcesJSON(results []domain.DrawResult) string {
	seen := map[string]bool{}
	for _, result := range results {
		var payload struct {
			Sources []string `json:"sources"`
		}
		if err := json.Unmarshal([]byte(result.BudgetSourcesJSON), &payload); err != nil {
			continue
		}
		for _, source := range payload.Sources {
			switch source {
			case domain.SourceBaseRTP, domain.SourceRoomAtmosphere, domain.SourceActivity, domain.SourcePresentation:
				seen[source] = true
			}
		}
	}
	sources := make([]string, 0, len(seen))
	for source := range seen {
		sources = append(sources, source)
	}
	sort.Strings(sources)
	raw, _ := json.Marshal(map[string]any{"sources": sources, "draw_count": len(results)})
	return string(raw)
}

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
	PlatformPool             luckyPool
	RoomPool                 luckyPool
	GiftPool                 luckyPool
	Atmosphere               luckyPool
	ActivityDay              string
	BudgetSourcesJSON        string
	RewardStatus             string
	NowMS                    int64
}

func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input luckyApplyInput) error {
	platformOut, roomOut, giftOut := luckySplitWeighted(input.Candidate.BaseReward, input.Config)
	// 只有基础返奖写入 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
	}
	if err := r.debitLuckyPool(ctx, tx, input.AppCode, "platform", input.Config.GiftID, platformOut, input.NowMS); err != nil {
		return err
	}
	// 基础返奖按配置权重拆到 platform/room/gift 三层池，保证任一层不能被透支到 reserve 以下。
	if err := r.debitLuckyPool(ctx, tx, input.AppCode, "room", input.RoomPool.ScopeID, roomOut, input.NowMS); err != nil {
		return err
	}
	if err := r.debitLuckyPool(ctx, tx, input.AppCode, "gift", input.GiftPool.ScopeID, giftOut, input.NowMS); err != nil {
		return err
	}
	if input.Candidate.RoomReward > 0 {
		// 气氛奖励只扣房间气氛池，不回写 Room Cell；客户端表现由后续 IM 事件驱动。
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_room_atmosphere_pools
			SET balance = balance - ?, total_out = total_out + ?, updated_at_ms = ?
			WHERE app_code = ? AND room_id = ?`,
			input.Candidate.RoomReward, input.Candidate.RoomReward, input.NowMS, input.AppCode, input.Atmosphere.ScopeID,
		); err != nil {
			return err
		}
	}
	if input.Candidate.ActivityReward > 0 {
		// 活动补贴同时扣总预算和 UTC 日预算；任何一个不足都已经在候选过滤阶段被排除。
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_activity_budgets
			SET spent_coins = spent_coins + ?,
			    updated_at_ms = ?
			WHERE app_code = ? AND gift_id = ? AND budget_day = ?`,
			input.Candidate.ActivityReward, input.NowMS, input.AppCode, input.Config.GiftID, luckyActivityTotalBudgetDay,
		); err != nil {
			return err
		}
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_activity_budgets
			SET spent_coins = spent_coins + ?,
			    daily_spent_coins = daily_spent_coins + ?,
			    updated_at_ms = ?
			WHERE app_code = ? AND gift_id = ? AND budget_day = ?`,
			input.Candidate.ActivityReward, input.Candidate.ActivityReward, input.NowMS, input.AppCode, input.Config.GiftID, input.ActivityDay,
		); err != nil {
			return err
		}
	}
	if err := r.updateLuckyRiskCounters(ctx, tx, input.AppCode, input.Config.GiftID, input.Command, input.Candidate.effectiveReward(), 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,
		"source":         input.Candidate.Source,
		"weight":         input.Candidate.Weight,
	})
	poolSnapshot, _ := json.Marshal(map[string]any{
		"platform_balance": input.PlatformPool.Balance,
		"room_balance":     input.RoomPool.Balance,
		"gift_balance":     input.GiftPool.Balance,
		"atmosphere":       input.Atmosphere.Balance,
	})
	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, room_atmosphere_reward_coins, activity_subsidy_coins,
			effective_reward_coins, budget_sources_json, 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
		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.RoomReward, input.Candidate.ActivityReward,
		input.Candidate.effectiveReward(), input.BudgetSourcesJSON, input.StageFeedback, input.Candidate.HighMultiplier,
		string(candidateSnapshot), string(poolSnapshot), string(rtpSnapshot), input.RewardStatus, "", "",
		input.Command.PaidAtMS, input.NowMS, input.NowMS,
	); err != nil {
		return err
	}
	if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) {
		// 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,
			"visible_region_id":            input.Command.VisibleRegionID,
			"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,
			"room_atmosphere_reward_coins": input.Candidate.RoomReward,
			"activity_subsidy_coins":       input.Candidate.ActivityReward,
			"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 activity_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() }()

	appCode := appcode.FromContext(ctx)
	events := make([]domain.DrawOutbox, 0, batchSize)
	priorityTypes := []string{
		domain.EventTypeLuckyGiftRewardSettlement,
		domain.EventTypeLuckyGiftDrawn,
	}
	for _, eventType := range priorityTypes {
		candidates, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
			SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
			FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_retry)
			WHERE app_code = ?
			  AND event_type = ?
			  AND status IN ('pending', 'retryable')
			  AND next_retry_at_ms <= ?
			ORDER BY created_at_ms ASC, outbox_id ASC
			LIMIT ?
			FOR UPDATE`, batchSize, appCode, eventType, nowMS, batchSize)
		if err != nil {
			return nil, err
		}
		if len(candidates) > 0 {
			events = append(events, candidates...)
			break
		}
	}
	if len(events) == 0 {
		for _, eventType := range priorityTypes {
			retryEvents, err := r.queryLuckyGiftOutboxCandidates(ctx, tx, `
				SELECT app_code, outbox_id, event_type, COALESCE(CAST(payload AS CHAR), '{}'), retry_count, created_at_ms
				FROM activity_outbox FORCE INDEX (idx_activity_outbox_lucky_lock)
				WHERE app_code = ?
				  AND event_type = ?
				  AND status = 'delivering'
				  AND lock_until_ms <= ?
				ORDER BY created_at_ms ASC, outbox_id ASC
				LIMIT ?
				FOR UPDATE`, batchSize, appCode, 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 activity_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
}

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 activity_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 activity_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 activity_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
}

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, ''), 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 := ""
	for rows.Next() {
		var status string
		var txID string
		var count int64
		if err := rows.Scan(&status, &txID, &count); err != nil {
			return domain.DrawRewardState{}, err
		}
		total += count
		if status == domain.StatusGranted {
			granted += count
			if walletTransactionID == "" {
				walletTransactionID = txID
			}
		}
	}
	if err := rows.Err(); err != nil {
		return domain.DrawRewardState{}, err
	}
	return domain.DrawRewardState{
		AllGranted:          total == int64(len(drawIDs)) && granted == total,
		WalletTransactionID: walletTransactionID,
	}, 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 := 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")
	}
	whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), 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 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, room_atmosphere_reward_coins, activity_subsidy_coins,
		       effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), 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.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
			&item.MultiplierPPM,
			&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
			&item.BudgetSourcesJSON, &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 luckyDrawSummaryCanUseStats(query) {
		return r.getLuckyGiftDrawSummaryFromStats(ctx, query)
	}
	return r.getLuckyGiftDrawSummaryFromRecords(ctx, query)
}

func (r *Repository) getLuckyGiftDrawSummaryFromRecords(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
	whereSQL, args := luckyDrawWhereClause(appcode.FromContext(ctx), 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(room_atmosphere_reward_coins), 0),
			COALESCE(SUM(activity_subsidy_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.RoomAtmosphereRewardCoins,
		&summary.ActivitySubsidyCoins,
		&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 (r *Repository) getLuckyGiftDrawSummaryFromStats(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error) {
	var summary domain.DrawSummary
	summary.PoolID = query.PoolID
	err := r.db.QueryRowContext(ctx, `
		SELECT total_draws, unique_users, unique_rooms, total_spent_coins, total_reward_coins,
		       base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
		       pending_draws, granted_draws, failed_draws
		FROM lucky_draw_pool_stats
		WHERE app_code = ? AND pool_id = ? AND gift_id = ?`,
		appcode.FromContext(ctx), query.PoolID, strings.TrimSpace(query.GiftID),
	).Scan(
		&summary.TotalDraws,
		&summary.UniqueUsers,
		&summary.UniqueRooms,
		&summary.TotalSpentCoins,
		&summary.TotalRewardCoins,
		&summary.BaseRewardCoins,
		&summary.RoomAtmosphereRewardCoins,
		&summary.ActivitySubsidyCoins,
		&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{"app_code = ?"}
	args := []any{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")
	}
	// v2 用金币流水定义结算窗口，旧运行窗口仍以抽数推进；这里按参考价格折算成“等价抽数”，实际每抽会在 withCurrentWager 中继续按真实 coin_spent 累计。
	windowDraws := luckyCeilDiv(ruleConfig.SettlementWindowWager, referencePrice)
	if windowDraws <= 0 {
		windowDraws = 1
	}
	tiers, maxReward, hasEnabledBaseTier := luckyRuntimeTiersFromRuleStages(ruleConfig.Stages, referencePrice)
	if !hasEnabledBaseTier {
		return domain.Config{}, xerr.New(xerr.InvalidArgument, "lucky gift enabled base tiers are required")
	}
	// 运行侧仍保留三层资金责任池；v2 第一阶段没有把这些比例暴露给后台，因此用产品方案里的保守默认权重承接基础 RTP 成本。
	config := domain.Config{
		AppCode:                  ruleConfig.AppCode,
		GiftID:                   poolID,
		PoolID:                   poolID,
		Enabled:                  ruleConfig.Enabled,
		RuleVersion:              ruleConfig.RuleVersion,
		GiftPrice:                referencePrice,
		TargetRTPPPM:             ruleConfig.TargetRTPPPM,
		PoolRatePPM:              ruleConfig.PoolRatePPM,
		ControlBandPPM:           ruleConfig.ControlBandPPM,
		GlobalWindowDraws:        windowDraws,
		GiftWindowDraws:          windowDraws,
		NoviceMaxEquivalentDraws: ruleConfig.NoviceMaxEquivalentDraws,
		NormalMaxEquivalentDraws: ruleConfig.NormalMaxEquivalentDraws,
		HighMultiplier:           10,
		HighWaterPoolMultiple:    2,
		PlatformPoolWeightPPM:    200_000,
		RoomPoolWeightPPM:        300_000,
		GiftPoolWeightPPM:        500_000,
		InitialPlatformPool:      referencePrice * 950,
		InitialGiftPool:          referencePrice * 2_375,
		InitialRoomPool:          referencePrice * 500,
		PlatformReserve:          referencePrice * 100,
		GiftReserve:              referencePrice * 100,
		RoomReserve:              referencePrice * 10,
		// 风控上限随 v2 规则版本固化，按绝对金币额累计；这里不再使用代码硬编码。
		MaxSinglePayout:       maxInt64(ruleConfig.MaxSinglePayout, maxReward),
		UserHourlyPayoutCap:   ruleConfig.UserHourlyPayoutCap,
		UserDailyPayoutCap:    ruleConfig.UserDailyPayoutCap,
		DeviceDailyPayoutCap:  ruleConfig.DeviceDailyPayoutCap,
		RoomHourlyPayoutCap:   ruleConfig.RoomHourlyPayoutCap,
		AnchorDailyPayoutCap:  ruleConfig.AnchorDailyPayoutCap,
		RoomAtmosphereRatePPM: 10_000,
		RoomAtmosphereInitial: referencePrice * 10,
		RoomAtmosphereReserve: referencePrice,
		ActivityBudget:        0,
		ActivityDailyLimit:    0,
		LargeTierEnabled:      true,
		Tiers:                 tiers,
		UpdatedByAdminID:      ruleConfig.CreatedByAdminID,
		CreatedAtMS:           ruleConfig.CreatedAtMS,
		UpdatedAtMS:           ruleConfig.CreatedAtMS,
	}
	config.MultiplierPPMs = luckyMultiplierPPMsFromTiers(config.Tiers, config.GiftPrice)
	return config, nil
}

func luckyRuntimeTiersFromRuleStages(stages []domain.RuleStage, referencePrice int64) ([]domain.Tier, int64, bool) {
	tiers := make([]domain.Tier, 0)
	var maxReward int64
	hasEnabledBaseTier := false
	for _, stage := range stages {
		runtimePool, ok := luckyRuntimePoolFromRuleStage(stage.Stage)
		if !ok {
			// 发布入口已经校验 stage，这里忽略未知阶段可以让脏数据不扩散成线上候选。
			continue
		}
		for _, ruleTier := range stage.Tiers {
			if ruleTier.RewardSource != domain.SourceBaseRTP {
				// 当前运行主链路只从阶段表读取基础 RTP 候选；房间气氛和活动补贴仍由各自预算函数动态注入。
				continue
			}
			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
			}
			if ruleTier.Enabled && rewardCoins > maxReward {
				maxReward = rewardCoins
			}
		}
	}
	return tiers, maxReward, 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
		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)
	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) {
	var state luckyUserState
	err := tx.QueryRowContext(ctx, `
		SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak
		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)
	if err == nil {
		return state, nil
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return luckyUserState{}, err
	}
	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, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, 0, 0, 0, 0, ?, ?)`,
		appCode, userID, giftID, nowMS, nowMS,
	); err != nil {
		return luckyUserState{}, err
	}
	return luckyUserState{}, nil
}

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)
}

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
		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); 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) {
	window := luckyRTPWindow{
		ScopeType:    scopeType,
		ScopeID:      scopeID,
		WindowIndex:  index,
		TargetRTPPPM: targetPPM,
		ControlDraws: windowDraws,
		CarryPPM:     carry,
		Status:       "open",
	}
	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, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, ?, 'open', ?, ?)`,
		appCode, scopeType, scopeID, index, targetPPM, windowDraws, window.CarryPPM, nowMS, nowMS,
	); err != nil {
		return luckyRTPWindow{}, err
	}
	return window, nil
}

func (r *Repository) getOrCreateLuckyPool(ctx context.Context, tx *sql.Tx, appCode, scopeType, scopeID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT scope_type, scope_id, balance, reserve_floor, total_in, total_out
		FROM lucky_pools
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?
		FOR UPDATE`,
		appCode, scopeType, scopeID,
	)
	var pool luckyPool
	if err := row.Scan(&pool.ScopeType, &pool.ScopeID, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err == nil {
		return pool, nil
	} else if !errors.Is(err, sql.ErrNoRows) {
		return luckyPool{}, err
	}
	pool = luckyPool{ScopeType: scopeType, ScopeID: scopeID, Balance: initialBalance, ReserveFloor: reserveFloor}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_pools (
			app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)`,
		appCode, scopeType, scopeID, initialBalance, reserveFloor, nowMS, nowMS,
	); err != nil {
		return luckyPool{}, err
	}
	return pool, nil
}

func (r *Repository) getOrCreateLuckyAtmosphere(ctx context.Context, tx *sql.Tx, appCode, roomID string, initialBalance, reserveFloor, nowMS int64) (luckyPool, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT room_id, balance, reserve_floor, total_in, total_out
		FROM lucky_room_atmosphere_pools
		WHERE app_code = ? AND room_id = ?
		FOR UPDATE`,
		appCode, roomID,
	)
	var room string
	pool := luckyPool{ScopeType: "room_atmosphere", ScopeID: roomID}
	if err := row.Scan(&room, &pool.Balance, &pool.ReserveFloor, &pool.TotalIn, &pool.TotalOut); err == nil {
		pool.ScopeID = room
		return pool, nil
	} else if !errors.Is(err, sql.ErrNoRows) {
		return luckyPool{}, err
	}
	pool.Balance = initialBalance
	pool.ReserveFloor = reserveFloor
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_room_atmosphere_pools (
			app_code, room_id, balance, reserve_floor, total_in, total_out, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, 0, ?, ?)`,
		appCode, roomID, initialBalance, reserveFloor, nowMS, nowMS,
	); err != nil {
		return luckyPool{}, err
	}
	return pool, nil
}

func (r *Repository) creditLuckyBasePools(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, spent int64, platform, room, gift *luckyPool, nowMS int64) error {
	poolIn := spent * config.PoolRatePPM / luckyPPMScale
	platformIn, roomIn, giftIn := luckySplitWeighted(poolIn, config)
	if err := r.creditLuckyPool(ctx, tx, appCode, "platform", config.GiftID, platformIn, platform, nowMS); err != nil {
		return err
	}
	if err := r.creditLuckyPool(ctx, tx, appCode, "room", room.ScopeID, roomIn, room, nowMS); err != nil {
		return err
	}
	return r.creditLuckyPool(ctx, tx, appCode, "gift", gift.ScopeID, giftIn, gift, nowMS)
}

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

// 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
}

// incrementLuckyRiskCountersInMemory 用本批已命中奖励预扣风控额度；最后只把总 payout 写回风险计数表。
func incrementLuckyRiskCountersInMemory(counters map[string]luckyCounter, payout int64) {
	if payout <= 0 {
		return
	}
	for key, counter := range counters {
		counter.Payout += payout
		counters[key] = counter
	}
}

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) creditLuckyAtmosphere(ctx context.Context, tx *sql.Tx, appCode, roomID 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_room_atmosphere_pools
		SET balance = balance + ?, total_in = total_in + ?, updated_at_ms = ?
		WHERE app_code = ? AND room_id = ?`,
		amount, amount, nowMS, appCode, roomID,
	)
	return err
}

func (r *Repository) ensureLuckyActivityBudget(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, nowMS int64) error {
	if config.ActivityBudget <= 0 || config.ActivityDailyLimit <= 0 {
		return nil
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_activity_budgets (
			app_code, gift_id, budget_day, budget_coins, spent_coins, daily_limit, daily_spent_coins, status, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, 0, 0, 'active', ?, ?)
		ON DUPLICATE KEY UPDATE
			budget_coins = VALUES(budget_coins),
			status = 'active',
			updated_at_ms = VALUES(updated_at_ms)`,
		appCode, config.GiftID, luckyActivityTotalBudgetDay, config.ActivityBudget, nowMS, nowMS,
	); err != nil {
		return err
	}
	day := time.UnixMilli(nowMS).UTC().Format("2006-01-02")
	_, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_activity_budgets (
			app_code, gift_id, budget_day, budget_coins, spent_coins, daily_limit, daily_spent_coins, status, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, ?, 0, 'active', ?, ?)
		ON DUPLICATE KEY UPDATE
			budget_coins = VALUES(budget_coins),
			daily_limit = VALUES(daily_limit),
			status = 'active',
			updated_at_ms = VALUES(updated_at_ms)`,
		appCode, config.GiftID, day, config.ActivityBudget, config.ActivityDailyLimit, nowMS, nowMS,
	)
	return err
}

func (r *Repository) getLuckyActivityRemaining(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, paidAtMS int64, nowMS int64) (int64, string, error) {
	if config.ActivityBudget <= 0 || config.ActivityDailyLimit <= 0 {
		return 0, "", nil
	}
	totalRemaining, err := r.getOrCreateLuckyActivityTotalRemaining(ctx, tx, appCode, config, nowMS)
	if err != nil {
		return 0, "", err
	}
	day := time.UnixMilli(paidAtMS).UTC().Format("2006-01-02")
	row := tx.QueryRowContext(ctx, `
		SELECT budget_coins, spent_coins, daily_limit, daily_spent_coins, status
		FROM lucky_activity_budgets
		WHERE app_code = ? AND gift_id = ? AND budget_day = ?
		FOR UPDATE`,
		appCode, config.GiftID, day,
	)
	var budget, spent, dailyLimit, dailySpent int64
	var status string
	if err := row.Scan(&budget, &spent, &dailyLimit, &dailySpent, &status); err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			return 0, "", err
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO lucky_activity_budgets (
				app_code, gift_id, budget_day, budget_coins, spent_coins, daily_limit, daily_spent_coins, status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, 0, ?, 0, 'active', ?, ?)`,
			appCode, config.GiftID, day, config.ActivityBudget, config.ActivityDailyLimit, nowMS, nowMS,
		); err != nil {
			return 0, "", err
		}
		budget, dailyLimit, status = config.ActivityBudget, config.ActivityDailyLimit, "active"
	}
	if status != "active" {
		return 0, day, nil
	}
	dailyRemaining := minInt64(maxInt64(0, budget-spent), maxInt64(0, dailyLimit-dailySpent))
	return minInt64(totalRemaining, dailyRemaining), day, nil
}

func (r *Repository) getOrCreateLuckyActivityTotalRemaining(ctx context.Context, tx *sql.Tx, appCode string, config domain.Config, nowMS int64) (int64, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT budget_coins, spent_coins, status
		FROM lucky_activity_budgets
		WHERE app_code = ? AND gift_id = ? AND budget_day = ?
		FOR UPDATE`,
		appCode, config.GiftID, luckyActivityTotalBudgetDay,
	)
	var budget, spent int64
	var status string
	if err := row.Scan(&budget, &spent, &status); err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			return 0, err
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO lucky_activity_budgets (
				app_code, gift_id, budget_day, budget_coins, spent_coins, daily_limit, daily_spent_coins, status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, 0, 0, 0, 'active', ?, ?)`,
			appCode, config.GiftID, luckyActivityTotalBudgetDay, config.ActivityBudget, nowMS, nowMS,
		); err != nil {
			return 0, err
		}
		budget, status = config.ActivityBudget, "active"
	}
	if status != "active" {
		return 0, nil
	}
	return maxInt64(0, budget-spent), nil
}

func (r *Repository) getLuckyRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyCounter, error) {
	hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15")
	day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02")
	specs := []luckyCounter{
		{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
		{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
		{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
		{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
		{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
	}
	out := make(map[string]luckyCounter, len(specs))
	for _, spec := range specs {
		counter, err := r.getOrCreateLuckyRiskCounter(ctx, tx, appCode, spec, nowMS)
		if err != nil {
			return nil, err
		}
		out[luckyCounterKey(spec.ScopeType, spec.WindowType)] = counter
	}
	return out, nil
}

func (r *Repository) getOrCreateLuckyRiskCounter(ctx context.Context, tx *sql.Tx, appCode string, spec luckyCounter, nowMS int64) (luckyCounter, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT payout_coins
		FROM lucky_risk_counters
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?
		FOR UPDATE`,
		appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey,
	)
	if err := row.Scan(&spec.Payout); err == nil {
		return spec, nil
	} else if !errors.Is(err, sql.ErrNoRows) {
		return luckyCounter{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_risk_counters (
			app_code, scope_type, scope_id, window_type, bucket_key, payout_coins, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 0, ?, ?)`,
		appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey, nowMS, nowMS,
	); err != nil {
		return luckyCounter{}, err
	}
	return spec, nil
}

func (r *Repository) updateLuckyRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, payout int64, nowMS int64) error {
	if payout <= 0 {
		return nil
	}
	hour := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02T15")
	day := time.UnixMilli(cmd.PaidAtMS).UTC().Format("2006-01-02")
	specs := []luckyCounter{
		{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
		{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
		{ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day},
		{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
		{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
	}
	for _, spec := range specs {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_risk_counters
			SET payout_coins = payout_coins + ?, updated_at_ms = ?
			WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?`,
			payout, nowMS, appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) selectLuckyCandidate(config domain.Config, pool string, platformPool, roomPool, giftPool luckyPool, counters map[string]luckyCounter) (luckyCandidate, map[string]bool, error) {
	// 三层池按责任权重反推“本次最多可支付的基础返奖总额”，不能只看某一个池的余额。
	poolCapacity := luckyWeightedPoolCapacity(platformPool, roomPool, giftPool, config)
	riskCapacity := luckyRiskCapacity(config, counters)
	baseMaxAllowed := minInt64(poolCapacity, riskCapacity)
	limited := map[string]bool{}

	baseCandidates := make([]luckyCandidate, 0, len(config.Tiers))
	var totalWeight int64
	for _, tier := range config.Tiers {
		// 体验池决定当前用户可见奖档；权重必须保持后台配置，RTP 缺口只进入监控口径，不能反向改本次概率。
		if tier.Pool != pool || !tier.Enabled {
			continue
		}
		highMultiplier := tier.HighWaterOnly || tier.RewardCoins >= config.GiftPrice*config.HighMultiplier
		if highMultiplier && !config.LargeTierEnabled {
			limited["large_tier_disabled"] = true
			continue
		}
		if tier.RewardCoins > baseMaxAllowed {
			if tier.RewardCoins > riskCapacity {
				limited["risk_cap"] = true
			}
			if tier.RewardCoins > poolCapacity {
				limited["pool_cap"] = true
			}
			continue
		}
		baseCandidates = append(baseCandidates, luckyCandidate{
			TierID:         tier.TierID,
			Source:         domain.SourceBaseRTP,
			Pool:           pool,
			BaseReward:     tier.RewardCoins,
			MultiplierPPM:  tier.MultiplierPPM,
			Weight:         tier.Weight,
			HighMultiplier: highMultiplier,
		})
	}
	// 正常运行只使用后台配置概率；奖池和风控只删除“本事务不能支付”的候选，不插值、不补缺口。
	candidates := luckyConfiguredWeightCandidates(baseCandidates)
	for _, candidate := range candidates {
		totalWeight += candidate.Weight
	}
	if len(candidates) == 0 {
		// 全部正奖档都被奖池或风控剪掉时，本次只能落 0 倍；不能强行透支，也不能生成修正档改变概率。
		return luckyCandidate{TierID: "no_payable_tier", Source: domain.SourceBaseRTP, Pool: pool}, limited, nil
	}
	// 使用 crypto/rand 生成随机落点；候选集合已经全部可支付，随机后不再做业务降级。
	index, err := luckySecureIndex(totalWeight)
	if err != nil {
		return luckyCandidate{}, limited, err
	}
	for _, candidate := range candidates {
		if index < candidate.Weight {
			return candidate, limited, nil
		}
		index -= candidate.Weight
	}
	return candidates[len(candidates)-1], limited, nil
}

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, room_atmosphere_reward_coins, activity_subsidy_coins,
		       effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), 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.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
		&item.BudgetSourcesJSON, &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 {
	out := make([]luckyCandidate, 0, len(candidates))
	var totalWeight int64
	for _, candidate := range candidates {
		if candidate.Weight <= 0 {
			continue
		}
		totalWeight += candidate.Weight
		out = append(out, candidate)
	}
	if totalWeight > 0 {
		return out
	}
	// 老数据或脏配置可能没有权重；为了让抽奖继续暴露结果而不是空候选，退化成等权随机。
	out = candidates[:0]
	for _, candidate := range candidates {
		candidate.Weight = 1
		out = append(out, candidate)
	}
	return out
}

func luckySplitWeighted(amount int64, config domain.Config) (int64, int64, int64) {
	platform := amount * config.PlatformPoolWeightPPM / luckyPPMScale
	room := amount * config.RoomPoolWeightPPM / luckyPPMScale
	gift := amount - platform - room
	return platform, room, gift
}

func luckyWeightedPoolCapacity(platform, room, gift luckyPool, config domain.Config) int64 {
	capacity := int64(^uint64(0) >> 1)
	if config.PlatformPoolWeightPPM > 0 {
		// 反推总返奖容量时必须除以责任权重；例如平台只承担 20%，平台可用 20 才能支撑总返奖 100。
		capacity = minInt64(capacity, platform.capacity()*luckyPPMScale/config.PlatformPoolWeightPPM)
	}
	if config.RoomPoolWeightPPM > 0 {
		capacity = minInt64(capacity, room.capacity()*luckyPPMScale/config.RoomPoolWeightPPM)
	}
	if config.GiftPoolWeightPPM > 0 {
		capacity = minInt64(capacity, gift.capacity()*luckyPPMScale/config.GiftPoolWeightPPM)
	}
	if capacity == int64(^uint64(0)>>1) {
		return 0
	}
	return capacity
}

func luckyRiskCapacity(config domain.Config, counters map[string]luckyCounter) int64 {
	// 风控是硬上限，所有会发币的来源都共享同一个 effective reward 剩余额度。
	capacity := config.MaxSinglePayout
	capacity = minInt64(capacity, config.UserHourlyPayoutCap-counters[luckyCounterKey("user", "hour")].Payout)
	capacity = minInt64(capacity, config.UserDailyPayoutCap-counters[luckyCounterKey("user", "day")].Payout)
	capacity = minInt64(capacity, config.DeviceDailyPayoutCap-counters[luckyCounterKey("device", "day")].Payout)
	capacity = minInt64(capacity, config.RoomHourlyPayoutCap-counters[luckyCounterKey("room", "hour")].Payout)
	capacity = minInt64(capacity, config.AnchorDailyPayoutCap-counters[luckyCounterKey("anchor", "day")].Payout)
	if capacity < 0 {
		return 0
	}
	return capacity
}

func luckyCounterKey(scopeType, windowType string) string {
	return scopeType + ":" + windowType
}

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 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 luckyBudgetSourcesJSON(candidate luckyCandidate) string {
	sources := []string{}
	if candidate.BaseReward > 0 || candidate.Source == domain.SourceBaseRTP {
		sources = append(sources, domain.SourceBaseRTP)
	}
	if candidate.RoomReward > 0 {
		sources = append(sources, domain.SourceRoomAtmosphere)
	}
	if candidate.ActivityReward > 0 {
		sources = append(sources, domain.SourceActivity)
	}
	if candidate.Presentation {
		sources = append(sources, domain.SourcePresentation)
	}
	raw, _ := json.Marshal(map[string]any{"sources": sources})
	return string(raw)
}

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 luckySecureIndex(totalWeight int64) (int64, error) {
	if totalWeight <= 0 {
		return 0, xerr.New(xerr.InvalidArgument, "candidate weight is empty")
	}
	n, err := crand.Int(crand.Reader, big.NewInt(totalWeight))
	if err != nil {
		return 0, err
	}
	return n.Int64(), nil
}

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
}
