package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"strings"

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

type luckyRuleQueryer interface {
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
	QueryRowContext(context.Context, string, ...any) *sql.Row
}

// GetLuckyGiftRuleConfig 读取指定奖池最新不可变规则版本；旧抽奖运行表不会参与后台配置读取。
func (r *Repository) GetLuckyGiftRuleConfig(ctx context.Context, poolID string) (domain.RuleConfig, bool, error) {
	if r == nil || r.db == nil {
		return domain.RuleConfig{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	return r.getLuckyGiftRuleConfig(ctx, r.db, appcode.FromContext(ctx), luckyPoolID(poolID), false)
}

// PublishLuckyGiftRuleConfig 只新增不可变版本，不覆盖历史配置；运行引擎按 strategy_version 和 rule_version 读取完整快照。
func (r *Repository) PublishLuckyGiftRuleConfig(ctx context.Context, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
	if r == nil || r.db == nil {
		return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	// 先执行发布（首个语句会锁住最新版本行），再做实验检查，保持与 CreateLuckyGiftExperiment
	// 一致的"规则行 → 实验行"加锁顺序，避免两个入口交叉加锁互相死锁；检查失败整个事务回滚。
	published, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, config, nowMS)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	// 实验期间两组都钉住既有版本，常规发布的新版本不会生效，只会在实验结束时被获胜版本覆盖；
	// 与其让运营发布一个静默无效的版本，不如在入口明确拒绝。实验自身的发布走 publishLuckyGiftRuleConfigTx。
	appCode := appcode.FromContext(ctx)
	poolID := luckyPoolID(config.PoolID)
	if _, active, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil {
		return domain.RuleConfig{}, err
	} else if active {
		return domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool has an active experiment; end the experiment before publishing a new config")
	}
	if err := tx.Commit(); err != nil {
		return domain.RuleConfig{}, err
	}
	return published, nil
}

// publishLuckyGiftRuleConfigTx 在调用方事务里追加一个不可变版本；创建/结束实验借助它把发布和实验状态变更收进同一个事务。
func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.Tx, config domain.RuleConfig, nowMS int64) (domain.RuleConfig, error) {
	appCode := appcode.FromContext(ctx)
	config.AppCode = appCode
	config.PoolID = luckyPoolID(config.PoolID)
	config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
	if config.StrategyVersion == "" {
		// 升级前的发布请求没有 strategy_version；存储边界再次兜底为 fixed_v2，避免绕过 service 的调用写成动态规则。
		config.StrategyVersion = domain.StrategyFixedV2
	}
	if config.StrategyVersion == domain.StrategyDynamicV3 && config.InitialPoolCoins != 0 {
		// 仓储边界再次守住零冷启动，即使内部调用绕过 service 校验，也不能发布一个会暗示自动注资的 V3 快照。
		return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "dynamic_v3 initial pool coins must be zero")
	}
	if config.User24HourRTPThresholdPPM == 0 && config.User24HourOrdinaryWinFactorPPM == 0 {
		// 旧 proto 客户端不会发送新增标量；仓储兜底成 0/100% 禁用态，确保绕过 service 的内部发布也不改变历史概率。
		config.User24HourOrdinaryWinFactorPPM = 1_000_000
	}
	if config.JackpotMultiplierPPMs == nil {
		config.JackpotMultiplierPPMs = []int64{}
	}
	// 旧的进程内调用方可能仍填写 day/72h 别名；业务口径只认轮次/48h，
	// 兼容赋值必须在生成不可变版本前完成，随后再把同一值双写到旧影子列供滚动升级和回滚读取。
	if config.JackpotUserRoundRTPMaxPPM == 0 {
		config.JackpotUserRoundRTPMaxPPM = config.JackpotUserDayRTPMaxPPM
	}
	if config.JackpotUser48hRTPMaxPPM == 0 {
		config.JackpotUser48hRTPMaxPPM = config.JackpotUser72hRTPMaxPPM
	}
	// 生产双实例需要允许新旧二进制短时共存和安全回滚。兼容窗口内新代码同时写入
	// day/round 与 72h/48h 两组列；旧代码只读取影子列，新代码只把新语义暴露到领域层。
	config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
	config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
	jackpotMultiplierPPMsJSON, err := json.Marshal(config.JackpotMultiplierPPMs)
	if err != nil {
		return domain.RuleConfig{}, fmt.Errorf("marshal lucky gift jackpot multipliers: %w", err)
	}
	config.RuleVersion = 1
	config.CreatedAtMS = nowMS
	if config.EffectiveFromMS <= 0 {
		config.EffectiveFromMS = nowMS
	}
	var currentVersion int64
	err = tx.QueryRowContext(ctx, `
		SELECT rule_version
		FROM lucky_gift_rule_versions
		WHERE app_code = ? AND pool_id = ?
		ORDER BY rule_version DESC
		LIMIT 1
		FOR UPDATE`,
		appCode, config.PoolID,
	).Scan(&currentVersion)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return domain.RuleConfig{}, err
	}
	if err == nil {
		config.RuleVersion = currentVersion + 1
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_gift_rule_versions (
			app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
			profit_rate_ppm, anchor_rate_ppm,
			settlement_window_wager, control_band_ppm, gift_price_reference,
			novice_max_equivalent_draws, normal_max_equivalent_draws,
			effective_from_ms, created_by_admin_id, created_at_ms,
			initial_pool_coins, loss_streak_guarantee,
			low_watermark_coins, low_water_nonzero_factor_ppm,
			high_watermark_coins, high_water_nonzero_factor_ppm,
			v2_high_water_boost_threshold_coins, v2_high_water_boost_factor_ppm, v2_high_water_boost_recover_coins,
			recharge_boost_window_ms, recharge_boost_factor_ppm,
			user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
			jackpot_global_rtp_max_ppm,
			jackpot_user_day_rtp_max_ppm, jackpot_user_round_rtp_max_ppm,
			jackpot_user_72h_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
			jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
			jackpot_rate_limit_window_minutes, max_jackpot_hits_per_window,
			max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
			device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
		) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
		config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
		config.ProfitRatePPM, config.AnchorRatePPM,
		config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
		config.NoviceMaxEquivalentDraws, config.NormalMaxEquivalentDraws,
		config.EffectiveFromMS, config.CreatedByAdminID, config.CreatedAtMS,
		config.InitialPoolCoins, config.LossStreakGuarantee,
		config.LowWatermarkCoins, config.LowWaterNonzeroFactorPPM,
		config.HighWatermarkCoins, config.HighWaterNonzeroFactorPPM,
		config.V2HighWaterBoostThresholdCoins, config.V2HighWaterBoostFactorPPM, config.V2HighWaterBoostRecoverCoins,
		config.RechargeBoostWindowMS, config.RechargeBoostFactorPPM,
		config.User24HourRTPThresholdPPM, config.User24HourOrdinaryWinFactorPPM, jackpotMultiplierPPMsJSON,
		config.JackpotGlobalRTPMaxPPM,
		config.JackpotUserDayRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM,
		config.JackpotUser72hRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
		config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
		config.JackpotRateLimitWindowMinutes, config.MaxJackpotHitsPerWindow,
		config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
		config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
	); err != nil {
		return domain.RuleConfig{}, err
	}
	for _, stage := range config.Stages {
		for _, tier := range stage.Tiers {
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO lucky_gift_stage_tiers (
					app_code, pool_id, rule_version, stage, min_recharge_7d_coins, min_recharge_30d_coins,
					tier_id, multiplier_ppm, base_weight_ppm,
					high_water_only, enabled
				) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
				config.AppCode, config.PoolID, config.RuleVersion, tier.Stage, stage.MinRecharge7DCoins, stage.MinRecharge30DCoins,
				tier.TierID, tier.MultiplierPPM, tier.BaseWeightPPM,
				tier.HighWaterOnly, tier.Enabled,
			); err != nil {
				return domain.RuleConfig{}, err
			}
		}
	}
	if config.StrategyVersion == domain.StrategyDynamicV3 {
		// 发布首个 V3 版本只物化一口 0 余额账本；后续 V3 版本命中唯一键后必须保持余额、累计和时间不变。
		// 这也保证 fixed_v2 -> dynamic_v3 切换不会继承旧 scope_type=pool 的历史资金。
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO lucky_pools (
				app_code, scope_type, scope_id, balance, reserve_floor, total_in, total_out,
				manual_credit_total, manual_debit_total, profit_total, anchor_income_total, initial_seed_total,
				created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?)
			ON DUPLICATE KEY UPDATE scope_id = scope_id`,
			config.AppCode, luckyDynamicPoolScopeType, config.PoolID, nowMS, nowMS,
		); err != nil {
			return domain.RuleConfig{}, err
		}
	}
	return config, nil
}

// ListLuckyGiftRuleConfigs 返回每个奖池的最新规则版本，用于后台奖池列表。
func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode string) ([]domain.RuleConfig, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	whereSQL := ""
	args := []any{}
	if strings.TrimSpace(appCode) != "" {
		whereSQL = "WHERE app_code = ?"
		args = append(args, appcode.Normalize(appCode))
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT v.app_code, v.pool_id, v.rule_version, v.enabled, v.strategy_version, v.target_rtp_ppm, v.pool_rate_ppm,
		       v.profit_rate_ppm, v.anchor_rate_ppm,
		       v.settlement_window_wager, v.control_band_ppm, v.gift_price_reference,
		       v.novice_max_equivalent_draws, v.normal_max_equivalent_draws,
		       v.effective_from_ms, v.created_by_admin_id, v.created_at_ms,
		       v.initial_pool_coins, v.loss_streak_guarantee,
		       v.low_watermark_coins, v.low_water_nonzero_factor_ppm,
		       v.high_watermark_coins, v.high_water_nonzero_factor_ppm,
		       v.v2_high_water_boost_threshold_coins, v.v2_high_water_boost_factor_ppm, v.v2_high_water_boost_recover_coins,
		       v.recharge_boost_window_ms, v.recharge_boost_factor_ppm,
		       v.user_24h_rtp_threshold_ppm, v.user_24h_ordinary_win_factor_ppm, v.jackpot_multiplier_ppms,
		       v.jackpot_global_rtp_max_ppm,
		       CASE WHEN v.jackpot_user_round_rtp_max_ppm = 0 AND v.jackpot_user_day_rtp_max_ppm <> 0
		            THEN v.jackpot_user_day_rtp_max_ppm ELSE v.jackpot_user_round_rtp_max_ppm END,
		       CASE WHEN v.jackpot_user_48h_rtp_max_ppm = 0 AND v.jackpot_user_72h_rtp_max_ppm <> 0
		            THEN v.jackpot_user_72h_rtp_max_ppm ELSE v.jackpot_user_48h_rtp_max_ppm END,
		       v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
		       v.jackpot_rate_limit_window_minutes, v.max_jackpot_hits_per_window,
		       v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
		       v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap
		FROM lucky_gift_rule_versions v
		INNER JOIN (
			SELECT app_code, pool_id, MAX(rule_version) AS rule_version
			FROM lucky_gift_rule_versions
			`+whereSQL+`
			GROUP BY app_code, pool_id
		) latest ON latest.app_code = v.app_code AND latest.pool_id = v.pool_id AND latest.rule_version = v.rule_version
		ORDER BY v.created_at_ms DESC, v.pool_id ASC`,
		args...,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	configs := make([]domain.RuleConfig, 0)
	for rows.Next() {
		config, err := scanLuckyGiftRuleConfig(rows)
		if err != nil {
			return nil, err
		}
		stages, err := r.getLuckyGiftRuleStages(ctx, r.db, config.AppCode, config.PoolID, config.RuleVersion)
		if err != nil {
			return nil, err
		}
		config.Stages = stages
		configs = append(configs, config)
	}
	return configs, rows.Err()
}

