package mysql

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"

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

const (
	luckyUserProfileSourceInternal       = "internal"
	luckyUserProfileSourceExternalItem   = "external_item"
	luckyUserProfileSourceExternalLegacy = "external_legacy"

	luckyUserProfileCursorInternal       = "user_profile_internal"
	luckyUserProfileCursorExternalItem   = "user_profile_external_item"
	luckyUserProfileCursorExternalLegacy = "user_profile_external_legacy"
)

// luckyUserProfileFact 是内部、外部逐抽和外部旧聚合事实的统一窄模型。大奖类型只能来自
// dynamic_v3 trace.JackpotMechanism；倍率再高也不能把普通 tier 误算成大奖。
type luckyUserProfileFact struct {
	AppCode         string
	SourceType      string
	SourceDrawID    string
	RequestID       string
	PoolID          string
	IdentityType    string
	IdentityKey     string
	StrategyUserID  int64
	InternalUserID  int64
	ExternalUserID  string
	RuleVersion     int64
	StrategyVersion string
	Stage           string
	DrawCount       int64
	WagerCoins      int64
	PayoutCoins     int64
	BaseRewardCoins int64
	MultiplierPPM   int64
	HitType         string
	RewardStatus    string
	HighOrdinary    bool
	GuaranteeHit    bool
	Downweight      bool
	TierID          string
	ReasonCode      string
	Conditions      []domain.StrategyConditionTrace
	OccurredAtMS    int64
	CreatedAtMS     int64
}

type luckyUserProfileCandidateSnapshot struct {
	DynamicV3 *domain.DecisionTrace `json:"dynamic_v3"`
}

type luckyUserProfileKey struct {
	AppCode         string
	PoolID          string
	StrategyVersion string
	IdentityType    string
	IdentityKey     string
}

// luckyUserProfileListCursor 固化“排序值 + 完整身份主键”。客户端只把 base64url token
// 原样回传；ScopeHash 阻止把某个筛选/排序下的游标误用于另一个查询，避免静默漏行。
type luckyUserProfileListCursor struct {
	Version      int    `json:"v"`
	ScopeHash    string `json:"s"`
	SortValue    int64  `json:"n"`
	IdentityType string `json:"t"`
	IdentityKey  string `json:"k"`
}

// luckyUserJackpotCursor 使用大奖索引的完整 DESC 唯一顺序；source_type 是主键的一部分，
// 同一毫秒且两个来源 draw_id 相同时仍不会重读或跳过。
type luckyUserJackpotCursor struct {
	Version      int    `json:"v"`
	ScopeHash    string `json:"s"`
	OccurredAtMS int64  `json:"o"`
	SourceType   string `json:"t"`
	DrawID       string `json:"d"`
}

type luckyUserProfileDelta struct {
	Latest                         luckyUserProfileFact
	TotalDraws                     int64
	TotalWagerCoins                int64
	TotalPayoutCoins               int64
	TotalBaseRewardCoins           int64
	OrdinaryWinCount               int64
	HighMultiplierOrdinaryWinCount int64
	RTPCompensationJackpotCount    int64
	CumulativeSpendJackpotCount    int64
	GuaranteeHitCount              int64
	ZeroDrawCount                  int64
	GrantedDrawCount               int64
	PendingDrawCount               int64
	FailedDrawCount                int64
	MaxMultiplierPPM               int64
	MaxRewardCoins                 int64
	FirstDrawAtMS                  int64
	LastDrawAtMS                   int64
}

type luckyUserMultiplierKey struct {
	ProfileKey    luckyUserProfileKey
	HitType       string
	MultiplierPPM int64
}

type luckyUserMultiplierDelta struct {
	DrawCount   int64
	WagerCoins  int64
	PayoutCoins int64
}

func luckyUserProfileFactFromRecord(record luckyDrawRecordInput, sourceType, identityType, identityKey, externalUserID string, strategyUserID int64) luckyUserProfileFact {
	fact := luckyUserProfileFact{
		AppCode: record.AppCode, SourceType: sourceType, SourceDrawID: record.DrawID,
		RequestID: record.Command.CommandID, PoolID: record.Config.PoolID,
		IdentityType: identityType, IdentityKey: identityKey, StrategyUserID: strategyUserID,
		ExternalUserID: externalUserID, RuleVersion: record.Config.RuleVersion,
		StrategyVersion: record.Config.StrategyVersion, Stage: record.ExperiencePool, DrawCount: 1,
		WagerCoins: record.Command.CoinSpent, PayoutCoins: record.Candidate.effectiveReward(),
		BaseRewardCoins: record.Candidate.BaseReward, MultiplierPPM: record.Candidate.MultiplierPPM,
		RewardStatus: record.RewardStatus, HighOrdinary: record.Candidate.HighMultiplier,
		TierID: record.Candidate.TierID, OccurredAtMS: luckyPaidTime(record.Command, record.NowMS).UnixMilli(),
		CreatedAtMS: record.NowMS,
	}
	if identityType == domain.UserProfileIdentityInternal {
		fact.InternalUserID = strategyUserID
	}
	if record.StrategyDecision != nil {
		applyLuckyUserProfileTrace(&fact, &record.StrategyDecision.Trace)
	}
	fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, fact.ReasonCode, fact.Conditions, record.StrategyDecision)
	fact.HighOrdinary = fact.HighOrdinary && fact.HitType == domain.UserProfileHitOrdinary
	return fact
}

func luckyUserProfileHitType(payout int64, _ string, _ []domain.StrategyConditionTrace, decision *domain.StrategyDecision) string {
	if payout <= 0 {
		return domain.UserProfileHitZero
	}
	if decision != nil {
		// 只认固化在审计 trace 中的独立大奖机制。普通概率允许配置同倍率，绝不能按倍率或 tier.Jackpot 猜大奖。
		switch decision.Trace.JackpotMechanism {
		case domain.StrategyJackpotMechanismRTPCompensation:
			return domain.UserProfileHitRTPCompensation
		case domain.StrategyJackpotMechanismMilestone:
			return domain.UserProfileHitSpendMilestone
		}
	}
	return domain.UserProfileHitOrdinary
}

func applyLuckyUserProfileTrace(fact *luckyUserProfileFact, trace *domain.DecisionTrace) {
	if fact == nil || trace == nil {
		return
	}
	fact.ReasonCode = strings.TrimSpace(trace.FinalReason)
	fact.Conditions = append([]domain.StrategyConditionTrace(nil), trace.Conditions...)
	for _, condition := range trace.Conditions {
		if condition.Name == domain.StrategyConditionUser24HourRTPDownweight {
			fact.Downweight = condition.Passed
		}
	}
	for _, removed := range trace.Removed {
		if removed.Reason == domain.StrategyRemovalMissProtection {
			fact.GuaranteeHit = true
			break
		}
	}
	if strings.TrimSpace(trace.Stage) != "" {
		fact.Stage = trace.Stage
	}
}

func luckyUserProfileFactFromSnapshot(fact luckyUserProfileFact, snapshot []byte) luckyUserProfileFact {
	var decoded luckyUserProfileCandidateSnapshot
	if len(snapshot) == 0 || json.Unmarshal(snapshot, &decoded) != nil || decoded.DynamicV3 == nil {
		fact.StrategyVersion = domain.StrategyFixedV2
		fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, "", nil, nil)
		fact.HighOrdinary = fact.HighOrdinary && fact.HitType == domain.UserProfileHitOrdinary
		return fact
	}
	fact.StrategyVersion = domain.StrategyDynamicV3
	applyLuckyUserProfileTrace(&fact, decoded.DynamicV3)
	if fact.PayoutCoins <= 0 {
		fact.HitType = domain.UserProfileHitZero
	} else {
		// 历史回灌直接读取持久 trace 的机制字段；不从倍率、tier_id 或旧 high_multiplier 标记推断大奖。
		switch decoded.DynamicV3.JackpotMechanism {
		case domain.StrategyJackpotMechanismRTPCompensation:
			fact.HitType = domain.UserProfileHitRTPCompensation
		case domain.StrategyJackpotMechanismMilestone:
			fact.HitType = domain.UserProfileHitSpendMilestone
		default:
			fact.HitType = domain.UserProfileHitOrdinary
		}
	}
	fact.HighOrdinary = fact.HighOrdinary && fact.HitType == domain.UserProfileHitOrdinary
	return fact
}

