package mysql

import (
	"context"
	"database/sql"
	"errors"
	"strings"

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

const luckyExperimentSelectColumns = `app_code, experiment_id, pool_id, name,
	control_rule_version, treatment_rule_version, treatment_traffic_ppm, bucket_salt,
	status, winner_arm, winner_rule_version, final_rule_version,
	created_by_admin_id, started_at_ms, ended_at_ms, updated_at_ms`

// resolveLuckyGiftRuleConfigForUser 是实验期的配置选择入口：
// 无未完成实验时保持"最新版本生效"的原始语义；有实验时按白名单和哈希分桶钉住组内版本。
// 事务开奖先锁最新规则行，再一致性读取实验：这与创建/结束实验保持"规则行→实验行"顺序，
// 同时确保 REPEATABLE READ 快照建立在前一笔同奖池结算提交之后，滚动 RTP 不会漏掉刚完成的抽奖。
// Check 等只读入口不加锁，仍使用普通一致性快照，避免把低频配置检查串行化。
// 实验状态（放量/白名单/暂停）变更可能让分组在 Check 与 Draw 之间漂移；两代策略都接受旧 JWT 的空设备，
// 而 dynamic_v3 的 paid_at_ms 来自扣费后的 wallet 回执，因此跨策略漂移不会再把已扣费命令卡死。
// 钉住的版本行缺失表示不可变事实被外部破坏，必须 fail-close，不能静默回落最新版本改变实验语义。
func (r *Repository) resolveLuckyGiftRuleConfigForUser(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, userKey string, forUpdate bool) (domain.RuleConfig, bool, error) {
	var lockedLatest domain.RuleConfig
	var lockedLatestExists bool
	var err error
	if forUpdate {
		// 最新规则行是同奖池开奖和实验管理共享的串行化入口。先锁它再读取实验，
		// 既固定事务快照边界，也避免 control/treatment 两个版本按用户分组产生反向锁序。
		lockedLatest, lockedLatestExists, err = r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, true)
		if err != nil {
			return domain.RuleConfig{}, false, err
		}
	}
	experiment, exists, err := r.getActiveLuckyGiftExperiment(ctx, q, appCode, poolID, false)
	if err != nil {
		return domain.RuleConfig{}, false, err
	}
	if !exists {
		if forUpdate {
			return lockedLatest, lockedLatestExists, nil
		}
		return r.getLuckyGiftRuleConfig(ctx, q, appCode, poolID, forUpdate)
	}
	override := ""
	if experiment.Status == domain.ExperimentStatusRunning {
		override, err = r.getLuckyGiftExperimentOverrideArm(ctx, q, appCode, experiment.ExperimentID, userKey)
		if err != nil {
			return domain.RuleConfig{}, false, err
		}
	}
	arm := domain.ResolveExperimentArm(experiment, userKey, override)
	ruleVersion := domain.ExperimentArmRuleVersion(experiment, arm)
	config, ok, err := r.getLuckyGiftRuleConfigVersion(ctx, q, appCode, poolID, ruleVersion, forUpdate)
	if err != nil {
		return domain.RuleConfig{}, false, err
	}
	if !ok {
		return domain.RuleConfig{}, false, xerr.New(xerr.Internal, "lucky gift experiment pinned rule version is missing")
	}
	return config, true, nil
}

func (r *Repository) getActiveLuckyGiftExperiment(ctx context.Context, q luckyRuleQueryer, appCode string, poolID string, forUpdate bool) (domain.Experiment, bool, error) {
	lockSQL := ""
	if forUpdate {
		lockSQL = " FOR UPDATE"
	}
	row := q.QueryRowContext(ctx, `
		SELECT `+luckyExperimentSelectColumns+`
		FROM lucky_gift_experiments
		WHERE app_code = ? AND pool_id = ? AND active = 1`+lockSQL,
		appCode, poolID,
	)
	experiment, err := scanLuckyGiftExperiment(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Experiment{}, false, nil
		}
		return domain.Experiment{}, false, err
	}
	return experiment, true, nil
}