// ListLuckyGiftRuleConfigVersions 返回同一奖池的完整不可变版本链。主键前缀
// (app_code,pool_id,rule_version) 可直接反向范围扫描，不会扫描其他应用或奖池的规则行。
func (r *Repository) ListLuckyGiftRuleConfigVersions(ctx context.Context, poolID string) ([]domain.RuleConfig, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	poolID = luckyPoolID(poolID)
	if appCode == "" || poolID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "lucky gift app and pool are required")
	}
	rows, err := r.db.QueryContext(ctx, luckyRuleConfigSelectSQL+`
		ORDER BY rule_version DESC`, appCode, poolID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	configs := make([]domain.RuleConfig, 0)
	for rows.Next() {
		config, err := scanLuckyGiftRuleConfig(rows)
		if err != nil {
			return nil, err
		}
		stages, err := r.getLuckyGiftRuleStages(ctx, r.db, appCode, poolID, config.RuleVersion)
		if err != nil {
			return nil, err
		}
		config.Stages = stages
		configs = append(configs, config)
	}
	return configs, rows.Err()
}

// RollbackLuckyGiftRuleConfig 复制目标快照并追加新版本；旧版本和当前版本都保持不可变。
// 首先锁最新规则行，再锁实验行，严格沿用常规发布/实验入口的加锁顺序，避免并发发布与回滚互相覆盖。
func (r *Repository) RollbackLuckyGiftRuleConfig(ctx context.Context, poolID string, targetRuleVersion int64, operatorAdminID int64, nowMS int64) (domain.RuleConfig, error) {
	if r == nil || r.db == nil {
		return domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	poolID = luckyPoolID(poolID)
	if appCode == "" || poolID == "" || targetRuleVersion <= 0 || operatorAdminID <= 0 {
		return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift rollback identity is incomplete")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	current, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	if !exists {
		return domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift pool config not found")
	}
	if targetRuleVersion >= current.RuleVersion {
		return domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "rollback target must be older than the current rule version")
	}
	if _, active, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil {
		return domain.RuleConfig{}, err
	} else if active {
		return domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool has an active experiment; end the experiment before rollback")
	}
	target, exists, err := r.getLuckyGiftRuleConfigVersion(ctx, tx, appCode, poolID, targetRuleVersion, false)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	if !exists {
		return domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift rollback target version not found")
	}
	target.CreatedByAdminID = operatorAdminID
	// 回滚从当前操作时刻生效；不能沿用历史 effective_from_ms，否则审计会把新版本误显示成旧发布时间。
	target.EffectiveFromMS = 0
	if target.StrategyVersion == domain.StrategyDynamicV3 {
		// V3 资金只保留在独立水位账本中。历史快照即使残留 seed，回滚也不能重新注资。
		target.InitialPoolCoins = 0
	}
	published, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, target, nowMS)
	if err != nil {
		return domain.RuleConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.RuleConfig{}, err
	}
	return published, nil
}

