package mysql

import (
	"context"
	"database/sql"
	"fmt"
	"strings"
	"time"

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

// luckyDynamicRiskCounter 是 dynamic_v3 在事务内锁定的单个风控窗口。
// scope_id 始终带 pool_id 前缀，防止同一用户在普通幸运与超级幸运池之间错误共享额度。
type luckyDynamicRiskCounter struct {
	ScopeType  string
	ScopeID    string
	WindowType string
	BucketKey  string
	Payout     int64
}

type luckyUserStrategyDay struct {
	StatDay          string
	WagerCoins       int64
	PayoutCoins      int64
	SpendCoins       int64
	SpendRuleVersion int64
	JackpotHits      int64
}

type luckyUserRTPHour struct {
	BucketHourMS int64
	WagerCoins   int64
	PayoutCoins  int64
}

// luckyUserRTPWindow is the user's money aggregate for one platform settlement window.
// qualification_status starts as pending while the platform window is open. It is evaluated lazily
// on that user's next paid draw after the platform window closes, avoiding a full-user scan at reset.
type luckyUserRTPWindow struct {
	PoolID                string
	RuleVersion           int64
	WindowIndex           int64
	UserID                int64
	UserRegisteredAtMS    int64
	WagerCoins            int64
	PayoutCoins           int64
	Rolling48hWagerCoins  int64
	Rolling48hPayoutCoins int64
	QualificationStatus   string
	AttemptedDrawID       string
	EvaluatedAtMS         int64
}

func (r *Repository) getOrCreateLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS int64) (luckyUserRTPWindow, error) {
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_user_rtp_windows (
		  app_code, pool_id, rule_version, window_index, user_id, user_registered_at_ms,
		  wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins,
		  qualification_status, attempted_draw_id, evaluated_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 'pending', '', 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, poolID, ruleVersion, windowIndex, userID, userRegisteredAtMS, nowMS, nowMS,
	); err != nil {
		return luckyUserRTPWindow{}, err
	}
	state := luckyUserRTPWindow{PoolID: poolID, RuleVersion: ruleVersion, WindowIndex: windowIndex, UserID: userID}
	err := tx.QueryRowContext(ctx, `
		SELECT user_registered_at_ms, wager_coins, payout_coins, rolling_48h_wager_coins, rolling_48h_payout_coins,
		       qualification_status, attempted_draw_id, evaluated_at_ms
		FROM lucky_user_rtp_windows
		WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?
		FOR UPDATE`, appCode, poolID, ruleVersion, windowIndex, userID,
	).Scan(&state.UserRegisteredAtMS, &state.WagerCoins, &state.PayoutCoins, &state.Rolling48hWagerCoins, &state.Rolling48hPayoutCoins,
		&state.QualificationStatus, &state.AttemptedDrawID, &state.EvaluatedAtMS)
	return state, err
}