func (r *Repository) getLuckyGiftExperimentByID(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string, forUpdate bool) (domain.Experiment, bool, error) {
	lockSQL := ""
	if forUpdate {
		lockSQL = " FOR UPDATE"
	}
	row := q.QueryRowContext(ctx, `
		SELECT `+luckyExperimentSelectColumns+`
		FROM lucky_gift_experiments
		WHERE app_code = ? AND experiment_id = ?`+lockSQL,
		appCode, experimentID,
	)
	experiment, err := scanLuckyGiftExperiment(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Experiment{}, false, nil
		}
		return domain.Experiment{}, false, err
	}
	return experiment, true, nil
}

func (r *Repository) getLuckyGiftExperimentOverrideArm(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string, userKey string) (string, error) {
	var arm string
	err := q.QueryRowContext(ctx, `
		SELECT arm FROM lucky_gift_experiment_overrides
		WHERE app_code = ? AND experiment_id = ? AND user_key = ?`,
		appCode, experimentID, userKey,
	).Scan(&arm)
	if errors.Is(err, sql.ErrNoRows) {
		return "", nil
	}
	if err != nil {
		return "", err
	}
	return arm, nil
}

// CreateLuckyGiftExperiment 在一个事务里发布 treatment 新版本并登记实验。
// 发布与登记必须原子完成：先发布后建实验会有一个"最新版本生效"窗口把 treatment 全量放开。
func (r *Repository) CreateLuckyGiftExperiment(ctx context.Context, cmd domain.CreateExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) {
	if r == nil || r.db == nil {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	poolID := luckyPoolID(cmd.TreatmentConfig.PoolID)
	// 锁住当前最新版本行：control 钉住它，同时与并发发布、并发建实验在同一把锁上互斥。
	control, exists, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, poolID, true)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if !exists {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment requires a published config to pin as control")
	}
	if !control.Enabled {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment control version must be enabled")
	}
	// v2 高水位加权滞回标志按 app+pool 单行存储（lucky_pools.v2_boost_active），不随 rule_version 键控；
	// advance 用"当前用户被钉住的组内三元组"推进这份共享状态。两组三元组不同（尤其 control 关/treatment 开
	// 的灰度形态）时：control 的每一抽都会清掉 treatment 刚置位的标志，滞回带内加权几乎恒为关；两组阈值不同
	// 还会让一组在自己从未越过阈值的水位按对方的状态加权付奖。该限制只适用于两组都是 fixed_v2；
	// V2/V3 混合实验的 dynamic_v3 使用独立资金账本，不读取或推进 V2 的滞回标志。
	if control.StrategyVersion == domain.StrategyFixedV2 && cmd.TreatmentConfig.StrategyVersion == domain.StrategyFixedV2 &&
		(control.V2HighWaterBoostThresholdCoins != cmd.TreatmentConfig.V2HighWaterBoostThresholdCoins ||
			control.V2HighWaterBoostFactorPPM != cmd.TreatmentConfig.V2HighWaterBoostFactorPPM ||
			control.V2HighWaterBoostRecoverCoins != cmd.TreatmentConfig.V2HighWaterBoostRecoverCoins) {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment arms must share identical v2 high-water boost settings; the pool-level hysteresis flag cannot serve two different boost configs")
	}
	// 锁读确保看见其他事务刚提交的实验行；同奖池并发建实验最终也会被唯一键兜底拒绝。
	// 已知取舍：不同奖池并发建实验可能在该唯一索引的共享间隙上互持 gap lock 后插入，触发 InnoDB 死锁检测
	// 回滚其一——管理端低频操作，报错重试即可，不为此引入额外串行化。
	if _, running, err := r.getActiveLuckyGiftExperiment(ctx, tx, appCode, poolID, true); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	} else if running {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Conflict, "lucky gift pool already has an active experiment")
	}
	treatment, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, cmd.TreatmentConfig, nowMS)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	experiment := domain.Experiment{
		AppCode:              appCode,
		PoolID:               poolID,
		ExperimentID:         idgen.New("lucky_exp"),
		Name:                 cmd.Name,
		ControlRuleVersion:   control.RuleVersion,
		TreatmentRuleVersion: treatment.RuleVersion,
		TreatmentTrafficPPM:  cmd.TreatmentTrafficPPM,
		BucketSalt:           idgen.New("bs"),
		Status:               domain.ExperimentStatusRunning,
		CreatedByAdminID:     cmd.OperatorAdminID,
		StartedAtMS:          nowMS,
		UpdatedAtMS:          nowMS,
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_gift_experiments (
			app_code, experiment_id, pool_id, name,
			control_rule_version, treatment_rule_version, treatment_traffic_ppm, bucket_salt,
			status, winner_arm, winner_rule_version, final_rule_version,
			created_by_admin_id, started_at_ms, ended_at_ms, created_at_ms, updated_at_ms
		) VALUES (?,?,?,?,?,?,?,?,?,'',0,0,?,?,0,?,?)`,
		experiment.AppCode, experiment.ExperimentID, experiment.PoolID, experiment.Name,
		experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, experiment.TreatmentTrafficPPM, experiment.BucketSalt,
		experiment.Status, experiment.CreatedByAdminID, experiment.StartedAtMS, nowMS, experiment.UpdatedAtMS,
	); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if err := upsertLuckyGiftExperimentOverrides(ctx, tx, appCode, experiment.ExperimentID, cmd.Overrides, cmd.OperatorAdminID, nowMS); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	experiment.Overrides = cmd.Overrides
	return experiment, treatment, nil
}

func (r *Repository) ListLuckyGiftExperiments(ctx context.Context, query domain.ExperimentQuery) ([]domain.Experiment, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.Normalize(query.AppCode)
	where := []string{"app_code = ?"}
	args := []any{appCode}
	if poolID := strings.TrimSpace(query.PoolID); poolID != "" {
		where = append(where, "pool_id = ?")
		args = append(args, luckyPoolID(poolID))
	}
	if status := strings.ToLower(strings.TrimSpace(query.Status)); status != "" {
		where = append(where, "status = ?")
		args = append(args, status)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT `+luckyExperimentSelectColumns+`
		FROM lucky_gift_experiments
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY started_at_ms DESC, experiment_id ASC
		LIMIT 200`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	experiments := make([]domain.Experiment, 0)
	for rows.Next() {
		experiment, err := scanLuckyGiftExperiment(rows)
		if err != nil {
			return nil, err
		}
		experiments = append(experiments, experiment)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	for index := range experiments {
		overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiments[index].ExperimentID)
		if err != nil {
			return nil, err
		}
		experiments[index].Overrides = overrides
		if query.WithStats {
			stats, err := r.getLuckyGiftExperimentArmStats(ctx, experiments[index])
			if err != nil {
				return nil, err
			}
			experiments[index].ArmStats = stats
		}
	}
	return experiments, nil
}