const luckyRuleConfigSelectSQL = `
	SELECT app_code, pool_id, rule_version, enabled, strategy_version, target_rtp_ppm, pool_rate_ppm,
	       profit_rate_ppm, anchor_rate_ppm,
	       settlement_window_wager, control_band_ppm, gift_price_reference,
	       novice_max_equivalent_draws, normal_max_equivalent_draws,
	       effective_from_ms, created_by_admin_id, created_at_ms,
	       initial_pool_coins, loss_streak_guarantee,
	       low_watermark_coins, low_water_nonzero_factor_ppm,
	       high_watermark_coins, high_water_nonzero_factor_ppm,
	       v2_high_water_boost_threshold_coins, v2_high_water_boost_factor_ppm, v2_high_water_boost_recover_coins,
	       recharge_boost_window_ms, recharge_boost_factor_ppm,
	       user_24h_rtp_threshold_ppm, user_24h_ordinary_win_factor_ppm, jackpot_multiplier_ppms,
	       jackpot_global_rtp_max_ppm,
	       CASE WHEN jackpot_user_round_rtp_max_ppm = 0 AND jackpot_user_day_rtp_max_ppm <> 0
	            THEN jackpot_user_day_rtp_max_ppm ELSE jackpot_user_round_rtp_max_ppm END,
	       CASE WHEN jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0
	            THEN jackpot_user_72h_rtp_max_ppm ELSE jackpot_user_48h_rtp_max_ppm END,
	       jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
	       jackpot_rate_limit_window_minutes, max_jackpot_hits_per_window,
	       max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
	       device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
	FROM lucky_gift_rule_versions
	WHERE app_code = ? AND pool_id = ?`