// getLatestPendingLuckyUserRTPWindow returns at most one closed qualification. A user cannot bank
// multiple compensation attempts: if historical pending rows exist, only the newest is retained and
// older rows are expired in the same transaction before the next paid draw is selected.
func (r *Repository) getLatestPendingLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, beforeWindowIndex, userID int64, globalScopeID string, nowMS int64) (luckyUserRTPWindow, luckyRTPWindow, bool, error) {
	var userWindow luckyUserRTPWindow
	userWindow.PoolID = poolID
	userWindow.RuleVersion = ruleVersion
	userWindow.UserID = userID
	var global luckyRTPWindow
	err := tx.QueryRowContext(ctx, `
		SELECT u.window_index, u.user_registered_at_ms, u.wager_coins, u.payout_coins,
		       u.rolling_48h_wager_coins, u.rolling_48h_payout_coins,
		       u.qualification_status, u.attempted_draw_id, u.evaluated_at_ms,
		       g.window_index, g.scope_type, g.scope_id, g.target_rtp_ppm, g.control_window_draws,
		       g.paid_draws, g.wager_coins, g.target_payout_coins, g.actual_base_payout_coins,
		       g.carry_ppm, g.status, g.started_at_ms, g.last_paid_at_ms, g.closed_at_ms
		FROM lucky_user_rtp_windows u
		JOIN lucky_rtp_windows g
		  ON g.app_code = u.app_code AND g.scope_type = 'pool' AND g.scope_id = ?
		 AND g.window_index = u.window_index AND g.status = 'closed'
		WHERE u.app_code = ? AND u.pool_id = ? AND u.rule_version = ? AND u.user_id = ?
		  AND u.qualification_status = 'pending' AND u.window_index < ?
		ORDER BY u.window_index DESC
		LIMIT 1
		FOR UPDATE`, globalScopeID, appCode, poolID, ruleVersion, userID, beforeWindowIndex,
	).Scan(&userWindow.WindowIndex, &userWindow.UserRegisteredAtMS, &userWindow.WagerCoins, &userWindow.PayoutCoins,
		&userWindow.Rolling48hWagerCoins, &userWindow.Rolling48hPayoutCoins,
		&userWindow.QualificationStatus, &userWindow.AttemptedDrawID, &userWindow.EvaluatedAtMS,
		// JOIN 的全局轮次必须显式扫回；若只读取用户轮次，global 默认 0 会让所有正常闭窗资格
		// 在 prepare 对齐校验中失败并回滚下一次送礼。
		&global.WindowIndex, &global.ScopeType, &global.ScopeID, &global.TargetRTPPPM, &global.ControlDraws,
		&global.PaidDraws, &global.WagerCoins, &global.TargetPayoutCoins, &global.ActualPayoutCoins,
		&global.CarryPPM, &global.Status, &global.StartedAtMS, &global.LastPaidAtMS, &global.ClosedAtMS)
	if err != nil {
		if err == sql.ErrNoRows {
			return luckyUserRTPWindow{}, luckyRTPWindow{}, false, nil
		}
		return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_rtp_windows
		SET qualification_status = 'expired', evaluated_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND user_id = ?
		  AND qualification_status = 'pending' AND window_index < ?`,
		nowMS, nowMS, appCode, poolID, ruleVersion, userID, userWindow.WindowIndex,
	); err != nil {
		return luckyUserRTPWindow{}, luckyRTPWindow{}, false, err
	}
	return userWindow, global, true, nil
}

// getLatestClosedLuckyDynamicRTPWindow returns the platform sample used only by the durable spend
// strategy. It intentionally does not join a user window: a user may have been inactive throughout
// the latest platform round. Legacy rows without an exact start/close boundary are excluded, because
// treating an administrative migration close as a settled sample could manufacture jackpot access.
func (r *Repository) getLatestClosedLuckyDynamicRTPWindow(ctx context.Context, tx *sql.Tx, appCode, globalScopeID string, beforeWindowIndex int64) (luckyRTPWindow, bool, error) {
	var window luckyRTPWindow
	err := tx.QueryRowContext(ctx, `
		SELECT scope_type, scope_id, window_index, target_rtp_ppm, control_window_draws,
		       paid_draws, wager_coins, target_payout_coins, actual_base_payout_coins,
		       carry_ppm, status, started_at_ms, last_paid_at_ms, closed_at_ms
		FROM lucky_rtp_windows
		WHERE app_code = ? AND scope_type = 'pool' AND scope_id = ? AND status = 'closed'
		  AND window_index < ? AND wager_coins > 0 AND started_at_ms > 0 AND closed_at_ms > 0
		ORDER BY window_index DESC
		LIMIT 1`, appCode, globalScopeID, beforeWindowIndex,
	).Scan(&window.ScopeType, &window.ScopeID, &window.WindowIndex, &window.TargetRTPPPM, &window.ControlDraws,
		&window.PaidDraws, &window.WagerCoins, &window.TargetPayoutCoins, &window.ActualPayoutCoins,
		&window.CarryPPM, &window.Status, &window.StartedAtMS, &window.LastPaidAtMS, &window.ClosedAtMS)
	if err == sql.ErrNoRows {
		return luckyRTPWindow{}, false, nil
	}
	if err != nil {
		return luckyRTPWindow{}, false, err
	}
	return window, true, nil
}

func (r *Repository) persistLuckyUserRTPWindow(ctx context.Context, tx *sql.Tx, appCode string, state luckyUserRTPWindow, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_rtp_windows
		SET wager_coins = ?, payout_coins = ?,
		    rolling_48h_wager_coins = ?, rolling_48h_payout_coins = ?,
		    qualification_status = ?, attempted_draw_id = ?, evaluated_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND pool_id = ? AND rule_version = ? AND window_index = ? AND user_id = ?`,
		state.WagerCoins, state.PayoutCoins, state.Rolling48hWagerCoins, state.Rolling48hPayoutCoins,
		state.QualificationStatus, state.AttemptedDrawID, state.EvaluatedAtMS, nowMS,
		appCode, state.PoolID, state.RuleVersion, state.WindowIndex, state.UserID,
	)
	return err
}

func luckyPaidTime(cmd domain.DrawCommand, nowMS int64) time.Time {
	ms := cmd.PaidAtMS
	if ms <= 0 {
		ms = nowMS
	}
	return time.UnixMilli(ms).UTC()
}