// persistLuckyGiftUserProfileFacts 将事实唯一键和累计画像放在调用方事务内提交。aggregation_token
// 只会落在首次 INSERT IGNORE 成功的事件上，在线写与历史回灌并发时也不会双算累计值。
func (r *Repository) persistLuckyGiftUserProfileFacts(ctx context.Context, tx *sql.Tx, facts []luckyUserProfileFact, nowMS int64) error {
	if len(facts) == 0 {
		return nil
	}
	grouped := make(map[string][]luckyUserProfileFact)
	for _, fact := range facts {
		if fact.DrawCount <= 0 {
			fact.DrawCount = 1
		}
		if fact.OccurredAtMS <= 0 {
			fact.OccurredAtMS = nowMS
		}
		if fact.CreatedAtMS <= 0 {
			fact.CreatedAtMS = nowMS
		}
		key := fact.AppCode + "\x00" + fact.SourceType
		grouped[key] = append(grouped[key], fact)
	}
	for _, sourceFacts := range grouped {
		if err := r.persistLuckyGiftUserProfileSourceFacts(ctx, tx, sourceFacts, nowMS); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) persistLuckyGiftUserProfileSourceFacts(ctx context.Context, tx *sql.Tx, facts []luckyUserProfileFact, nowMS int64) error {
	if len(facts) == 0 {
		return nil
	}
	token := idgen.New("lucky_profile_batch")
	const chunkSize = 200
	for start := 0; start < len(facts); start += chunkSize {
		end := start + chunkSize
		if end > len(facts) {
			end = len(facts)
		}
		var query strings.Builder
		query.WriteString(`INSERT IGNORE INTO lucky_gift_user_profile_events (
			app_code, source_type, source_draw_id, pool_id, strategy_version, identity_type, identity_key,
			draw_count, wager_coins, payout_coins, base_reward_coins, multiplier_ppm,
			hit_type, reward_status, occurred_at_ms, aggregation_token, created_at_ms
		) VALUES `)
		args := make([]any, 0, (end-start)*17)
		for index, fact := range facts[start:end] {
			if index > 0 {
				query.WriteByte(',')
			}
			query.WriteString("(" + luckySQLPlaceholders(17) + ")")
			args = append(args, fact.AppCode, fact.SourceType, fact.SourceDrawID, fact.PoolID, fact.StrategyVersion, fact.IdentityType, fact.IdentityKey,
				fact.DrawCount, fact.WagerCoins, fact.PayoutCoins, fact.BaseRewardCoins, fact.MultiplierPPM,
				fact.HitType, fact.RewardStatus, fact.OccurredAtMS, token, nowMS)
		}
		if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
			return err
		}
	}

	insertedRows, err := tx.QueryContext(ctx, `
		SELECT source_draw_id
		FROM lucky_gift_user_profile_events FORCE INDEX (idx_lucky_user_profile_event_token)
		WHERE app_code = ? AND source_type = ? AND aggregation_token = ?`, facts[0].AppCode, facts[0].SourceType, token)
	if err != nil {
		return err
	}
	inserted := make(map[string]struct{}, len(facts))
	for insertedRows.Next() {
		var drawID string
		if err := insertedRows.Scan(&drawID); err != nil {
			_ = insertedRows.Close()
			return err
		}
		inserted[drawID] = struct{}{}
	}
	if err := insertedRows.Err(); err != nil {
		_ = insertedRows.Close()
		return err
	}
	if err := insertedRows.Close(); err != nil {
		return err
	}
	if len(inserted) == 0 {
		return nil
	}
	newFacts := make([]luckyUserProfileFact, 0, len(inserted))
	for _, fact := range facts {
		if _, ok := inserted[fact.SourceDrawID]; ok {
			newFacts = append(newFacts, fact)
			// 输入即使被上游意外重复，也只能由事件唯一键对应的一份事实推进累计值。
			delete(inserted, fact.SourceDrawID)
		}
	}
	return r.applyLuckyGiftUserProfileDeltas(ctx, tx, newFacts, nowMS)
}