func (r *Repository) getLuckyGiftRuleConfig(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.RuleConfig, bool, error) {
	if poolID == "" {
		return domain.RuleConfig{}, false, nil
	}
	lockSQL := ""
	if forUpdate {
		lockSQL = " FOR UPDATE"
	}
	row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+`
		ORDER BY rule_version DESC
		LIMIT 1`+lockSQL,
		appCode, poolID,
	)
	return r.finishLuckyGiftRuleConfigRow(ctx, q, appCode, poolID, row)
}

// getLuckyGiftRuleConfigVersion 读取实验钉住的指定版本快照；版本行不可变，语义与最新版本读取一致。
func (r *Repository) getLuckyGiftRuleConfigVersion(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64, forUpdate bool) (domain.RuleConfig, bool, error) {
	if poolID == "" || ruleVersion <= 0 {
		return domain.RuleConfig{}, false, nil
	}
	lockSQL := ""
	if forUpdate {
		lockSQL = " FOR UPDATE"
	}
	row := q.QueryRowContext(ctx, luckyRuleConfigSelectSQL+`
		AND rule_version = ?`+lockSQL,
		appCode, poolID, ruleVersion,
	)
	return r.finishLuckyGiftRuleConfigRow(ctx, q, appCode, poolID, row)
}

func (r *Repository) finishLuckyGiftRuleConfigRow(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, row *sql.Row) (domain.RuleConfig, bool, error) {
	config, err := scanLuckyGiftRuleConfig(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.RuleConfig{}, false, nil
		}
		return domain.RuleConfig{}, false, err
	}
	stages, err := r.getLuckyGiftRuleStages(ctx, q, appCode, poolID, config.RuleVersion)
	if err != nil {
		return domain.RuleConfig{}, false, err
	}
	config.Stages = stages
	return config, true, nil
}