func (r *Repository) getLuckyDynamicRiskCounters(ctx context.Context, tx *sql.Tx, appCode, poolID string, cmd domain.DrawCommand, nowMS int64) (map[string]luckyDynamicRiskCounter, error) {
	paidAt := luckyPaidTime(cmd, nowMS)
	hour := paidAt.Format("2006-01-02T15")
	day := paidAt.Format("2006-01-02")
	specs := []luckyDynamicRiskCounter{
		{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "hour", BucketKey: hour},
		{ScopeType: "user", ScopeID: luckyPoolScopedID(poolID, fmt.Sprintf("%d", cmd.UserID)), WindowType: "day", BucketKey: day},
		{ScopeType: "room", ScopeID: luckyPoolScopedID(poolID, cmd.RoomID), WindowType: "hour", BucketKey: hour},
		{ScopeType: "anchor", ScopeID: luckyPoolScopedID(poolID, cmd.AnchorID), WindowType: "day", BucketKey: day},
	}
	if cmd.DeviceID != "" {
		// 空设备不能聚合到一个共享的空 scope，更不能用会话/命令临时值拆出可无限刷新的 scope。
		specs = append(specs, luckyDynamicRiskCounter{
			ScopeType: "device", ScopeID: luckyPoolScopedID(poolID, cmd.DeviceID), WindowType: "day", BucketKey: day,
		})
	}
	out := make(map[string]luckyDynamicRiskCounter, len(specs))
	for _, spec := range specs {
		counter, err := r.getOrCreateLuckyDynamicRiskCounter(ctx, tx, appCode, spec, nowMS)
		if err != nil {
			return nil, err
		}
		out[luckyDynamicRiskCounterKey(spec.ScopeType, spec.WindowType)] = counter
	}
	return out, nil
}

func (r *Repository) getOrCreateLuckyDynamicRiskCounter(ctx context.Context, tx *sql.Tx, appCode string, spec luckyDynamicRiskCounter, nowMS int64) (luckyDynamicRiskCounter, error) {
	// 幂等建行先取得唯一键写锁，消除两个首次请求都读到 no rows 后其中一个 duplicate-key 失败的竞态。
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_risk_counters (
		  app_code, scope_type, scope_id, window_type, bucket_key, payout_coins, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey, nowMS, nowMS,
	); err != nil {
		return luckyDynamicRiskCounter{}, err
	}
	err := tx.QueryRowContext(ctx, `
		SELECT payout_coins
		FROM lucky_risk_counters
		WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?
		FOR UPDATE`,
		appCode, spec.ScopeType, spec.ScopeID, spec.WindowType, spec.BucketKey,
	).Scan(&spec.Payout)
	return spec, err
}

func luckyDynamicRiskCapacity(config domain.Config, counters map[string]luckyDynamicRiskCounter) domain.StrategyRiskCapacity {
	// 每个值都是“本抽还能支付多少”，而不是配置总额；0 是真实硬锁，不能被解释为 unlimited。
	deviceCounter, hasDeviceCounter := counters[luckyDynamicRiskCounterKey("device", "day")]
	return domain.StrategyRiskCapacity{
		Enabled:         true,
		SkipDeviceDay:   !hasDeviceCounter,
		SingleDrawCoins: config.MaxSinglePayout,
		UserHourCoins:   maxInt64(0, config.UserHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "hour")].Payout),
		UserDayCoins:    maxInt64(0, config.UserDailyPayoutCap-counters[luckyDynamicRiskCounterKey("user", "day")].Payout),
		DeviceDayCoins:  maxInt64(0, config.DeviceDailyPayoutCap-deviceCounter.Payout),
		RoomHourCoins:   maxInt64(0, config.RoomHourlyPayoutCap-counters[luckyDynamicRiskCounterKey("room", "hour")].Payout),
		AnchorDayCoins:  maxInt64(0, config.AnchorDailyPayoutCap-counters[luckyDynamicRiskCounterKey("anchor", "day")].Payout),
	}
}

func (r *Repository) addLuckyDynamicRiskPayout(ctx context.Context, tx *sql.Tx, appCode string, counters map[string]luckyDynamicRiskCounter, payout, nowMS int64) error {
	if payout <= 0 {
		return nil
	}
	for _, counter := range counters {
		if _, err := tx.ExecContext(ctx, `
			UPDATE lucky_risk_counters
			SET payout_coins = payout_coins + ?, updated_at_ms = ?
			WHERE app_code = ? AND scope_type = ? AND scope_id = ? AND window_type = ? AND bucket_key = ?`,
			payout, nowMS, appCode, counter.ScopeType, counter.ScopeID, counter.WindowType, counter.BucketKey,
		); err != nil {
			return err
		}
	}
	return nil
}

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