func (r *Repository) applyLuckyGiftUserProfileDeltas(ctx context.Context, tx *sql.Tx, facts []luckyUserProfileFact, nowMS int64) error {
	profiles := make(map[luckyUserProfileKey]*luckyUserProfileDelta)
	multipliers := make(map[luckyUserMultiplierKey]*luckyUserMultiplierDelta)
	jackpots := make([]luckyUserProfileFact, 0)
	for _, fact := range facts {
		key := luckyUserProfileKey{AppCode: fact.AppCode, PoolID: fact.PoolID, StrategyVersion: fact.StrategyVersion, IdentityType: fact.IdentityType, IdentityKey: fact.IdentityKey}
		delta := profiles[key]
		if delta == nil {
			delta = &luckyUserProfileDelta{Latest: fact, FirstDrawAtMS: fact.OccurredAtMS, LastDrawAtMS: fact.OccurredAtMS}
			profiles[key] = delta
		}
		if fact.OccurredAtMS > delta.LastDrawAtMS || (fact.OccurredAtMS == delta.LastDrawAtMS && fact.SourceDrawID > delta.Latest.SourceDrawID) {
			delta.Latest = fact
			delta.LastDrawAtMS = fact.OccurredAtMS
		}
		if fact.OccurredAtMS < delta.FirstDrawAtMS {
			delta.FirstDrawAtMS = fact.OccurredAtMS
		}
		delta.TotalDraws += fact.DrawCount
		delta.TotalWagerCoins += fact.WagerCoins
		delta.TotalPayoutCoins += fact.PayoutCoins
		delta.TotalBaseRewardCoins += fact.BaseRewardCoins
		switch fact.HitType {
		case domain.UserProfileHitRTPCompensation:
			delta.RTPCompensationJackpotCount += fact.DrawCount
			jackpots = append(jackpots, fact)
		case domain.UserProfileHitSpendMilestone:
			delta.CumulativeSpendJackpotCount += fact.DrawCount
			jackpots = append(jackpots, fact)
		case domain.UserProfileHitOrdinary:
			if fact.PayoutCoins > 0 {
				delta.OrdinaryWinCount += fact.DrawCount
				if fact.HighOrdinary {
					delta.HighMultiplierOrdinaryWinCount += fact.DrawCount
				}
			}
		default:
			delta.ZeroDrawCount += fact.DrawCount
		}
		if fact.GuaranteeHit && fact.PayoutCoins > 0 {
			delta.GuaranteeHitCount += fact.DrawCount
		}
		switch fact.RewardStatus {
		case domain.StatusPending:
			delta.PendingDrawCount += fact.DrawCount
		case domain.StatusFailed:
			delta.FailedDrawCount += fact.DrawCount
		default:
			delta.GrantedDrawCount += fact.DrawCount
		}
		if fact.MultiplierPPM > delta.MaxMultiplierPPM {
			delta.MaxMultiplierPPM = fact.MultiplierPPM
		}
		if fact.PayoutCoins > delta.MaxRewardCoins {
			delta.MaxRewardCoins = fact.PayoutCoins
		}
		multiplierKey := luckyUserMultiplierKey{ProfileKey: key, HitType: fact.HitType, MultiplierPPM: fact.MultiplierPPM}
		multiplier := multipliers[multiplierKey]
		if multiplier == nil {
			multiplier = &luckyUserMultiplierDelta{}
			multipliers[multiplierKey] = multiplier
		}
		multiplier.DrawCount += fact.DrawCount
		multiplier.WagerCoins += fact.WagerCoins
		multiplier.PayoutCoins += fact.PayoutCoins
	}
	for key, delta := range profiles {
		if err := r.upsertLuckyGiftUserProfileDelta(ctx, tx, key, *delta, nowMS); err != nil {
			return err
		}
	}
	for key, delta := range multipliers {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO lucky_gift_user_multiplier_stats (
				app_code, pool_id, strategy_version, identity_type, identity_key, hit_type, multiplier_ppm,
				draw_count, wager_coins, payout_coins, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
			ON DUPLICATE KEY UPDATE
				draw_count = draw_count + VALUES(draw_count),
				wager_coins = wager_coins + VALUES(wager_coins),
				payout_coins = payout_coins + VALUES(payout_coins),
				updated_at_ms = VALUES(updated_at_ms)`,
			key.ProfileKey.AppCode, key.ProfileKey.PoolID, key.ProfileKey.StrategyVersion, key.ProfileKey.IdentityType, key.ProfileKey.IdentityKey,
			key.HitType, key.MultiplierPPM, delta.DrawCount, delta.WagerCoins, delta.PayoutCoins, nowMS); err != nil {
			return err
		}
	}
	for _, fact := range jackpots {
		conditionsJSON, err := json.Marshal(fact.Conditions)
		if err != nil {
			return err
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT IGNORE INTO lucky_gift_user_jackpot_hits (
				app_code, source_type, source_draw_id, pool_id, strategy_version, identity_type, identity_key,
				request_id, rule_version, stage, mechanism, reason_code, tier_id,
				multiplier_ppm, wager_coins, payout_coins, conditions_json, occurred_at_ms, created_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			fact.AppCode, fact.SourceType, fact.SourceDrawID, fact.PoolID, fact.StrategyVersion, fact.IdentityType, fact.IdentityKey,
			fact.RequestID, fact.RuleVersion, fact.Stage, fact.HitType, fact.ReasonCode, fact.TierID,
			fact.MultiplierPPM, fact.WagerCoins, fact.PayoutCoins, string(conditionsJSON), fact.OccurredAtMS, nowMS); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) upsertLuckyGiftUserProfileDelta(ctx context.Context, tx *sql.Tx, key luckyUserProfileKey, delta luckyUserProfileDelta, nowMS int64) error {
	latest := delta.Latest
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_gift_user_profiles (
			app_code, pool_id, strategy_version, identity_type, identity_key, strategy_user_id, internal_user_id, external_user_id,
			rule_version, current_stage, total_draws, total_wager_coins, total_payout_coins,
			total_base_reward_coins, ordinary_win_count, high_multiplier_ordinary_win_count,
			rtp_compensation_jackpot_count, cumulative_spend_jackpot_count, guarantee_hit_count, zero_draw_count,
			granted_draw_count, pending_draw_count, failed_draw_count, max_multiplier_ppm, max_reward_coins,
			last_downweight_active, first_draw_at_ms, last_draw_at_ms, aggregated_at_ms, created_at_ms, updated_at_ms
		) VALUES (`+luckySQLPlaceholders(31)+`)
		ON DUPLICATE KEY UPDATE
			strategy_user_id = VALUES(strategy_user_id),
			internal_user_id = VALUES(internal_user_id),
			external_user_id = VALUES(external_user_id),
			rule_version = IF(VALUES(last_draw_at_ms) >= last_draw_at_ms, VALUES(rule_version), rule_version),
			current_stage = IF(VALUES(last_draw_at_ms) >= last_draw_at_ms, VALUES(current_stage), current_stage),
			total_draws = total_draws + VALUES(total_draws),
			total_wager_coins = total_wager_coins + VALUES(total_wager_coins),
			total_payout_coins = total_payout_coins + VALUES(total_payout_coins),
			total_base_reward_coins = total_base_reward_coins + VALUES(total_base_reward_coins),
			ordinary_win_count = ordinary_win_count + VALUES(ordinary_win_count),
			high_multiplier_ordinary_win_count = high_multiplier_ordinary_win_count + VALUES(high_multiplier_ordinary_win_count),
			rtp_compensation_jackpot_count = rtp_compensation_jackpot_count + VALUES(rtp_compensation_jackpot_count),
			cumulative_spend_jackpot_count = cumulative_spend_jackpot_count + VALUES(cumulative_spend_jackpot_count),
			guarantee_hit_count = guarantee_hit_count + VALUES(guarantee_hit_count),
			zero_draw_count = zero_draw_count + VALUES(zero_draw_count),
			granted_draw_count = granted_draw_count + VALUES(granted_draw_count),
			pending_draw_count = pending_draw_count + VALUES(pending_draw_count),
			failed_draw_count = failed_draw_count + VALUES(failed_draw_count),
			max_multiplier_ppm = GREATEST(max_multiplier_ppm, VALUES(max_multiplier_ppm)),
			max_reward_coins = GREATEST(max_reward_coins, VALUES(max_reward_coins)),
			last_downweight_active = IF(VALUES(last_draw_at_ms) >= last_draw_at_ms, VALUES(last_downweight_active), last_downweight_active),
			first_draw_at_ms = IF(first_draw_at_ms = 0, VALUES(first_draw_at_ms), LEAST(first_draw_at_ms, VALUES(first_draw_at_ms))),
			last_draw_at_ms = GREATEST(last_draw_at_ms, VALUES(last_draw_at_ms)),
			aggregated_at_ms = GREATEST(aggregated_at_ms, VALUES(aggregated_at_ms)),
			updated_at_ms = VALUES(updated_at_ms)`,
		key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey, latest.StrategyUserID, latest.InternalUserID, latest.ExternalUserID,
		latest.RuleVersion, latest.Stage, delta.TotalDraws, delta.TotalWagerCoins, delta.TotalPayoutCoins,
		delta.TotalBaseRewardCoins, delta.OrdinaryWinCount, delta.HighMultiplierOrdinaryWinCount,
		delta.RTPCompensationJackpotCount, delta.CumulativeSpendJackpotCount, delta.GuaranteeHitCount, delta.ZeroDrawCount,
		delta.GrantedDrawCount, delta.PendingDrawCount, delta.FailedDrawCount, delta.MaxMultiplierPPM, delta.MaxRewardCoins,
		latest.Downweight, delta.FirstDrawAtMS, delta.LastDrawAtMS, nowMS, nowMS, nowMS); err != nil {
		return err
	}
	return r.syncLuckyGiftUserProfileSortState(ctx, tx, key)
}

// syncLuckyGiftUserProfileSortState 在同一开奖/回灌事务内固化所有列表排序字段。列表因此不再
// JOIN lucky_user_states，也不需要对累计 RTP 或大奖数做表达式 filesort；状态行缺失时明确回落 0。
func (r *Repository) syncLuckyGiftUserProfileSortState(ctx context.Context, tx *sql.Tx, key luckyUserProfileKey) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_gift_user_profiles p
		LEFT JOIN lucky_user_states s
		  ON s.app_code = p.app_code AND s.user_id = p.strategy_user_id AND s.gift_id = p.pool_id
		SET p.lifetime_rtp_ppm = CASE
		      WHEN p.total_wager_coins <= 0 THEN 0
		      ELSE CAST(LEAST(CAST(9223372036854775807 AS DECIMAL(65,0)),
		           FLOOR(CAST(p.total_payout_coins AS DECIMAL(65,0)) * 1000000 / p.total_wager_coins)) AS SIGNED)
		    END,
		    p.jackpot_count = p.rtp_compensation_jackpot_count + p.cumulative_spend_jackpot_count,
		    p.current_equivalent_draws = COALESCE(s.equivalent_draws, 0),
		    p.current_loss_streak = COALESCE(s.loss_streak, 0),
		    p.current_pending_spend_jackpot_tokens = CASE
		      WHEN s.pending_spend_jackpot_rule_version = p.rule_version THEN COALESCE(s.pending_spend_jackpot_tokens, 0)
		      ELSE 0
		    END
		WHERE p.app_code = ? AND p.pool_id = ? AND p.strategy_version = ?
		  AND p.identity_type = ? AND p.identity_key = ?`,
		key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey)
	return err
}

func (r *Repository) ListLuckyGiftUserProfiles(ctx context.Context, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error) {
	if r == nil || r.db == nil {
		return nil, 0, "", false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	// COUNT、当前页、窗口指标必须来自同一 InnoDB 一致性快照；否则并发开奖可能让
	// total/累计值仍是旧提交，而 24h 窗口已经包含新提交，页面同一行会自相矛盾。
	tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
	if err != nil {
		return nil, 0, "", false, err
	}
	defer func() { _ = tx.Rollback() }()
	profiles, total, nextCursor, hasMore, err := r.listLuckyGiftUserProfiles(ctx, tx, query, nowMS)
	if err != nil {
		return nil, 0, "", false, err
	}
	if err := tx.Commit(); err != nil {
		return nil, 0, "", false, err
	}
	return profiles, total, nextCursor, hasMore, nil
}

func (r *Repository) listLuckyGiftUserProfiles(ctx context.Context, q luckyRuleQueryer, query domain.UserProfileQuery, nowMS int64) ([]domain.UserProfile, int64, string, bool, error) {
	rule, found, err := r.getLuckyGiftRuleConfig(ctx, q, query.AppCode, query.PoolID, false)
	if err != nil {
		return nil, 0, "", false, err
	}
	if !found {
		return []domain.UserProfile{}, 0, "", false, nil
	}
	whereSQL, args, err := luckyUserProfileWhereClause(query, rule.StrategyVersion)
	if err != nil {
		return nil, 0, "", false, err
	}
	order, err := luckyUserProfileOrderClause(query.SortBy, query.SortDirection)
	if err != nil {
		return nil, 0, "", false, err
	}
	var total int64
	if err := q.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM lucky_gift_user_profiles p FORCE INDEX (`+luckyUserProfileCountIndex(query, order)+`)
		WHERE `+whereSQL, args...).Scan(&total); err != nil {
		return nil, 0, "", false, err
	}
	listWhereSQL := whereSQL
	listArgs := append([]any(nil), args...)
	if query.Cursor != "" {
		cursor, cursorErr := decodeLuckyUserProfileListCursor(query.Cursor, query, rule.StrategyVersion, order)
		if cursorErr != nil {
			return nil, 0, "", false, cursorErr
		}
		comparison := "<"
		if order.Direction == "ASC" {
			comparison = ">"
		}
		// 三列统一方向，和 FORCE 的 DESC 复合索引完全一致；ASC 由 InnoDB 反向扫描同一索引。
		listWhereSQL += " AND (" + order.Column + " " + comparison + " ? OR (" + order.Column + " = ? AND " +
			"(p.identity_type " + comparison + " ? OR (p.identity_type = ? AND p.identity_key " + comparison + " ?))))"
		listArgs = append(listArgs, cursor.SortValue, cursor.SortValue, cursor.IdentityType, cursor.IdentityType, cursor.IdentityKey)
	}
	listArgs = append(listArgs, int(query.PageSize)+1)
	rows, err := q.QueryContext(ctx, `
		SELECT p.app_code, p.pool_id, p.identity_type, p.identity_key, p.strategy_user_id,
		       p.internal_user_id, p.external_user_id, p.rule_version, p.strategy_version, p.current_stage,
		       p.total_draws, p.current_equivalent_draws, p.current_loss_streak,
		       p.current_pending_spend_jackpot_tokens, p.total_draws, p.total_wager_coins,
		       p.total_payout_coins, p.lifetime_rtp_ppm, p.total_base_reward_coins, p.ordinary_win_count,
		       p.high_multiplier_ordinary_win_count, p.rtp_compensation_jackpot_count,
		       p.cumulative_spend_jackpot_count, p.guarantee_hit_count, p.zero_draw_count,
		       p.granted_draw_count, p.pending_draw_count, p.failed_draw_count, p.max_multiplier_ppm,
		       p.max_reward_coins, p.first_draw_at_ms, p.last_draw_at_ms, p.aggregated_at_ms
		FROM lucky_gift_user_profiles p FORCE INDEX (`+order.Index+`)
		WHERE `+listWhereSQL+`
		ORDER BY `+order.Column+` `+order.Direction+`, p.identity_type `+order.Direction+`, p.identity_key `+order.Direction+`
		LIMIT ?`, listArgs...)
	if err != nil {
		return nil, 0, "", false, err
	}
	defer rows.Close()
	profiles := make([]domain.UserProfile, 0, query.PageSize+1)
	for rows.Next() {
		var profile domain.UserProfile
		var totalDraws, totalWager, totalPayout, lifetimeRTPPPM int64
		if err := rows.Scan(
			&profile.AppCode, &profile.PoolID, &profile.IdentityType, &profile.IdentityKey, &profile.StrategyUserID,
			&profile.InternalUserID, &profile.ExternalUserID, &profile.RuleVersion, &profile.StrategyVersion, &profile.Stage,
			&profile.PaidDraws, &profile.EquivalentDraws, &profile.LossStreak, &profile.PendingSpendJackpotTokens,
			&totalDraws, &totalWager, &totalPayout, &lifetimeRTPPPM, &profile.BaseRewardCoins, &profile.OrdinaryWinCount,
			&profile.HighMultiplierOrdinaryWinCount, &profile.RTPCompensationJackpotCount,
			&profile.CumulativeSpendJackpotCount, &profile.GuaranteeHitCount, &profile.ZeroDrawCount,
			&profile.GrantedDrawCount, &profile.PendingDrawCount, &profile.FailedDrawCount, &profile.MaxMultiplierPPM,
			&profile.MaxRewardCoins, &profile.FirstDrawAtMS, &profile.LastDrawAtMS, &profile.AggregatedAtMS,
		); err != nil {
			return nil, 0, "", false, err
		}
		profile.Lifetime = luckyUserProfileWindow(totalDraws, totalWager, totalPayout)
		if totalWager > 0 {
			profile.Lifetime.RTPPPM = lifetimeRTPPPM
			profile.Lifetime.HasRTP = true
		}
		if nowMS > profile.AggregatedAtMS {
			// data_lag 表达后台读到的画像距当前快照有多久；抽奖时间到聚合时间只是处理耗时，
			// 会把历史回灌误显示成实时画像延迟，无法回答运营真正关心的“这行数据有多旧”。
			profile.DataLagMS = nowMS - profile.AggregatedAtMS
		}
		profiles = append(profiles, profile)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, "", false, err
	}
	hasMore := len(profiles) > int(query.PageSize)
	if hasMore {
		profiles = profiles[:query.PageSize]
	}
	if err := r.loadLuckyGiftUserProfileWindows(ctx, q, query.AppCode, query.PoolID, rule.StrategyVersion, profiles, nowMS); err != nil {
		return nil, 0, "", false, err
	}
	applyLuckyGiftUserProfileCurrentControls(rule, profiles)
	nextCursor := ""
	if hasMore && len(profiles) > 0 {
		nextCursor, err = encodeLuckyUserProfileListCursor(query, rule.StrategyVersion, order, profiles[len(profiles)-1])
		if err != nil {
			return nil, 0, "", false, err
		}
	}
	return profiles, total, nextCursor, hasMore, nil
}