func (r *Repository) getLuckyGiftRuleStages(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, ruleVersion int64) ([]domain.RuleStage, error) {
	rows, err := q.QueryContext(ctx, `
		SELECT stage, min_recharge_7d_coins, min_recharge_30d_coins,
		       tier_id, multiplier_ppm, base_weight_ppm, high_water_only, enabled
		FROM lucky_gift_stage_tiers
		WHERE app_code = ? AND pool_id = ? AND rule_version = ?
		ORDER BY FIELD(stage, 'novice', 'normal', 'advanced'), multiplier_ppm ASC, tier_id ASC`,
		appCode, poolID, ruleVersion,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	stageIndex := map[string]int{}
	stages := make([]domain.RuleStage, 0, 3)
	for rows.Next() {
		var tier domain.RuleTier
		var minRecharge7DCoins, minRecharge30DCoins int64
		if err := rows.Scan(
			&tier.Stage,
			&minRecharge7DCoins,
			&minRecharge30DCoins,
			&tier.TierID,
			&tier.MultiplierPPM,
			&tier.BaseWeightPPM,
			&tier.HighWaterOnly,
			&tier.Enabled,
		); err != nil {
			return nil, err
		}
		index, exists := stageIndex[tier.Stage]
		if !exists {
			index = len(stages)
			stageIndex[tier.Stage] = index
			stages = append(stages, domain.RuleStage{
				Stage:               tier.Stage,
				MinRecharge7DCoins:  minRecharge7DCoins,
				MinRecharge30DCoins: minRecharge30DCoins,
			})
		} else if stages[index].MinRecharge7DCoins != minRecharge7DCoins || stages[index].MinRecharge30DCoins != minRecharge30DCoins {
			// 阈值按 stage 定义但为兼容旧表结构随 tier 冗余保存；同阶段不一致表示数据已被绕过发布入口篡改，不能静默选第一行。
			return nil, fmt.Errorf("inconsistent recharge thresholds for lucky gift stage %s", tier.Stage)
		}
		stages[index].Tiers = append(stages[index].Tiers, tier)
	}
	return stages, rows.Err()
}

func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
	var config domain.RuleConfig
	var jackpotMultiplierPPMsJSON []byte
	if err := scanner.Scan(
		&config.AppCode,
		&config.PoolID,
		&config.RuleVersion,
		&config.Enabled,
		&config.StrategyVersion,
		&config.TargetRTPPPM,
		&config.PoolRatePPM,
		&config.ProfitRatePPM,
		&config.AnchorRatePPM,
		&config.SettlementWindowWager,
		&config.ControlBandPPM,
		&config.GiftPriceReference,
		&config.NoviceMaxEquivalentDraws,
		&config.NormalMaxEquivalentDraws,
		&config.EffectiveFromMS,
		&config.CreatedByAdminID,
		&config.CreatedAtMS,
		&config.InitialPoolCoins,
		&config.LossStreakGuarantee,
		&config.LowWatermarkCoins,
		&config.LowWaterNonzeroFactorPPM,
		&config.HighWatermarkCoins,
		&config.HighWaterNonzeroFactorPPM,
		&config.V2HighWaterBoostThresholdCoins,
		&config.V2HighWaterBoostFactorPPM,
		&config.V2HighWaterBoostRecoverCoins,
		&config.RechargeBoostWindowMS,
		&config.RechargeBoostFactorPPM,
		&config.User24HourRTPThresholdPPM,
		&config.User24HourOrdinaryWinFactorPPM,
		&jackpotMultiplierPPMsJSON,
		&config.JackpotGlobalRTPMaxPPM,
		&config.JackpotUserRoundRTPMaxPPM,
		&config.JackpotUser48hRTPMaxPPM,
		&config.JackpotSpendThresholdCoins,
		&config.MaxJackpotHitsPerUserDay,
		&config.JackpotRateLimitWindowMinutes,
		&config.MaxJackpotHitsPerWindow,
		&config.MaxSinglePayout,
		&config.UserHourlyPayoutCap,
		&config.UserDailyPayoutCap,
		&config.DeviceDailyPayoutCap,
		&config.RoomHourlyPayoutCap,
		&config.AnchorDailyPayoutCap,
	); err != nil {
		return domain.RuleConfig{}, err
	}
	config.StrategyVersion = strings.ToLower(strings.TrimSpace(config.StrategyVersion))
	if config.StrategyVersion == "" {
		config.StrategyVersion = domain.StrategyFixedV2
	}
	if len(jackpotMultiplierPPMsJSON) > 0 {
		if err := json.Unmarshal(jackpotMultiplierPPMsJSON, &config.JackpotMultiplierPPMs); err != nil {
			return domain.RuleConfig{}, fmt.Errorf("decode lucky gift jackpot multipliers: %w", err)
		}
	}
	if config.JackpotMultiplierPPMs == nil {
		config.JackpotMultiplierPPMs = []int64{}
	}
	// 数据库兼容期保留旧影子列，但查询已收敛到轮次/48h 两个业务口径；旧别名仅供同进程源码兼容。
	config.JackpotUserDayRTPMaxPPM = config.JackpotUserRoundRTPMaxPPM
	config.JackpotUser72hRTPMaxPPM = config.JackpotUser48hRTPMaxPPM
	config.SettledRoundRTPEligibility = true
	return config, nil
}