// countLuckyJackpotHitsInWindow reads the bounded app+pool jackpot ledger after the caller has
// locked the pool row. That shared lock serializes competing draw decisions, while the composite
// time index keeps the query proportional to jackpots inside this short window rather than draws.
func (r *Repository) countLuckyJackpotHitsInWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, endMS, windowMinutes int64) (int64, error) {
	if windowMinutes <= 0 || windowMinutes > 24*60 {
		return 0, fmt.Errorf("lucky jackpot rate window minutes must be within 1..1440")
	}
	windowMS := windowMinutes * int64(time.Minute/time.Millisecond)
	startMS := endMS - windowMS
	if startMS < 0 {
		startMS = 0
	}
	var count int64
	err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM lucky_gift_user_jackpot_hits FORCE INDEX (idx_lucky_jackpot_rate_window)
		WHERE app_code = ? AND pool_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
		appCode, poolID, startMS, luckyExclusiveEndMS(endMS),
	).Scan(&count)
	return count, err
}

func (r *Repository) getOrCreateLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserStrategyDay, error) {
	day := luckyPaidTime(cmd, nowMS).Format("2006-01-02")
	state := luckyUserStrategyDay{StatDay: day}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_user_strategy_days (
		  app_code, pool_id, user_id, stat_day, wager_coins, payout_coins, spend_coins,
		  spend_rule_version, jackpot_hits, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, 0, 0, ?, 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, poolID, userID, day, ruleVersion, nowMS, nowMS,
	); err != nil {
		return luckyUserStrategyDay{}, err
	}
	err := tx.QueryRowContext(ctx, `
		SELECT wager_coins, payout_coins, spend_coins, spend_rule_version, jackpot_hits
		FROM lucky_user_strategy_days
		WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?
		FOR UPDATE`, appCode, poolID, userID, day,
	).Scan(&state.WagerCoins, &state.PayoutCoins, &state.SpendCoins, &state.SpendRuleVersion, &state.JackpotHits)
	if err == nil && state.SpendRuleVersion != ruleVersion {
		// 每日累计消费只服务于产生当前规则版本 token。规则发布后清零旧版本进度，但保留
		// wager/payout/jackpot_hits，防止换版本同时重置用户 RTP 与当天大奖额度。
		state.SpendCoins = 0
		state.SpendRuleVersion = ruleVersion
	}
	return state, err
}