func luckyUserProfileCountIndex(query domain.UserProfileQuery, order luckyUserProfileOrder) string {
	// 精确身份搜索优先使用专用索引；其余状态 COUNT 选择同前缀物化索引，永不回表 JOIN current state。
	if query.InternalUserID > 0 && query.ExternalUserID == "" {
		return "idx_lucky_user_profile_internal"
	}
	if query.ExternalUserID != "" && query.InternalUserID <= 0 {
		return "idx_lucky_user_profile_external"
	}
	if query.Stage != "" {
		return "idx_lucky_user_profile_stage"
	}
	switch query.Status {
	case "jackpot":
		return "idx_lucky_user_profile_sort_jackpot"
	case "loss_streak":
		return "idx_lucky_user_profile_sort_loss"
	case "pending_token":
		return "idx_lucky_user_profile_pending"
	default:
		return order.Index
	}
}

func luckyUserProfileWhereClause(query domain.UserProfileQuery, strategyVersion string) (string, []any, error) {
	where := []string{"p.app_code = ?", "p.pool_id = ?", "p.strategy_version = ?"}
	args := []any{query.AppCode, query.PoolID, strategyVersion}
	identityType := strings.TrimSpace(query.IdentityType)
	if identityType != "" {
		where = append(where, "p.identity_type = ?")
		args = append(args, identityType)
	}
	// identity_type=all 时内部和外部搜索条件必须是 OR；AND 会让任何一行都不可能同时满足两种身份。
	if identityType == "" && query.InternalUserID > 0 && query.ExternalUserID != "" {
		where = append(where, "((p.identity_type = 'internal' AND p.internal_user_id = ?) OR (p.identity_type = 'external' AND p.external_user_id = ?))")
		args = append(args, query.InternalUserID, query.ExternalUserID)
	} else {
		if query.InternalUserID > 0 {
			where = append(where, "p.internal_user_id = ?")
			args = append(args, query.InternalUserID)
		}
		if query.ExternalUserID != "" {
			where = append(where, "p.external_user_id = ?")
			args = append(args, query.ExternalUserID)
		}
	}
	if query.Stage != "" {
		where = append(where, "p.current_stage = ?")
		args = append(args, query.Stage)
	}
	switch query.Status {
	case "":
	case "jackpot":
		where = append(where, "p.jackpot_count > 0")
	case "loss_streak":
		where = append(where, "p.current_loss_streak > 0")
	case "pending_token":
		where = append(where, "p.current_pending_spend_jackpot_tokens > 0")
	default:
		return "", nil, xerr.New(xerr.InvalidArgument, "unsupported lucky gift user profile status")
	}
	return strings.Join(where, " AND "), args, nil
}

type luckyUserProfileOrder struct {
	Name      string
	Column    string
	Index     string
	Direction string
}