// UpdateLuckyGiftExperiment 调整放量比例或在 running/paused 之间切换。
// completed 实验是已收口的历史事实，不允许任何修改。
func (r *Repository) UpdateLuckyGiftExperiment(ctx context.Context, cmd domain.UpdateExperimentCommand, nowMS int64) (domain.Experiment, error) {
	if r == nil || r.db == nil {
		return domain.Experiment{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Experiment{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true)
	if err != nil {
		return domain.Experiment{}, err
	}
	if !exists {
		return domain.Experiment{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
	}
	if experiment.Status == domain.ExperimentStatusCompleted {
		return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is completed and immutable")
	}
	if cmd.TreatmentTrafficPPM >= 0 {
		experiment.TreatmentTrafficPPM = cmd.TreatmentTrafficPPM
	}
	if cmd.Status != "" {
		experiment.Status = cmd.Status
	}
	experiment.UpdatedAtMS = nowMS
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_gift_experiments
		SET treatment_traffic_ppm = ?, status = ?, updated_at_ms = ?
		WHERE app_code = ? AND experiment_id = ?`,
		experiment.TreatmentTrafficPPM, experiment.Status, experiment.UpdatedAtMS,
		appCode, cmd.ExperimentID,
	); err != nil {
		return domain.Experiment{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Experiment{}, err
	}
	overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID)
	if err != nil {
		return domain.Experiment{}, err
	}
	experiment.Overrides = overrides
	return experiment, nil
}

func (r *Repository) SetLuckyGiftExperimentOverrides(ctx context.Context, cmd domain.SetExperimentOverridesCommand, nowMS int64) (domain.Experiment, error) {
	if r == nil || r.db == nil {
		return domain.Experiment{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Experiment{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true)
	if err != nil {
		return domain.Experiment{}, err
	}
	if !exists {
		return domain.Experiment{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
	}
	if experiment.Status == domain.ExperimentStatusCompleted {
		return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is completed and immutable")
	}
	if err := upsertLuckyGiftExperimentOverrides(ctx, tx, appCode, cmd.ExperimentID, cmd.Upserts, cmd.OperatorAdminID, nowMS); err != nil {
		return domain.Experiment{}, err
	}
	for _, userKey := range cmd.Removes {
		userKey = strings.TrimSpace(userKey)
		if userKey == "" {
			continue
		}
		if _, err := tx.ExecContext(ctx, `
			DELETE FROM lucky_gift_experiment_overrides
			WHERE app_code = ? AND experiment_id = ? AND user_key = ?`,
			appCode, cmd.ExperimentID, userKey,
		); err != nil {
			return domain.Experiment{}, err
		}
	}
	// 累计上限在事务内核算：单次 500 的入口校验挡不住多次追加；超限会让 LIMIT 读取截断白名单展示，必须整体拒绝。
	var totalOverrides int
	if err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM lucky_gift_experiment_overrides
		WHERE app_code = ? AND experiment_id = ?`,
		appCode, cmd.ExperimentID,
	).Scan(&totalOverrides); err != nil {
		return domain.Experiment{}, err
	}
	if totalOverrides > domain.MaxExperimentOverrides {
		return domain.Experiment{}, xerr.New(xerr.InvalidArgument, "experiment overrides exceed the 500 user limit")
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_gift_experiments SET updated_at_ms = ?
		WHERE app_code = ? AND experiment_id = ?`,
		nowMS, appCode, cmd.ExperimentID,
	); err != nil {
		return domain.Experiment{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Experiment{}, err
	}
	experiment.UpdatedAtMS = nowMS
	overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID)
	if err != nil {
		return domain.Experiment{}, err
	}
	experiment.Overrides = overrides
	return experiment, nil
}

// EndLuckyGiftExperiment 结束实验：把获胜组钉住的配置快照重发布为新的最新版本，并在同一事务里收口实验。
// 重发布保证结束后"最新版本生效"的默认语义恰好收敛到获胜配置，没有先改状态再切版本的中间态。
func (r *Repository) EndLuckyGiftExperiment(ctx context.Context, cmd domain.EndExperimentCommand, nowMS int64) (domain.Experiment, domain.RuleConfig, error) {
	if r == nil || r.db == nil {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	defer func() { _ = tx.Rollback() }()

	appCode := appcode.FromContext(ctx)
	// 无锁预读只为拿到不可变的 pool_id：先锁规则行再锁实验行，与常规发布/创建实验保持同一加锁顺序，
	// 消除 End 与 Publish/Create 并发时的 AB-BA 交叉死锁。状态判断以下方锁读后的行为准。
	peek, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, false)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if !exists {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
	}
	if _, _, err := r.getLuckyGiftRuleConfig(ctx, tx, appCode, peek.PoolID, true); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	experiment, exists, err := r.getLuckyGiftExperimentByID(ctx, tx, appCode, cmd.ExperimentID, true)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if !exists {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.NotFound, "lucky gift experiment not found")
	}
	if experiment.Status == domain.ExperimentStatusCompleted {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift experiment is already completed")
	}
	winnerVersion := domain.ExperimentArmRuleVersion(experiment, cmd.WinnerArm)
	winnerConfig, ok, err := r.getLuckyGiftRuleConfigVersion(ctx, tx, appCode, experiment.PoolID, winnerVersion, false)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if !ok {
		return domain.Experiment{}, domain.RuleConfig{}, xerr.New(xerr.Internal, "lucky gift experiment winner rule version is missing")
	}
	winnerConfig.CreatedByAdminID = cmd.OperatorAdminID
	winnerConfig.EffectiveFromMS = 0
	if winnerConfig.StrategyVersion == domain.StrategyDynamicV3 {
		// 零冷启动策略上线前的遗留版本行可能残留 initial_pool_coins；运行时只做读时清零不回写，
		// 重发布入口必须同样清零，否则以此类版本为获胜方收口会被发布守卫拒绝，实验无法结束。
		winnerConfig.InitialPoolCoins = 0
	}
	finalConfig, err := r.publishLuckyGiftRuleConfigTx(ctx, tx, winnerConfig, nowMS)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	experiment.Status = domain.ExperimentStatusCompleted
	experiment.WinnerArm = cmd.WinnerArm
	experiment.WinnerRuleVersion = winnerVersion
	experiment.FinalRuleVersion = finalConfig.RuleVersion
	experiment.EndedAtMS = nowMS
	experiment.UpdatedAtMS = nowMS
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_gift_experiments
		SET status = ?, winner_arm = ?, winner_rule_version = ?, final_rule_version = ?, ended_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND experiment_id = ?`,
		experiment.Status, experiment.WinnerArm, experiment.WinnerRuleVersion, experiment.FinalRuleVersion,
		experiment.EndedAtMS, experiment.UpdatedAtMS,
		appCode, cmd.ExperimentID,
	); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	overrides, err := r.listLuckyGiftExperimentOverrides(ctx, r.db, appCode, experiment.ExperimentID)
	if err != nil {
		return domain.Experiment{}, domain.RuleConfig{}, err
	}
	experiment.Overrides = overrides
	return experiment, finalConfig, nil
}

func upsertLuckyGiftExperimentOverrides(ctx context.Context, tx *sql.Tx, appCode string, experimentID string, overrides []domain.ExperimentOverride, operatorAdminID int64, nowMS int64) error {
	for _, override := range overrides {
		userKey := strings.TrimSpace(override.UserKey)
		if userKey == "" {
			continue
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO lucky_gift_experiment_overrides (app_code, experiment_id, user_key, arm, created_by_admin_id, created_at_ms)
			VALUES (?,?,?,?,?,?)
			ON DUPLICATE KEY UPDATE arm = VALUES(arm), created_by_admin_id = VALUES(created_by_admin_id)`,
			appCode, experimentID, userKey, override.Arm, operatorAdminID, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) listLuckyGiftExperimentOverrides(ctx context.Context, q luckyRuleQueryer, appCode string, experimentID string) ([]domain.ExperimentOverride, error) {
	rows, err := q.QueryContext(ctx, `
		SELECT user_key, arm FROM lucky_gift_experiment_overrides
		WHERE app_code = ? AND experiment_id = ?
		ORDER BY user_key ASC
		LIMIT 1000`, appCode, experimentID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	overrides := make([]domain.ExperimentOverride, 0)
	for rows.Next() {
		var override domain.ExperimentOverride
		if err := rows.Scan(&override.UserKey, &override.Arm); err != nil {
			return nil, err
		}
		overrides = append(overrides, override)
	}
	return overrides, rows.Err()
}

// getLuckyGiftExperimentArmStats 按 rule_version 从抽奖事实现算两组聚合。
// 只统计内部房间抽奖（lucky_draw_records）；外部 App 抽奖走独立审计表，不混入同一口径。
func (r *Repository) getLuckyGiftExperimentArmStats(ctx context.Context, experiment domain.Experiment) ([]domain.ExperimentArmStats, error) {
	statsByVersion := map[int64]*domain.ExperimentArmStats{}
	arms := []domain.ExperimentArmStats{
		{Arm: domain.ExperimentArmControl, RuleVersion: experiment.ControlRuleVersion},
		{Arm: domain.ExperimentArmTreatment, RuleVersion: experiment.TreatmentRuleVersion},
	}
	windowSQL := ""
	args := []any{experiment.AppCode, experiment.PoolID, experiment.ControlRuleVersion, experiment.TreatmentRuleVersion, experiment.StartedAtMS}
	if experiment.Status == domain.ExperimentStatusCompleted && experiment.EndedAtMS > 0 {
		windowSQL = " AND created_at_ms <= ?"
		args = append(args, experiment.EndedAtMS)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT rule_version, COUNT(*), COUNT(DISTINCT user_id),
		       COALESCE(SUM(coin_spent), 0), COALESCE(SUM(effective_reward_coins), 0)
		FROM lucky_draw_records
		WHERE app_code = ? AND pool_id = ? AND rule_version IN (?, ?) AND created_at_ms >= ?`+windowSQL+`
		GROUP BY rule_version`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var stats domain.ExperimentArmStats
		if err := rows.Scan(&stats.RuleVersion, &stats.TotalDraws, &stats.UniqueUsers, &stats.TotalSpentCoins, &stats.TotalRewardCoins); err != nil {
			return nil, err
		}
		if stats.TotalSpentCoins > 0 {
			stats.ActualRTPPPM = stats.TotalRewardCoins * luckyPPMScale / stats.TotalSpentCoins
		}
		statsByVersion[stats.RuleVersion] = &stats
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	strategyRows, err := r.db.QueryContext(ctx, `
		SELECT rule_version, strategy_version FROM lucky_gift_rule_versions
		WHERE app_code = ? AND pool_id = ? AND rule_version IN (?, ?)`,
		experiment.AppCode, experiment.PoolID, experiment.ControlRuleVersion, experiment.TreatmentRuleVersion)
	if err != nil {
		return nil, err
	}
	defer strategyRows.Close()
	strategyByVersion := map[int64]string{}
	for strategyRows.Next() {
		var ruleVersion int64
		var strategyVersion string
		if err := strategyRows.Scan(&ruleVersion, &strategyVersion); err != nil {
			return nil, err
		}
		strategyByVersion[ruleVersion] = strings.ToLower(strings.TrimSpace(strategyVersion))
	}
	if err := strategyRows.Err(); err != nil {
		return nil, err
	}
	for index := range arms {
		if found := statsByVersion[arms[index].RuleVersion]; found != nil {
			arms[index].TotalDraws = found.TotalDraws
			arms[index].UniqueUsers = found.UniqueUsers
			arms[index].TotalSpentCoins = found.TotalSpentCoins
			arms[index].TotalRewardCoins = found.TotalRewardCoins
			arms[index].ActualRTPPPM = found.ActualRTPPPM
		}
		arms[index].StrategyVersion = strategyByVersion[arms[index].RuleVersion]
	}
	return arms, nil
}

func scanLuckyGiftExperiment(scanner luckyScanner) (domain.Experiment, error) {
	var experiment domain.Experiment
	if err := scanner.Scan(
		&experiment.AppCode,
		&experiment.ExperimentID,
		&experiment.PoolID,
		&experiment.Name,
		&experiment.ControlRuleVersion,
		&experiment.TreatmentRuleVersion,
		&experiment.TreatmentTrafficPPM,
		&experiment.BucketSalt,
		&experiment.Status,
		&experiment.WinnerArm,
		&experiment.WinnerRuleVersion,
		&experiment.FinalRuleVersion,
		&experiment.CreatedByAdminID,
		&experiment.StartedAtMS,
		&experiment.EndedAtMS,
		&experiment.UpdatedAtMS,
	); err != nil {
		return domain.Experiment{}, err
	}
	return experiment, nil
}