func (r *Repository) persistLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserStrategyDay, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_strategy_days
		SET wager_coins = ?, payout_coins = ?, spend_coins = ?, spend_rule_version = ?, jackpot_hits = ?, updated_at_ms = ?
		WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?`,
		state.WagerCoins, state.PayoutCoins, state.SpendCoins, state.SpendRuleVersion, state.JackpotHits, nowMS,
		appCode, poolID, userID, state.StatDay,
	)
	return err
}

func (r *Repository) getOrCreateLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserRTPHour, error) {
	paidAt := luckyPaidTime(cmd, nowMS)
	bucketHourMS := paidAt.Truncate(time.Hour).UnixMilli()
	state := luckyUserRTPHour{BucketHourMS: bucketHourMS}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO lucky_user_rtp_hour_buckets (
		  app_code, pool_id, user_id, bucket_hour_ms, wager_coins, payout_coins, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
		appCode, poolID, userID, bucketHourMS, nowMS, nowMS,
	); err != nil {
		return luckyUserRTPHour{}, err
	}
	err := tx.QueryRowContext(ctx, `
		SELECT wager_coins, payout_coins
		FROM lucky_user_rtp_hour_buckets
		WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?
		FOR UPDATE`, appCode, poolID, userID, bucketHourMS,
	).Scan(&state.WagerCoins, &state.PayoutCoins)
	return state, err
}

func (r *Repository) getLuckyUserRolling48HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
	return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 48)
}

func (r *Repository) getLuckyUserRolling24HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
	return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 24)
}

// getLuckyUserRolling72HourRTP remains only for old in-package callers during rolling source upgrades.
func (r *Repository) getLuckyUserRolling72HourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS int64) (domain.StrategyRTP, error) {
	return r.getLuckyUserRollingHourRTP(ctx, tx, appCode, poolID, userID, endMS, 72)
}

func (r *Repository) getLuckyUserRollingHourRTP(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, endMS, hours int64) (domain.StrategyRTP, error) {
	// 严格口径是 [end-hours,end)，不能把“当前小时+前 N 个整点桶”近似成滚动窗口。
	// 完整小时从聚合桶读取；头尾两个不足一小时的片段从专用边界事件读取，避免回扫 lucky_draw_records。
	const hourMS = int64(time.Hour / time.Millisecond)
	startMS := endMS - hours*hourMS
	if hours <= 0 || startMS < 0 || endMS <= 0 {
		return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP range is invalid")
	}
	fullStartMS := startMS
	if remainder := startMS % hourMS; remainder != 0 {
		fullStartMS += hourMS - remainder
	}
	fullEndMS := endMS - endMS%hourMS
	var state domain.StrategyRTP
	if fullStartMS < fullEndMS {
		if err := tx.QueryRowContext(ctx, `
			SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
			FROM lucky_user_rtp_hour_buckets
			WHERE app_code = ? AND pool_id = ? AND user_id = ?
			  AND bucket_hour_ms >= ? AND bucket_hour_ms < ?`,
			appCode, poolID, userID, fullStartMS, fullEndMS,
		).Scan(&state.WagerCoins, &state.PayoutCoins); err != nil {
			return domain.StrategyRTP{}, err
		}
	}
	for _, boundary := range [][2]int64{{startMS, fullStartMS}, {fullEndMS, endMS}} {
		if boundary[0] >= boundary[1] {
			continue
		}
		var wager, payout int64
		if err := tx.QueryRowContext(ctx, `
			SELECT COALESCE(SUM(wager_coins), 0), COALESCE(SUM(payout_coins), 0)
			FROM lucky_user_rtp_boundary_events FORCE INDEX (idx_lucky_user_rtp_boundary_range)
			WHERE app_code = ? AND pool_id = ? AND user_id = ?
			  AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
			appCode, poolID, userID, boundary[0], boundary[1],
		).Scan(&wager, &payout); err != nil {
			return domain.StrategyRTP{}, err
		}
		if state.WagerCoins > int64(^uint64(0)>>1)-wager || state.PayoutCoins > int64(^uint64(0)>>1)-payout {
			return domain.StrategyRTP{}, fmt.Errorf("dynamic lucky gift rolling RTP overflow")
		}
		state.WagerCoins += wager
		state.PayoutCoins += payout
	}
	return state, nil
}

func (r *Repository) insertLuckyUserRTPBoundaryEvents(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, records []luckyDrawRecordInput, nowMS int64) error {
	const chunkSize = 200
	for start := 0; start < len(records); start += chunkSize {
		end := start + chunkSize
		if end > len(records) {
			end = len(records)
		}
		var query strings.Builder
		query.WriteString(`
			INSERT INTO lucky_user_rtp_boundary_events (
			  app_code, pool_id, user_id, draw_id, occurred_at_ms, wager_coins, payout_coins, created_at_ms
			) VALUES `)
		args := make([]any, 0, (end-start)*8)
		for index, record := range records[start:end] {
			if index > 0 {
				query.WriteByte(',')
			}
			query.WriteString("(?,?,?,?,?,?,?,?)")
			args = append(args,
				appCode, poolID, userID, record.DrawID, luckyPaidTime(record.Command, nowMS).UnixMilli(),
				record.Command.CoinSpent, record.Candidate.effectiveReward(), nowMS,
			)
		}
		if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) persistLuckyUserRTPHour(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserRTPHour, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_user_rtp_hour_buckets
		SET wager_coins = ?, payout_coins = ?, updated_at_ms = ?
		WHERE app_code = ? AND pool_id = ? AND user_id = ? AND bucket_hour_ms = ?`,
		state.WagerCoins, state.PayoutCoins, nowMS, appCode, poolID, userID, state.BucketHourMS,
	)
	return err
}

func (r *Repository) updateLuckyDynamicPoolNet(ctx context.Context, tx *sql.Tx, appCode, scopeID string, publicIn, payout, profitIn, anchorIncome, nowMS int64) error {
	if publicIn < 0 || payout < 0 || profitIn < 0 || anchorIncome < 0 {
		return fmt.Errorf("dynamic lucky gift pool delta cannot be negative")
	}
	_, err := tx.ExecContext(ctx, `
		UPDATE lucky_pools
		SET balance = balance + ? - ?, total_in = total_in + ?, total_out = total_out + ?,
		    profit_total = profit_total + ?, anchor_income_total = anchor_income_total + ?, updated_at_ms = ?
		WHERE app_code = ? AND scope_type = ? AND scope_id = ?`,
		publicIn, payout, publicIn, payout, profitIn, anchorIncome, nowMS,
		appCode, luckyDynamicPoolScopeType, scopeID,
	)
	return err
}