func luckyUserProfileOrderClause(sortBy, direction string) (luckyUserProfileOrder, error) {
	order := luckyUserProfileOrder{Name: "last_draw_at", Column: "p.last_draw_at_ms", Index: "idx_lucky_user_profile_sort_last"}
	switch strings.TrimSpace(sortBy) {
	case "", "last_draw_at":
	case "lifetime_rtp":
		order.Name, order.Column, order.Index = "lifetime_rtp", "p.lifetime_rtp_ppm", "idx_lucky_user_profile_sort_rtp"
	case "lifetime_wager":
		order.Name, order.Column, order.Index = "lifetime_wager", "p.total_wager_coins", "idx_lucky_user_profile_sort_wager"
	case "lifetime_payout":
		order.Name, order.Column, order.Index = "lifetime_payout", "p.total_payout_coins", "idx_lucky_user_profile_sort_payout"
	case "jackpot_count":
		order.Name, order.Column, order.Index = "jackpot_count", "p.jackpot_count", "idx_lucky_user_profile_sort_jackpot"
	case "loss_streak":
		order.Name, order.Column, order.Index = "loss_streak", "p.current_loss_streak", "idx_lucky_user_profile_sort_loss"
	default:
		return luckyUserProfileOrder{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift user profile sort_by")
	}
	direction = strings.ToUpper(strings.TrimSpace(direction))
	if direction == "" {
		direction = "DESC"
	}
	if direction != "ASC" && direction != "DESC" {
		return luckyUserProfileOrder{}, xerr.New(xerr.InvalidArgument, "unsupported lucky gift user profile sort_direction")
	}
	order.Direction = direction
	return order, nil
}

func luckyUserProfileCursorScope(query domain.UserProfileQuery, strategyVersion string, order luckyUserProfileOrder) string {
	canonical := strings.Join([]string{
		query.AppCode, query.PoolID, strategyVersion, query.IdentityType,
		strconv.FormatInt(query.InternalUserID, 10), query.ExternalUserID, query.Stage, query.Status,
		order.Name, order.Direction,
	}, "\x00")
	sum := sha256.Sum256([]byte(canonical))
	return fmt.Sprintf("%x", sum[:])
}

func encodeLuckyUserProfileListCursor(query domain.UserProfileQuery, strategyVersion string, order luckyUserProfileOrder, profile domain.UserProfile) (string, error) {
	cursor := luckyUserProfileListCursor{
		Version: 1, ScopeHash: luckyUserProfileCursorScope(query, strategyVersion, order),
		SortValue:    luckyUserProfileSortValue(profile, order.Name),
		IdentityType: profile.IdentityType, IdentityKey: profile.IdentityKey,
	}
	payload, err := json.Marshal(cursor)
	if err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(payload), nil
}

func decodeLuckyUserProfileListCursor(raw string, query domain.UserProfileQuery, strategyVersion string, order luckyUserProfileOrder) (luckyUserProfileListCursor, error) {
	payload, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
	if err != nil {
		return luckyUserProfileListCursor{}, xerr.New(xerr.InvalidArgument, "invalid lucky gift user profile cursor")
	}
	var cursor luckyUserProfileListCursor
	if json.Unmarshal(payload, &cursor) != nil || cursor.Version != 1 ||
		cursor.ScopeHash != luckyUserProfileCursorScope(query, strategyVersion, order) ||
		(cursor.IdentityType != domain.UserProfileIdentityInternal && cursor.IdentityType != domain.UserProfileIdentityExternal) ||
		cursor.IdentityKey == "" || len(cursor.IdentityKey) > 128 {
		return luckyUserProfileListCursor{}, xerr.New(xerr.InvalidArgument, "invalid or mismatched lucky gift user profile cursor")
	}
	return cursor, nil
}

func luckyUserProfileSortValue(profile domain.UserProfile, sortName string) int64 {
	switch sortName {
	case "lifetime_rtp":
		return profile.Lifetime.RTPPPM
	case "lifetime_wager":
		return profile.Lifetime.WagerCoins
	case "lifetime_payout":
		return profile.Lifetime.PayoutCoins
	case "jackpot_count":
		return profile.RTPCompensationJackpotCount + profile.CumulativeSpendJackpotCount
	case "loss_streak":
		return profile.LossStreak
	default:
		return profile.LastDrawAtMS
	}
}

func luckyUserProfileWindow(draws, wager, payout int64) domain.UserProfileWindow {
	window := domain.UserProfileWindow{Draws: draws, WagerCoins: wager, PayoutCoins: payout, NetCoins: payout - wager}
	if ratio, ok := (domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout}).RatioPPM(); ok {
		window.RTPPPM = ratio
		window.HasRTP = true
	}
	return window
}

type luckyUserProfileWindowAggregate struct {
	OneHourDraws, OneHourWager, OneHourPayout          int64
	TwentyFourDraws, TwentyFourWager, TwentyFourPayout int64
	FortyEightDraws, FortyEightWager, FortyEightPayout int64
}

func (r *Repository) loadLuckyGiftUserProfileWindows(ctx context.Context, q luckyRuleQueryer, appCode, poolID, strategyVersion string, profiles []domain.UserProfile, nowMS int64) error {
	if len(profiles) == 0 {
		return nil
	}
	var identitySQL strings.Builder
	args := []any{nowMS - 48*time.Hour.Milliseconds(), nowMS, appCode, poolID, strategyVersion}
	for index, profile := range profiles {
		if index > 0 {
			identitySQL.WriteString(" OR ")
		}
		identitySQL.WriteString("(identity_type = ? AND identity_key = ?)")
		args = append(args, profile.IdentityType, profile.IdentityKey)
	}
	args = append(args,
		nowMS-time.Hour.Milliseconds(), nowMS-time.Hour.Milliseconds(), nowMS-time.Hour.Milliseconds(),
		nowMS-24*time.Hour.Milliseconds(), nowMS-24*time.Hour.Milliseconds(), nowMS-24*time.Hour.Milliseconds(),
	)
	rows, err := q.QueryContext(ctx, `
		SELECT identity_type, identity_key,
		       COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN draw_count ELSE 0 END), 0),
		       COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN wager_coins ELSE 0 END), 0),
		       COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN payout_coins ELSE 0 END), 0),
		       COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN draw_count ELSE 0 END), 0),
		       COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN wager_coins ELSE 0 END), 0),
		       COALESCE(SUM(CASE WHEN occurred_at_ms >= ? THEN payout_coins ELSE 0 END), 0),
		       COALESCE(SUM(draw_count), 0), COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
		FROM lucky_gift_user_profile_events FORCE INDEX (idx_lucky_user_profile_event_window)
		WHERE occurred_at_ms >= ? AND occurred_at_ms < ? AND app_code = ? AND pool_id = ? AND strategy_version = ?
		  AND (`+identitySQL.String()+`)
		GROUP BY identity_type, identity_key`, rotateLuckyUserWindowArgs(args, len(profiles))...)
	if err != nil {
		return err
	}
	defer rows.Close()
	byIdentity := make(map[string]luckyUserProfileWindowAggregate, len(profiles))
	for rows.Next() {
		var identityType, identityKey string
		var aggregate luckyUserProfileWindowAggregate
		if err := rows.Scan(&identityType, &identityKey,
			&aggregate.OneHourDraws, &aggregate.OneHourWager, &aggregate.OneHourPayout,
			&aggregate.TwentyFourDraws, &aggregate.TwentyFourWager, &aggregate.TwentyFourPayout,
			&aggregate.FortyEightDraws, &aggregate.FortyEightWager, &aggregate.FortyEightPayout); err != nil {
			return err
		}
		byIdentity[identityType+"\x00"+identityKey] = aggregate
	}
	if err := rows.Err(); err != nil {
		return err
	}
	for index := range profiles {
		aggregate := byIdentity[profiles[index].IdentityType+"\x00"+profiles[index].IdentityKey]
		profiles[index].OneHour = luckyUserProfileWindow(aggregate.OneHourDraws, aggregate.OneHourWager, aggregate.OneHourPayout)
		profiles[index].TwentyFourHour = luckyUserProfileWindow(aggregate.TwentyFourDraws, aggregate.TwentyFourWager, aggregate.TwentyFourPayout)
		profiles[index].FortyEightHour = luckyUserProfileWindow(aggregate.FortyEightDraws, aggregate.FortyEightWager, aggregate.FortyEightPayout)
	}
	return nil
}

// rotateLuckyUserWindowArgs 将固定窗口参数放到 SELECT 占位符前，再接 WHERE 和当前页身份键。
// 调用方按可读顺序构造参数，此处只做一次机械重排，避免动态 SQL 拼接错位。
func rotateLuckyUserWindowArgs(args []any, profileCount int) []any {
	whereFixed := args[:5]
	identities := args[5 : 5+profileCount*2]
	windowCutoffs := args[5+profileCount*2:]
	out := make([]any, 0, len(args))
	out = append(out, windowCutoffs...)
	out = append(out, whereFixed...)
	out = append(out, identities...)
	return out
}

func applyLuckyGiftUserProfileCurrentControls(rule domain.RuleConfig, profiles []domain.UserProfile) {
	if len(profiles) == 0 {
		return
	}
	for index := range profiles {
		profile := &profiles[index]
		if profile.RuleVersion != rule.RuleVersion {
			// 列表聚合行可能停留在用户最后一次开奖版本。当前规则已切换时不展示旧 token，
			// 与下一次抽奖在用户状态锁内执行的懒过期语义保持一致。
			profile.PendingSpendJackpotTokens = 0
		}
		profile.DownweightFactorPPM = luckyPPMScale
		profile.User24HourRTPThresholdPPM = rule.User24HourRTPThresholdPPM
		if rule.LossStreakGuarantee > profile.LossStreak {
			profile.GuaranteeDrawsRemaining = rule.LossStreakGuarantee - profile.LossStreak
		}
		if profile.StrategyVersion != domain.StrategyDynamicV3 {
			if config, configErr := luckyRuntimeConfigFromRuleConfig(rule); configErr == nil {
				profile.Stage = luckyExperiencePool(profile.EquivalentDraws, config)
			}
		}
		factor := rule.User24HourOrdinaryWinFactorPPM
		if factor <= 0 || factor > luckyPPMScale {
			factor = luckyPPMScale
		}
		profile.DownweightFactorPPM = factor
		profile.DownweightActive = rule.StrategyVersion == domain.StrategyDynamicV3 &&
			rule.User24HourRTPThresholdPPM > 0 && factor < luckyPPMScale &&
			profile.TwentyFourHour.HasRTP && profile.TwentyFourHour.RTPPPM > rule.User24HourRTPThresholdPPM
	}
}

func (r *Repository) GetLuckyGiftUserProfile(ctx context.Context, query domain.UserProfileQuery, nowMS int64) (domain.UserProfileDetail, bool, error) {
	query.Page = 1
	query.PageSize = 1
	query.Stage = ""
	query.Status = ""
	query.SortBy = "last_draw_at"
	// 展开详情仍必须和列表头部画像、倍率分布、大奖总数处于同一快照；加载更多页之间
	// 可以看到新开奖，但单次响应内部不能出现“总数 10、当前页第 11 条”的撕裂数据。
	tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
	if err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	defer func() { _ = tx.Rollback() }()
	profiles, _, _, _, err := r.listLuckyGiftUserProfiles(ctx, tx, query, nowMS)
	if err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	if len(profiles) == 0 {
		return domain.UserProfileDetail{}, false, tx.Commit()
	}
	profile := profiles[0]
	detail := domain.UserProfileDetail{
		Profile: profile, JackpotPage: query.JackpotPage, JackpotPageSize: query.JackpotPageSize,
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT hit_type, multiplier_ppm, draw_count, wager_coins, payout_coins
		FROM lucky_gift_user_multiplier_stats
		WHERE app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?
		ORDER BY hit_type ASC, multiplier_ppm ASC`, profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey)
	if err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	for rows.Next() {
		var stat domain.UserProfileMultiplierStat
		if err := rows.Scan(&stat.HitType, &stat.MultiplierPPM, &stat.DrawCount, &stat.WagerCoins, &stat.PayoutCoins); err != nil {
			_ = rows.Close()
			return domain.UserProfileDetail{}, false, err
		}
		detail.MultiplierDistribution = append(detail.MultiplierDistribution, stat)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return domain.UserProfileDetail{}, false, err
	}
	if err := rows.Close(); err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	if err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM lucky_gift_user_jackpot_hits
		WHERE app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?`,
		profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey).Scan(&detail.JackpotHitTotal); err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	jackpotWhere := `app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?`
	jackpotArgs := []any{profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey}
	if query.JackpotCursor != "" {
		cursor, cursorErr := decodeLuckyUserJackpotCursor(query.JackpotCursor, profile)
		if cursorErr != nil {
			return domain.UserProfileDetail{}, false, cursorErr
		}
		jackpotWhere += ` AND (occurred_at_ms < ? OR (occurred_at_ms = ? AND
		  (source_type < ? OR (source_type = ? AND source_draw_id < ?))))`
		jackpotArgs = append(jackpotArgs, cursor.OccurredAtMS, cursor.OccurredAtMS, cursor.SourceType, cursor.SourceType, cursor.DrawID)
	}
	jackpotArgs = append(jackpotArgs, query.JackpotPageSize+1)
	jackpotRows, err := tx.QueryContext(ctx, `
		SELECT source_type, source_draw_id, request_id, rule_version, stage, mechanism, reason_code, tier_id,
		       multiplier_ppm, wager_coins, payout_coins, occurred_at_ms, conditions_json
		FROM lucky_gift_user_jackpot_hits FORCE INDEX (idx_lucky_user_jackpot_profile)
		WHERE `+jackpotWhere+`
		ORDER BY occurred_at_ms DESC, source_type DESC, source_draw_id DESC
		LIMIT ?`, jackpotArgs...)
	if err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	defer jackpotRows.Close()
	for jackpotRows.Next() {
		var hit domain.UserProfileJackpotHit
		var conditionsJSON []byte
		if err := jackpotRows.Scan(&hit.SourceType, &hit.DrawID, &hit.RequestID, &hit.RuleVersion, &hit.Stage, &hit.Mechanism,
			&hit.ReasonCode, &hit.TierID, &hit.MultiplierPPM, &hit.WagerCoins, &hit.PayoutCoins,
			&hit.OccurredAtMS, &conditionsJSON); err != nil {
			return domain.UserProfileDetail{}, false, err
		}
		var conditions []domain.StrategyConditionTrace
		if err := json.Unmarshal(conditionsJSON, &conditions); err != nil {
			return domain.UserProfileDetail{}, false, err
		}
		for _, condition := range conditions {
			hit.Conditions = append(hit.Conditions, domain.UserProfileJackpotCondition{
				Name: condition.Name, Numerator: condition.Numerator, Denominator: condition.Denominator,
				RatioPPM: condition.RatioPPM, LimitPPM: condition.LimitPPM, FactorPPM: condition.FactorPPM,
				Passed: condition.Passed, Reason: condition.Reason,
			})
		}
		hit.ReasonSummary = luckyUserProfileJackpotReasonSummary(hit.Mechanism)
		detail.JackpotHits = append(detail.JackpotHits, hit)
	}
	if err := jackpotRows.Err(); err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	if err := jackpotRows.Close(); err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	detail.JackpotHasMore = len(detail.JackpotHits) > int(query.JackpotPageSize)
	if detail.JackpotHasMore {
		detail.JackpotHits = detail.JackpotHits[:query.JackpotPageSize]
		last := detail.JackpotHits[len(detail.JackpotHits)-1]
		detail.NextJackpotCursor, err = encodeLuckyUserJackpotCursor(profile, last)
		if err != nil {
			return domain.UserProfileDetail{}, false, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.UserProfileDetail{}, false, err
	}
	return detail, true, nil
}

func luckyUserJackpotCursorScope(profile domain.UserProfile) string {
	canonical := strings.Join([]string{
		profile.AppCode, profile.PoolID, profile.StrategyVersion, profile.IdentityType, profile.IdentityKey,
	}, "\x00")
	sum := sha256.Sum256([]byte(canonical))
	return fmt.Sprintf("%x", sum[:])
}

func encodeLuckyUserJackpotCursor(profile domain.UserProfile, hit domain.UserProfileJackpotHit) (string, error) {
	payload, err := json.Marshal(luckyUserJackpotCursor{
		Version: 1, ScopeHash: luckyUserJackpotCursorScope(profile), OccurredAtMS: hit.OccurredAtMS,
		SourceType: hit.SourceType, DrawID: hit.DrawID,
	})
	if err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(payload), nil
}

func decodeLuckyUserJackpotCursor(raw string, profile domain.UserProfile) (luckyUserJackpotCursor, error) {
	payload, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(raw))
	if err != nil {
		return luckyUserJackpotCursor{}, xerr.New(xerr.InvalidArgument, "invalid lucky gift jackpot cursor")
	}
	var cursor luckyUserJackpotCursor
	if json.Unmarshal(payload, &cursor) != nil || cursor.Version != 1 ||
		cursor.ScopeHash != luckyUserJackpotCursorScope(profile) || cursor.OccurredAtMS < 0 ||
		cursor.SourceType == "" || len(cursor.SourceType) > 24 || cursor.DrawID == "" || len(cursor.DrawID) > 128 {
		return luckyUserJackpotCursor{}, xerr.New(xerr.InvalidArgument, "invalid or mismatched lucky gift jackpot cursor")
	}
	return cursor, nil
}

func luckyUserProfileJackpotReasonSummary(mechanism string) string {
	switch mechanism {
	case domain.UserProfileHitRTPCompensation:
		return "用户满足补偿大奖的 RTP、成熟度、每日次数和奖池支付条件，随后在独立大奖奖档中命中。"
	case domain.UserProfileHitSpendMilestone:
		return "用户已有累计消费大奖资格，RTP、每日次数和奖池支付条件通过，本次命中并消耗一个资格。"
	default:
		return "本次由独立大奖路径命中；下方条件保留了开奖当刻的真实判断结果。"
	}
}

// RefreshLuckyGiftUserProfiles 用三个独立游标回灌内部逐抽、外部逐抽和外部旧聚合事实。
// 游标从 0 开始且每批有上限；运营查询永远只读画像表，不会因为历史回灌扫描事实表。
func (r *Repository) RefreshLuckyGiftUserProfiles(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
	}
	apps, err := r.listLuckyGiftUserProfileSourceApps(ctx)
	if err != nil {
		return 0, err
	}
	if app := strings.TrimSpace(appcode.FromContext(ctx)); app != "" {
		apps[appcode.Normalize(app)] = struct{}{}
	}
	appCodes := make([]string, 0, len(apps))
	for app := range apps {
		appCodes = append(appCodes, app)
	}
	sort.Strings(appCodes)
	processed := 0
	for _, app := range appCodes {
		count, err := r.refreshLuckyGiftUserProfileInternal(ctx, app, nowMS, batchSize)
		if err != nil {
			return processed, err
		}
		processed += count
		count, err = r.refreshLuckyGiftUserProfileExternalItems(ctx, app, nowMS, batchSize)
		if err != nil {
			return processed, err
		}
		processed += count
		count, err = r.refreshLuckyGiftUserProfileExternalLegacy(ctx, app, nowMS, batchSize)
		if err != nil {
			return processed, err
		}
		processed += count
	}
	return processed, nil
}

func (r *Repository) listLuckyGiftUserProfileSourceApps(ctx context.Context) (map[string]struct{}, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code FROM lucky_gift_rule_versions GROUP BY app_code
		UNION
		SELECT app_code FROM lucky_gift_user_profiles GROUP BY app_code
		UNION
		SELECT app_code FROM lucky_draw_pool_stat_cursors
		WHERE cursor_name IN (?, ?, ?)
		GROUP BY app_code`, luckyUserProfileCursorInternal, luckyUserProfileCursorExternalItem, luckyUserProfileCursorExternalLegacy)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	apps := make(map[string]struct{})
	for rows.Next() {
		var app string
		if err := rows.Scan(&app); err != nil {
			return nil, err
		}
		if app = appcode.Normalize(app); app != "" {
			apps[app] = struct{}{}
		}
	}
	return apps, rows.Err()
}

func (r *Repository) lockLuckyGiftUserProfileCursor(ctx context.Context, tx *sql.Tx, appCode, cursorName string, nowMS int64) (luckyDrawStatsCursor, error) {
	if _, err := tx.ExecContext(ctx, `
		INSERT IGNORE 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 (?, ?, 0, '', ?, ?)`, appCode, cursorName, nowMS, nowMS); err != nil {
		return luckyDrawStatsCursor{}, err
	}
	var cursor luckyDrawStatsCursor
	if 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 = ?
		FOR UPDATE`, appCode, cursorName).Scan(&cursor.LastDrawCreatedAtMS, &cursor.LastDrawID); err != nil {
		return luckyDrawStatsCursor{}, err
	}
	return cursor, nil
}

func (r *Repository) advanceLuckyGiftUserProfileCursor(ctx context.Context, tx *sql.Tx, appCode, cursorName string, cursor luckyDrawStatsCursor, nowMS int64) error {
	_, 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 = ?`, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, nowMS, appCode, cursorName)
	return err
}

func (r *Repository) refreshLuckyGiftUserProfileInternal(ctx context.Context, appCode string, nowMS int64, batchSize int) (int, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()
	cursor, err := r.lockLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorInternal, nowMS)
	if err != nil {
		return 0, err
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT draw_id, command_id, user_id, pool_id, rule_version, experience_pool, selected_tier_id,
		       coin_spent, base_reward_coins, effective_reward_coins, high_multiplier,
		       candidate_tiers_json, reward_status, paid_at_ms, 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
	}
	facts := make([]luckyUserProfileFact, 0, batchSize)
	last := cursor
	for rows.Next() {
		var fact luckyUserProfileFact
		var userID int64
		var highMultiplier bool
		var snapshot []byte
		if err := rows.Scan(&fact.SourceDrawID, &fact.RequestID, &userID, &fact.PoolID, &fact.RuleVersion,
			&fact.Stage, &fact.TierID, &fact.WagerCoins, &fact.BaseRewardCoins, &fact.PayoutCoins,
			&highMultiplier, &snapshot, &fact.RewardStatus, &fact.OccurredAtMS, &fact.CreatedAtMS); err != nil {
			_ = rows.Close()
			return 0, err
		}
		fact.AppCode = appCode
		fact.SourceType = luckyUserProfileSourceInternal
		fact.IdentityType = domain.UserProfileIdentityInternal
		fact.IdentityKey = strconv.FormatInt(userID, 10)
		fact.StrategyUserID = userID
		fact.InternalUserID = userID
		fact.DrawCount = 1
		fact.HighOrdinary = highMultiplier
		fact.MultiplierPPM = luckyUserProfileMultiplierFromSnapshot(snapshot)
		fact = luckyUserProfileFactFromSnapshot(fact, snapshot)
		facts = append(facts, fact)
		last = luckyDrawStatsCursor{LastDrawCreatedAtMS: fact.CreatedAtMS, LastDrawID: fact.SourceDrawID}
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return 0, err
	}
	if err := rows.Close(); err != nil {
		return 0, err
	}
	if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS); err != nil {
		return 0, err
	}
	if len(facts) > 0 {
		if err := r.advanceLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorInternal, last, nowMS); err != nil {
			return 0, err
		}
	}
	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return len(facts), nil
}

func luckyUserProfileMultiplierFromSnapshot(snapshot []byte) int64 {
	var data struct {
		MultiplierPPM int64 `json:"multiplier_ppm"`
	}
	_ = json.Unmarshal(snapshot, &data)
	return data.MultiplierPPM
}

func (r *Repository) refreshLuckyGiftUserProfileExternalItems(ctx context.Context, appCode string, nowMS int64, batchSize int) (int, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()
	cursor, err := r.lockLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalItem, nowMS)
	if err != nil {
		return 0, err
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT draw_id, request_id, external_user_id, pool_id, rule_version, stage, selected_tier_id,
		       unit_amount, reward_amount, multiplier_ppm, candidate_snapshot_json, reward_status,
		       paid_at_ms, created_at_ms
		FROM external_lucky_gift_draw_items FORCE INDEX (idx_external_lucky_item_profile_cursor)
		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
	}
	facts := make([]luckyUserProfileFact, 0, batchSize)
	last := cursor
	for rows.Next() {
		var fact luckyUserProfileFact
		var snapshot []byte
		if err := rows.Scan(&fact.SourceDrawID, &fact.RequestID, &fact.ExternalUserID, &fact.PoolID,
			&fact.RuleVersion, &fact.Stage, &fact.TierID, &fact.WagerCoins, &fact.PayoutCoins,
			&fact.MultiplierPPM, &snapshot, &fact.RewardStatus, &fact.OccurredAtMS, &fact.CreatedAtMS); err != nil {
			_ = rows.Close()
			return 0, err
		}
		fact.AppCode = appCode
		fact.SourceType = luckyUserProfileSourceExternalItem
		fact.IdentityType = domain.UserProfileIdentityExternal
		fact.IdentityKey = fact.ExternalUserID
		fact.StrategyUserID = externalLuckyUserID(appCode, fact.ExternalUserID)
		fact.DrawCount = 1
		fact.BaseRewardCoins = fact.PayoutCoins
		fact = luckyUserProfileFactFromSnapshot(fact, snapshot)
		facts = append(facts, fact)
		last = luckyDrawStatsCursor{LastDrawCreatedAtMS: fact.CreatedAtMS, LastDrawID: fact.SourceDrawID}
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return 0, err
	}
	if err := rows.Close(); err != nil {
		return 0, err
	}
	if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS); err != nil {
		return 0, err
	}
	if len(facts) > 0 {
		if err := r.advanceLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalItem, last, nowMS); err != nil {
			return 0, err
		}
	}
	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return len(facts), nil
}

func (r *Repository) refreshLuckyGiftUserProfileExternalLegacy(ctx context.Context, appCode string, nowMS int64, batchSize int) (int, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()
	cursor, err := r.lockLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalLegacy, nowMS)
	if err != nil {
		return 0, err
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT d.draw_id, d.request_id, d.external_user_id, d.pool_id, d.gift_count, d.total_amount,
		       d.reward_amount, d.multiplier_ppm, d.reward_status, d.rule_version, d.selected_tier_id,
		       d.paid_at_ms, d.created_at_ms, COALESCE(rv.strategy_version, 'fixed_v2'),
		       EXISTS(SELECT 1 FROM external_lucky_gift_draw_items i
		              WHERE i.app_code = d.app_code AND i.request_id = d.request_id LIMIT 1)
		FROM external_lucky_gift_draws d FORCE INDEX (idx_external_lucky_profile_cursor)
		LEFT JOIN lucky_gift_rule_versions rv
		  ON rv.app_code = d.app_code AND rv.pool_id = d.pool_id AND rv.rule_version = d.rule_version
		WHERE d.app_code = ? AND (d.created_at_ms > ? OR (d.created_at_ms = ? AND d.draw_id > ?))
		ORDER BY d.created_at_ms ASC, d.draw_id ASC
		LIMIT ?`, appCode, cursor.LastDrawCreatedAtMS, cursor.LastDrawCreatedAtMS, cursor.LastDrawID, batchSize)
	if err != nil {
		return 0, err
	}
	facts := make([]luckyUserProfileFact, 0, batchSize)
	last := cursor
	rowCount := 0
	for rows.Next() {
		var fact luckyUserProfileFact
		var hasItems bool
		if err := rows.Scan(&fact.SourceDrawID, &fact.RequestID, &fact.ExternalUserID, &fact.PoolID,
			&fact.DrawCount, &fact.WagerCoins, &fact.PayoutCoins, &fact.MultiplierPPM, &fact.RewardStatus,
			&fact.RuleVersion, &fact.TierID, &fact.OccurredAtMS, &fact.CreatedAtMS, &fact.StrategyVersion, &hasItems); err != nil {
			_ = rows.Close()
			return 0, err
		}
		rowCount++
		last = luckyDrawStatsCursor{LastDrawCreatedAtMS: fact.CreatedAtMS, LastDrawID: fact.SourceDrawID}
		if hasItems {
			continue
		}
		fact.AppCode = appCode
		fact.SourceType = luckyUserProfileSourceExternalLegacy
		fact.IdentityType = domain.UserProfileIdentityExternal
		fact.IdentityKey = fact.ExternalUserID
		fact.StrategyUserID = externalLuckyUserID(appCode, fact.ExternalUserID)
		if fact.StrategyVersion == domain.StrategyFixedV2 {
			// fixed_v2 的外部聚合行无论 gift_count 多大都只随机一次；dynamic_v3 才是逐份开奖。
			// dynamic_v3 正常会被 item 游标消费，这个分支只为兼容极早期缺 item 的聚合事实。
			fact.DrawCount = 1
		}
		fact.BaseRewardCoins = fact.PayoutCoins
		fact.HitType = luckyUserProfileHitType(fact.PayoutCoins, "", nil, nil)
		// 旧外部聚合事实没有 high_multiplier 审计列，保持 false；不能自行发明倍率阈值补猜。
		fact.HighOrdinary = false
		facts = append(facts, fact)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return 0, err
	}
	if err := rows.Close(); err != nil {
		return 0, err
	}
	if err := r.persistLuckyGiftUserProfileFacts(ctx, tx, facts, nowMS); err != nil {
		return 0, err
	}
	if rowCount > 0 {
		if err := r.advanceLuckyGiftUserProfileCursor(ctx, tx, appCode, luckyUserProfileCursorExternalLegacy, last, nowMS); err != nil {
			return 0, err
		}
	}
	if err := tx.Commit(); err != nil {
		return 0, err
	}
	return rowCount, nil
}

type luckyUserProfileRewardStatusDelta struct {
	Pending int64
	Granted int64
	Failed  int64
}

// migrateLuckyGiftUserProfileRewardStatuses 与 lucky_draw_records 的终态更新共用事务。
// 它先锁画像事件并记住旧状态，再原子迁移三个计数，避免初写 pending 的中奖在画像中永久停留。
func (r *Repository) migrateLuckyGiftUserProfileRewardStatuses(ctx context.Context, tx *sql.Tx, appCode string, drawIDs []string, status string, onlyPending bool, nowMS int64) error {
	if len(drawIDs) == 0 {
		return nil
	}
	args := []any{appCode}
	for _, drawID := range drawIDs {
		args = append(args, drawID)
	}
	query := `
		SELECT pool_id, strategy_version, identity_type, identity_key, reward_status, draw_count
		FROM lucky_gift_user_profile_events
		WHERE app_code = ? AND source_type = 'internal'
		  AND source_draw_id IN (` + luckySQLPlaceholders(len(drawIDs)) + `)
		  AND reward_status <> ?`
	args = append(args, status)
	if onlyPending {
		query += ` AND reward_status = ?`
		args = append(args, domain.StatusPending)
	}
	query += ` FOR UPDATE`
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return err
	}
	deltas := make(map[luckyUserProfileKey]*luckyUserProfileRewardStatusDelta)
	for rows.Next() {
		var key luckyUserProfileKey
		var oldStatus string
		var drawCount int64
		key.AppCode = appCode
		if err := rows.Scan(&key.PoolID, &key.StrategyVersion, &key.IdentityType, &key.IdentityKey, &oldStatus, &drawCount); err != nil {
			_ = rows.Close()
			return err
		}
		delta := deltas[key]
		if delta == nil {
			delta = &luckyUserProfileRewardStatusDelta{}
			deltas[key] = delta
		}
		luckyUserProfileAddRewardStatusDelta(delta, oldStatus, -drawCount)
		luckyUserProfileAddRewardStatusDelta(delta, status, drawCount)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	if len(deltas) == 0 {
		return nil
	}
	updateArgs := []any{status, appCode}
	for _, drawID := range drawIDs {
		updateArgs = append(updateArgs, drawID)
	}
	updateQuery := `
		UPDATE lucky_gift_user_profile_events
		SET reward_status = ?
		WHERE app_code = ? AND source_type = 'internal'
		  AND source_draw_id IN (` + luckySQLPlaceholders(len(drawIDs)) + `)
		  AND reward_status <> ?`
	updateArgs = append(updateArgs, status)
	if onlyPending {
		updateQuery += ` AND reward_status = ?`
		updateArgs = append(updateArgs, domain.StatusPending)
	}
	if _, err := tx.ExecContext(ctx, updateQuery, updateArgs...); err != nil {
		return err
	}
	for key, delta := range deltas {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_gift_user_profiles
			SET pending_draw_count = GREATEST(pending_draw_count + ?, 0),
			    granted_draw_count = GREATEST(granted_draw_count + ?, 0),
			    failed_draw_count = GREATEST(failed_draw_count + ?, 0),
			    aggregated_at_ms = GREATEST(aggregated_at_ms, ?), updated_at_ms = ?
			WHERE app_code = ? AND pool_id = ? AND strategy_version = ? AND identity_type = ? AND identity_key = ?`,
			delta.Pending, delta.Granted, delta.Failed, nowMS, nowMS,
			key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey); err != nil {
			return err
		}
	}
	return nil
}

func luckyUserProfileAddRewardStatusDelta(delta *luckyUserProfileRewardStatusDelta, status string, value int64) {
	if delta == nil {
		return
	}
	switch status {
	case domain.StatusPending:
		delta.Pending += value
	case domain.StatusFailed:
		delta.Failed += value
	default:
		delta.Granted += value
	}
}
