package mysql

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"hash/fnv"
	"strings"
	"time"

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

const (
	activityTemplateRewardJobStatusDead      = "dead"
	activityTemplateRewardJobDefaultAttempts = int32(8)
	activityTemplateRewardJobDefaultBatch    = int32(50)
	activityTemplateRewardJobMaxBatch        = int32(200)
	activityTemplateRewardJobDefaultLease    = 30 * time.Second
	activityTemplateRewardJobRetryBase       = 5 * time.Second
	activityTemplateRewardJobRetryNominalCap = 12 * time.Minute
	activityTemplateRewardJobRetryHardCap    = 15 * time.Minute
)

// activityTemplateRewardJobLease keeps transport-neutral lease metadata beside the public job view.
// Lease ownership deliberately stays in storage: callers only need the stable worker ID they used to claim.
type activityTemplateRewardJobLease struct {
	Job           domain.RewardJob
	MaxAttempts   int32
	NextRetryAtMS int64
	LockedBy      string
	LockUntilMS   int64
}

func (r *Repository) GetActivityTemplateRuntimeVersion(ctx context.Context, templateID string, versionNo int64) (domain.Runtime, error) {
	if r == nil || r.db == nil {
		return domain.Runtime{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	return getActivityTemplateRuntimeByIdentity(ctx, r.db, appcode.FromContext(ctx), strings.TrimSpace(templateID), versionNo)
}

// ListActivityTemplateRuntimeLeaderboard returns a frozen snapshot as soon as settlement starts;
// before that it reads only the bounded score index for the requested immutable version.
func (r *Repository) ListActivityTemplateRuntimeLeaderboard(ctx context.Context, runtime domain.Runtime, query domain.LeaderboardQuery) (domain.LeaderboardResult, error) {
	if r == nil || r.db == nil {
		return domain.LeaderboardResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	periodKey := strings.TrimSpace(query.PeriodKey)
	if query.BoardType == domain.BoardTypeTotal {
		periodKey = "total"
	} else if periodKey == "" {
		periodKey = utcTaskDay(query.NowMS)
	}
	period, err := getActivityTemplateRankPeriod(ctx, r.db, runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo, query.BoardType, periodKey, false)
	if err != nil {
		return domain.LeaderboardResult{}, err
	}
	page, pageSize := normalizeRuntimePage(query.Page, query.PageSize)
	offset := int64(page-1) * int64(pageSize)
	var snapshotCount int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM activity_template_rank_snapshots
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?`,
		runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo, query.BoardType, periodKey).Scan(&snapshotCount); err != nil {
		return domain.LeaderboardResult{}, err
	}
	result := domain.LeaderboardResult{Runtime: runtime, Period: period, Entries: []domain.LeaderboardEntry{}}
	if snapshotCount > 0 || period.Status == domain.PeriodStatusSettled {
		result.Total = minInt64(snapshotCount, int64(periodLeaderboardSize(period)))
		rows, err := r.db.QueryContext(ctx, `
			SELECT rank_no, user_id, score, gift_count, coin_amount, first_scored_at_ms
			FROM activity_template_rank_snapshots
			WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?
			ORDER BY rank_no LIMIT ? OFFSET ?`, runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo,
			query.BoardType, periodKey, int(pageSize), offset)
		if err != nil {
			return domain.LeaderboardResult{}, err
		}
		defer rows.Close()
		for rows.Next() {
			var entry domain.LeaderboardEntry
			entry.Snapshot = true
			if err := rows.Scan(&entry.RankNo, &entry.UserID, &entry.Score, &entry.GiftCount, &entry.CoinAmount, &entry.FirstScoredAtMS); err != nil {
				return domain.LeaderboardResult{}, err
			}
			result.Entries = append(result.Entries, entry)
			if entry.UserID == query.UserID {
				result.MyEntry, result.MyFound = entry, true
			}
		}
		if !result.MyFound && query.UserID > 0 {
			entry, found, err := getActivityTemplateSnapshotUserEntry(ctx, r.db, runtime, query.BoardType, periodKey, query.UserID)
			if err != nil {
				return domain.LeaderboardResult{}, err
			}
			result.MyEntry, result.MyFound = entry, found
		}
		return result, rows.Err()
	}
	return r.listLiveActivityTemplateLeaderboard(ctx, runtime, period, query, pageSize, offset)
}

func (r *Repository) listLiveActivityTemplateLeaderboard(ctx context.Context, runtime domain.Runtime, period domain.RankPeriod, query domain.LeaderboardQuery, pageSize int32, offset int64) (domain.LeaderboardResult, error) {
	result := domain.LeaderboardResult{Runtime: runtime, Period: period, Entries: []domain.LeaderboardEntry{}}
	table := "activity_template_user_total_scores"
	dayClause := ""
	args := []any{runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo}
	if query.BoardType == domain.BoardTypeDaily {
		table = "activity_template_user_day_scores"
		dayClause = " AND task_day = ?"
		args = append(args, period.PeriodKey)
	}
	var rawTotal int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM `+table+` WHERE app_code = ? AND template_id = ? AND version_no = ?`+dayClause, args...).Scan(&rawTotal); err != nil {
		return domain.LeaderboardResult{}, err
	}
	result.Total = minInt64(rawTotal, int64(periodLeaderboardSize(period)))
	if offset >= result.Total {
		return result, nil
	}
	limit := int64(pageSize)
	if offset+limit > result.Total {
		limit = result.Total - offset
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT user_id, score, gift_count, coin_amount, first_scored_at_ms
		FROM `+table+`
		WHERE app_code = ? AND template_id = ? AND version_no = ?`+dayClause+`
		ORDER BY score DESC, first_scored_at_ms ASC, user_id ASC
		LIMIT ? OFFSET ?`, append(args, limit, offset)...)
	if err != nil {
		return domain.LeaderboardResult{}, err
	}
	defer rows.Close()
	rank := int32(offset + 1)
	for rows.Next() {
		entry := domain.LeaderboardEntry{RankNo: rank}
		if err := rows.Scan(&entry.UserID, &entry.Score, &entry.GiftCount, &entry.CoinAmount, &entry.FirstScoredAtMS); err != nil {
			return domain.LeaderboardResult{}, err
		}
		result.Entries = append(result.Entries, entry)
		if entry.UserID == query.UserID {
			result.MyEntry, result.MyFound = entry, true
		}
		rank++
	}
	if err := rows.Err(); err != nil {
		return domain.LeaderboardResult{}, err
	}
	if !result.MyFound && query.UserID > 0 {
		entry, found, err := getActivityTemplateLiveUserEntry(ctx, r.db, runtime, query.BoardType, period.PeriodKey, query.UserID)
		if err != nil {
			return domain.LeaderboardResult{}, err
		}
		result.MyEntry, result.MyFound = entry, found
	}
	return result, nil
}

// ClaimDueActivityTemplateRankPeriods only discovers periods that still need score freezing/job materialization.
// A stale settling row is recoverable only when it has no materialized reward job; failed/backoff/dead job queues
// are deliberately excluded so they cannot fill this batch, hot-loop has_more, or starve later pending periods.
func (r *Repository) ClaimDueActivityTemplateRankPeriods(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.RankPeriod, error) {
	if batchSize <= 0 {
		batchSize = 20
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	rows, err := tx.QueryContext(ctx, `
		SELECT p.app_code, p.template_id, p.template_code, p.version_no, p.board_type, p.period_key,
		       p.period_start_ms, p.period_end_ms, p.settle_after_ms, p.leaderboard_size, p.status, p.settled_at_ms
		FROM activity_template_rank_periods p
		WHERE p.app_code = ? AND p.settle_after_ms <= ? AND (
		  p.status = ?
		  OR (
		    p.status = ? AND p.lock_until_ms <= ?
		    AND NOT EXISTS (
		      SELECT 1 FROM activity_template_reward_jobs j
		      WHERE j.app_code = p.app_code AND j.template_id = p.template_id
		        AND j.version_no = p.version_no AND j.board_type = p.board_type AND j.period_key = p.period_key
		    )
		  )
		)
		ORDER BY p.settle_after_ms, p.template_id, p.version_no,
		         CASE p.board_type WHEN 'daily' THEN 0 ELSE 1 END, p.period_key
		LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS,
		domain.PeriodStatusPending, domain.PeriodStatusSettling, nowMS, int(batchSize))
	if err != nil {
		return nil, err
	}
	items := make([]domain.RankPeriod, 0, batchSize)
	for rows.Next() {
		var item domain.RankPeriod
		if err := rows.Scan(&item.AppCode, &item.TemplateID, &item.TemplateCode, &item.VersionNo,
			&item.BoardType, &item.PeriodKey, &item.StartMS, &item.EndMS, &item.SettleAfterMS,
			&item.LeaderboardSize, &item.Status, &item.SettledAtMS); err != nil {
			rows.Close()
			return nil, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	lockUntil := nowMS + lockTTL.Milliseconds()
	for _, item := range items {
		if _, err := tx.ExecContext(ctx, `
			UPDATE activity_template_rank_periods
			SET status = ?, locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?`,
			domain.PeriodStatusSettling, workerID, lockUntil, nowMS, item.AppCode, item.TemplateID,
			item.VersionNo, item.BoardType, item.PeriodKey); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return items, nil
}

// PrepareActivityTemplateRankSettlement freezes ordered scores once and materializes immutable reward jobs.
// It intentionally does not claim jobs: period discovery is bounded by periods, while wallet fan-out must be
// bounded independently at job granularity so one 1000-winner board cannot monopolize a cron invocation.
func (r *Repository) PrepareActivityTemplateRankSettlement(ctx context.Context, period domain.RankPeriod, nowMS int64) ([]domain.RewardJob, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	locked, err := getActivityTemplateRankPeriod(ctx, tx, period.AppCode, period.TemplateID, period.VersionNo, period.BoardType, period.PeriodKey, true)
	if err != nil {
		return nil, err
	}
	if locked.Status == domain.PeriodStatusSettled || locked.Status == domain.PeriodStatusCancelled {
		return []domain.RewardJob{}, nil
	}
	if locked.Status == domain.PeriodStatusRewarding {
		// Another materializer recovered this lease first. Its snapshot/jobs committed atomically with rewarding,
		// so this stale invocation has no remaining work and must not clear a finalizer's newer lease.
		return []domain.RewardJob{}, nil
	}
	if locked.Status != domain.PeriodStatusSettling {
		return nil, xerr.New(xerr.Conflict, "activity template rank period is not claimed for materialization")
	}
	var snapshotCount int64
	if err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM activity_template_rank_snapshots
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?`,
		period.AppCode, period.TemplateID, period.VersionNo, period.BoardType, period.PeriodKey).Scan(&snapshotCount); err != nil {
		return nil, err
	}
	if snapshotCount == 0 {
		if err := freezeActivityTemplateRankSnapshot(ctx, tx, locked, nowMS); err != nil {
			return nil, err
		}
		runtime, err := getActivityTemplateRuntimeByIdentity(ctx, tx, period.AppCode, period.TemplateID, period.VersionNo)
		if err != nil {
			return nil, err
		}
		if err := createActivityTemplateRewardJobs(ctx, tx, runtime, locked, nowMS); err != nil {
			return nil, err
		}
	}
	// `rewarding` is the durable materialization marker. It distinguishes a successfully frozen empty board
	// from a worker that crashed after claiming `settling` but before this transaction committed.
	result, err := tx.ExecContext(ctx, `
		UPDATE activity_template_rank_periods
		SET status = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?
		  AND status = ?`, domain.PeriodStatusRewarding, nowMS, period.AppCode, period.TemplateID,
		period.VersionNo, period.BoardType, period.PeriodKey, domain.PeriodStatusSettling)
	if err != nil {
		return nil, err
	}
	if err := requireActivityTemplateRewardJobMutation(result, "activity template rank period materialization lease lost"); err != nil {
		return nil, err
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return []domain.RewardJob{}, nil
}

// ClaimFinalizableActivityTemplateRankPeriods leases only materialized periods whose jobs are all granted.
// Backoff, running, failed and dead jobs all satisfy status <> granted and therefore never make this maintenance
// batch look busy. `settling` with at least one job is accepted only for pre-rewarding deployments; an empty stale
// claim is sent back through materialization instead. This bounded recovery closes the final-job crash gap.
func (r *Repository) ClaimFinalizableActivityTemplateRankPeriods(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.RankPeriod, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		batchSize = 20
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()
	rows, err := tx.QueryContext(ctx, `
		SELECT p.app_code, p.template_id, p.template_code, p.version_no, p.board_type, p.period_key,
		       p.period_start_ms, p.period_end_ms, p.settle_after_ms, p.leaderboard_size, p.status, p.settled_at_ms
		FROM activity_template_rank_periods p
		WHERE p.app_code = ? AND p.lock_until_ms <= ? AND (
		  p.status = ?
		  OR (
		    p.status = ? AND EXISTS (
		      SELECT 1 FROM activity_template_reward_jobs materialized
		      WHERE materialized.app_code = p.app_code AND materialized.template_id = p.template_id
		        AND materialized.version_no = p.version_no AND materialized.board_type = p.board_type
		        AND materialized.period_key = p.period_key
		    )
		  )
		)
		  AND NOT EXISTS (
		    SELECT 1 FROM activity_template_reward_jobs j
		    WHERE j.app_code = p.app_code AND j.template_id = p.template_id
		      AND j.version_no = p.version_no AND j.board_type = p.board_type AND j.period_key = p.period_key
		      AND j.status <> ?
		  )
		ORDER BY p.settle_after_ms, p.template_id, p.version_no,
		         CASE p.board_type WHEN 'daily' THEN 0 ELSE 1 END, p.period_key
		LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS, domain.PeriodStatusRewarding,
		domain.PeriodStatusSettling, domain.ClaimStatusGranted, int(batchSize))
	if err != nil {
		return nil, err
	}
	items := make([]domain.RankPeriod, 0, batchSize)
	for rows.Next() {
		var item domain.RankPeriod
		if err := rows.Scan(&item.AppCode, &item.TemplateID, &item.TemplateCode, &item.VersionNo,
			&item.BoardType, &item.PeriodKey, &item.StartMS, &item.EndMS, &item.SettleAfterMS,
			&item.LeaderboardSize, &item.Status, &item.SettledAtMS); err != nil {
			_ = rows.Close()
			return nil, err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	lockUntilMS := nowMS + lockTTL.Milliseconds()
	for _, item := range items {
		result, err := tx.ExecContext(ctx, `
			UPDATE activity_template_rank_periods
			SET locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?
			  AND status IN (?, ?)`, workerID, lockUntilMS, nowMS, item.AppCode, item.TemplateID, item.VersionNo,
			item.BoardType, item.PeriodKey, domain.PeriodStatusRewarding, domain.PeriodStatusSettling)
		if err != nil {
			return nil, err
		}
		affected, err := result.RowsAffected()
		if err != nil {
			return nil, err
		}
		if affected != 1 {
			return nil, xerr.New(xerr.Conflict, "activity template finalizable period lease lost")
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return items, nil
}

// ClaimActivityTemplateRewardJobs leases a finite cross-period job batch. A crashed running job is reclaimable
// only after its lease expires; every claim increments attempt_count before any wallet RPC is made. Jobs whose
// final attempt expired are fenced into dead instead of remaining running forever.
func (r *Repository) ClaimActivityTemplateRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.RewardJob, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "activity template reward worker_id is required")
	}
	lockTTL, batchSize = normalizeActivityTemplateRewardLeaseOptions(lockTTL, batchSize)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	rows, err := tx.QueryContext(ctx, activityTemplateRewardJobLeaseSelect+`
		WHERE app_code = ? AND (
		      (status IN (?, ?) AND next_retry_at_ms <= ?)
		      OR (status = ? AND lock_until_ms <= ?)
		)
		ORDER BY next_retry_at_ms, created_at_ms, reward_job_id
		LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), domain.ClaimStatusPending,
		domain.ClaimStatusFailed, nowMS, domain.ClaimStatusRunning, nowMS, int(batchSize))
	if err != nil {
		return nil, err
	}
	candidates := make([]activityTemplateRewardJobLease, 0, batchSize)
	for rows.Next() {
		candidate, err := scanActivityTemplateRewardJobLease(rows)
		if err != nil {
			_ = rows.Close()
			return nil, err
		}
		candidates = append(candidates, candidate)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}

	lockUntilMS := nowMS + lockTTL.Milliseconds()
	jobs := make([]domain.RewardJob, 0, len(candidates))
	for _, candidate := range candidates {
		maxAttempts := normalizeActivityTemplateRewardMaxAttempts(candidate.MaxAttempts)
		if candidate.Job.AttemptCount >= maxAttempts {
			// The final worker may have died after the wallet call. The stable wallet_command_id remains intact,
			// so an operator can explicitly revive this dead job and safely replay that command later.
			result, err := tx.ExecContext(ctx, `
				UPDATE activity_template_reward_jobs
				SET status = ?, next_retry_at_ms = 0, locked_by = '', lock_until_ms = 0,
				    failure_reason = CASE WHEN failure_reason = '' THEN ? ELSE failure_reason END,
				    updated_at_ms = ?
				WHERE app_code = ? AND reward_job_id = ?`, activityTemplateRewardJobStatusDead,
				"maximum delivery attempts exhausted", nowMS, candidate.Job.AppCode, candidate.Job.RewardJobID)
			if err != nil {
				return nil, err
			}
			if err := requireActivityTemplateRewardJobMutation(result, "activity template reward job terminal transition lost"); err != nil {
				return nil, err
			}
			continue
		}
		result, err := tx.ExecContext(ctx, `
			UPDATE activity_template_reward_jobs
			SET status = ?, attempt_count = attempt_count + 1, next_retry_at_ms = 0,
			    locked_by = ?, lock_until_ms = ?, failure_reason = '', updated_at_ms = ?
			WHERE app_code = ? AND reward_job_id = ?`, domain.ClaimStatusRunning, workerID,
			lockUntilMS, nowMS, candidate.Job.AppCode, candidate.Job.RewardJobID)
		if err != nil {
			return nil, err
		}
		if err := requireActivityTemplateRewardJobMutation(result, "activity template reward job claim lost"); err != nil {
			return nil, err
		}
		candidate.Job.Status = domain.ClaimStatusRunning
		candidate.Job.FailureReason = ""
		candidate.Job.AttemptCount++
		candidate.Job.UpdatedAtMS = nowMS
		jobs = append(jobs, candidate.Job)
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return jobs, nil
}

// ExtendActivityTemplateRewardJobLease is a heartbeat with owner fencing. It never resurrects an expired lease:
// once the expiry boundary is crossed, the old worker must stop and let a later claim replay the idempotent command.
func (r *Repository) ExtendActivityTemplateRewardJobLease(ctx context.Context, rewardJobID, workerID string, nowMS int64, lockTTL time.Duration) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rewardJobID, workerID = strings.TrimSpace(rewardJobID), strings.TrimSpace(workerID)
	if rewardJobID == "" || workerID == "" {
		return xerr.New(xerr.InvalidArgument, "activity template reward lease identity is incomplete")
	}
	lockTTL, _ = normalizeActivityTemplateRewardLeaseOptions(lockTTL, 1)
	result, err := r.db.ExecContext(ctx, `
		UPDATE activity_template_reward_jobs
		SET lock_until_ms = GREATEST(lock_until_ms, ?), updated_at_ms = ?
		WHERE app_code = ? AND reward_job_id = ? AND status = ? AND locked_by = ? AND lock_until_ms > ?`,
		nowMS+lockTTL.Milliseconds(), nowMS, appcode.FromContext(ctx), rewardJobID,
		domain.ClaimStatusRunning, workerID, nowMS)
	if err != nil {
		return err
	}
	return requireActivityTemplateRewardJobMutation(result, "activity template reward job lease lost")
}

// MarkActivityTemplateRewardJobGrantedWithLease accepts completion only from the unexpired lease owner. This
// fencing prevents a slow worker from overwriting a retry worker after takeover; the wallet command itself is
// stable, so rejecting a stale completion only causes a safe idempotent replay.
func (r *Repository) MarkActivityTemplateRewardJobGrantedWithLease(ctx context.Context, rewardJobID, workerID, walletGrantID string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rewardJobID, workerID, walletGrantID = strings.TrimSpace(rewardJobID), strings.TrimSpace(workerID), strings.TrimSpace(walletGrantID)
	if rewardJobID == "" || workerID == "" || walletGrantID == "" {
		return xerr.New(xerr.InvalidArgument, "activity template reward completion identity is incomplete")
	}
	result, err := r.db.ExecContext(ctx, `
		UPDATE activity_template_reward_jobs
		SET status = ?, wallet_grant_id = ?, failure_reason = '', next_retry_at_ms = 0,
		    locked_by = '', lock_until_ms = 0, granted_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND reward_job_id = ? AND status = ? AND locked_by = ? AND lock_until_ms > ?`,
		domain.ClaimStatusGranted, walletGrantID, nowMS, nowMS, appcode.FromContext(ctx), rewardJobID,
		domain.ClaimStatusRunning, workerID, nowMS)
	if err != nil {
		return err
	}
	return requireActivityTemplateRewardJobMutation(result, "activity template reward job completion lease lost")
}

// MarkActivityTemplateRewardJobFailedWithLease releases the current lease into a deterministic exponential
// retry schedule. The terminal attempt becomes dead and requires an explicit admin revival.
func (r *Repository) MarkActivityTemplateRewardJobFailedWithLease(ctx context.Context, rewardJobID, workerID, reason string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rewardJobID, workerID = strings.TrimSpace(rewardJobID), strings.TrimSpace(workerID)
	if rewardJobID == "" || workerID == "" {
		return xerr.New(xerr.InvalidArgument, "activity template reward failure identity is incomplete")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	var attemptCount, maxAttempts int32
	err = tx.QueryRowContext(ctx, `
		SELECT attempt_count, max_attempts FROM activity_template_reward_jobs
		WHERE app_code = ? AND reward_job_id = ? AND status = ? AND locked_by = ? AND lock_until_ms > ?
		FOR UPDATE`, appcode.FromContext(ctx), rewardJobID, domain.ClaimStatusRunning, workerID, nowMS).
		Scan(&attemptCount, &maxAttempts)
	if errors.Is(err, sql.ErrNoRows) {
		return xerr.New(xerr.Conflict, "activity template reward job failure lease lost")
	}
	if err != nil {
		return err
	}
	maxAttempts = normalizeActivityTemplateRewardMaxAttempts(maxAttempts)
	status, nextRetryAtMS := domain.ClaimStatusFailed, activityTemplateRewardRetryAtMS(rewardJobID, attemptCount, nowMS)
	if attemptCount >= maxAttempts {
		status, nextRetryAtMS = activityTemplateRewardJobStatusDead, 0
	}
	result, err := tx.ExecContext(ctx, `
		UPDATE activity_template_reward_jobs
		SET status = ?, failure_reason = ?, next_retry_at_ms = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND reward_job_id = ? AND status = ? AND locked_by = ? AND lock_until_ms > ?`,
		status, truncateActivityTemplateRuntimeError(reason), nextRetryAtMS, nowMS, appcode.FromContext(ctx),
		rewardJobID, domain.ClaimStatusRunning, workerID, nowMS)
	if err != nil {
		return err
	}
	if err := requireActivityTemplateRewardJobMutation(result, "activity template reward job failure lease lost"); err != nil {
		return err
	}
	return tx.Commit()
}

// ClaimActivityTemplateRewardJobForManualRetry explicitly revives failed/dead work and leases it to the
// requesting admin worker in one transaction. A dead job receives exactly one additional attempt budget without
// resetting attempt_count, preserving the audit trail and the original wallet_command_id.
func (r *Repository) ClaimActivityTemplateRewardJobForManualRetry(ctx context.Context, templateID, rewardJobID, workerID string, nowMS int64, lockTTL time.Duration) (domain.RewardJob, error) {
	if r == nil || r.db == nil {
		return domain.RewardJob{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	templateID, rewardJobID, workerID = strings.TrimSpace(templateID), strings.TrimSpace(rewardJobID), strings.TrimSpace(workerID)
	if templateID == "" || rewardJobID == "" || workerID == "" {
		return domain.RewardJob{}, xerr.New(xerr.InvalidArgument, "activity template manual reward retry identity is incomplete")
	}
	lockTTL, _ = normalizeActivityTemplateRewardLeaseOptions(lockTTL, 1)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.RewardJob{}, err
	}
	defer func() { _ = tx.Rollback() }()
	candidate, found, err := getActivityTemplateRewardJobLease(ctx, tx, appcode.FromContext(ctx), rewardJobID, true)
	if err != nil {
		return domain.RewardJob{}, err
	}
	if !found || candidate.Job.TemplateID != templateID {
		return domain.RewardJob{}, xerr.New(xerr.NotFound, "activity template reward job not found")
	}
	if candidate.Job.Status != domain.ClaimStatusFailed && candidate.Job.Status != activityTemplateRewardJobStatusDead {
		return domain.RewardJob{}, xerr.New(xerr.Conflict, "only failed or dead activity template reward jobs can be retried manually")
	}
	maxAttempts := normalizeActivityTemplateRewardMaxAttempts(candidate.MaxAttempts)
	if candidate.Job.AttemptCount >= maxAttempts {
		maxAttempts = candidate.Job.AttemptCount + 1
	}
	result, err := tx.ExecContext(ctx, `
		UPDATE activity_template_reward_jobs
		SET status = ?, attempt_count = attempt_count + 1, max_attempts = ?, next_retry_at_ms = 0,
		    locked_by = ?, lock_until_ms = ?, failure_reason = '', updated_at_ms = ?
		WHERE app_code = ? AND reward_job_id = ? AND status IN (?, ?)`, domain.ClaimStatusRunning,
		maxAttempts, workerID, nowMS+lockTTL.Milliseconds(), nowMS, appcode.FromContext(ctx), rewardJobID,
		domain.ClaimStatusFailed, activityTemplateRewardJobStatusDead)
	if err != nil {
		return domain.RewardJob{}, err
	}
	if err := requireActivityTemplateRewardJobMutation(result, "activity template manual reward retry claim lost"); err != nil {
		return domain.RewardJob{}, err
	}
	candidate.Job.Status = domain.ClaimStatusRunning
	candidate.Job.AttemptCount++
	candidate.Job.MaxAttempts = maxAttempts
	candidate.Job.NextRetryAtMS = 0
	candidate.Job.FailureReason = ""
	candidate.Job.UpdatedAtMS = nowMS
	if err := tx.Commit(); err != nil {
		return domain.RewardJob{}, err
	}
	return candidate.Job, nil
}

func (r *Repository) FinishActivityTemplateRankPeriodIfComplete(ctx context.Context, period domain.RankPeriod, nowMS int64) (bool, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return false, err
	}
	defer tx.Rollback()
	locked, err := getActivityTemplateRankPeriod(ctx, tx, period.AppCode, period.TemplateID, period.VersionNo, period.BoardType, period.PeriodKey, true)
	if err != nil {
		return false, err
	}
	// Only the settlement state machine may transition to settled. In particular, an admin disable can
	// cancel a not-yet-started period while a stale worker is finishing; preserving cancelled here prevents
	// an empty period from being rewritten as a successful settlement.
	if locked.Status != domain.PeriodStatusSettling && locked.Status != domain.PeriodStatusRewarding {
		return false, nil
	}
	var remaining int64
	if err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM activity_template_reward_jobs
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ? AND status <> ?`,
		period.AppCode, period.TemplateID, period.VersionNo, period.BoardType, period.PeriodKey, domain.ClaimStatusGranted).Scan(&remaining); err != nil {
		return false, err
	}
	if remaining > 0 {
		if _, err := tx.ExecContext(ctx, `
			UPDATE activity_template_rank_periods
			SET status = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
			WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ? AND status = ?`,
			domain.PeriodStatusRewarding, nowMS, period.AppCode, period.TemplateID, period.VersionNo, period.BoardType,
			period.PeriodKey, locked.Status); err != nil {
			return false, err
		}
		return false, tx.Commit()
	}
	result, err := tx.ExecContext(ctx, `
		UPDATE activity_template_rank_periods
		SET status = ?, settled_at_ms = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ? AND status = ?`,
		domain.PeriodStatusSettled, nowMS, nowMS, period.AppCode, period.TemplateID, period.VersionNo,
		period.BoardType, period.PeriodKey, locked.Status)
	if err != nil {
		return false, err
	}
	affected, err := result.RowsAffected()
	if err != nil {
		return false, err
	}
	if affected != 1 {
		return false, nil
	}
	return true, tx.Commit()
}

func (r *Repository) ListActivityTemplateRewardJobs(ctx context.Context, query domain.RewardJobQuery) ([]domain.RewardJob, int64, error) {
	where := []string{"app_code = ?", "template_id = ?"}
	args := []any{appcode.FromContext(ctx), strings.TrimSpace(query.TemplateID)}
	if query.VersionNo > 0 {
		where, args = append(where, "version_no = ?"), append(args, query.VersionNo)
	}
	if query.BoardType != "" {
		where, args = append(where, "board_type = ?"), append(args, query.BoardType)
	}
	if query.PeriodKey != "" {
		where, args = append(where, "period_key = ?"), append(args, query.PeriodKey)
	}
	if query.Status != "" {
		where, args = append(where, "status = ?"), append(args, query.Status)
	}
	whereSQL := " WHERE " + strings.Join(where, " AND ")
	var total int64
	if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM activity_template_reward_jobs"+whereSQL, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	page, pageSize := normalizeRuntimePage(query.Page, query.PageSize)
	rows, err := r.db.QueryContext(ctx, activityTemplateRewardJobSelect+whereSQL+`
		ORDER BY updated_at_ms DESC, reward_job_id DESC LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]domain.RewardJob, 0)
	for rows.Next() {
		item, err := scanActivityTemplateRewardJob(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

const activityTemplateRewardJobSelect = `
	SELECT app_code, reward_job_id, template_id, template_code, version_no, board_type, period_key,
	       rank_no, user_id, score, resource_group_id, reward_snapshot_id,
	       COALESCE(CAST(reward_snapshot_json AS CHAR), ''), wallet_command_id, wallet_grant_id, status,
	       failure_reason, attempt_count, max_attempts, next_retry_at_ms, created_at_ms, updated_at_ms, granted_at_ms
	FROM activity_template_reward_jobs `

const activityTemplateRewardJobLeaseSelect = `
	SELECT app_code, reward_job_id, template_id, template_code, version_no, board_type, period_key,
	       rank_no, user_id, score, resource_group_id, reward_snapshot_id,
	       COALESCE(CAST(reward_snapshot_json AS CHAR), ''), wallet_command_id, wallet_grant_id, status,
	       failure_reason, attempt_count, created_at_ms, updated_at_ms, granted_at_ms,
	       max_attempts, next_retry_at_ms, locked_by, lock_until_ms
	FROM activity_template_reward_jobs `

func freezeActivityTemplateRankSnapshot(ctx context.Context, tx *sql.Tx, period domain.RankPeriod, nowMS int64) error {
	var leaderboardSize int32
	if err := tx.QueryRowContext(ctx, `
		SELECT leaderboard_size FROM activity_template_rank_periods
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?`,
		period.AppCode, period.TemplateID, period.VersionNo, period.BoardType, period.PeriodKey).Scan(&leaderboardSize); err != nil {
		return err
	}
	table, dayClause := "activity_template_user_total_scores", ""
	args := []any{period.AppCode, period.TemplateID, period.VersionNo}
	if period.BoardType == domain.BoardTypeDaily {
		table, dayClause = "activity_template_user_day_scores", " AND task_day = ?"
		args = append(args, period.PeriodKey)
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT user_id, score, gift_count, coin_amount, first_scored_at_ms FROM `+table+`
		WHERE app_code = ? AND template_id = ? AND version_no = ?`+dayClause+`
		ORDER BY score DESC, first_scored_at_ms ASC, user_id ASC LIMIT ?`, append(args, int(leaderboardSize))...)
	if err != nil {
		return err
	}
	type snapshot struct{ userID, score, giftCount, coinAmount, firstMS int64 }
	items := make([]snapshot, 0, leaderboardSize)
	for rows.Next() {
		var item snapshot
		if err := rows.Scan(&item.userID, &item.score, &item.giftCount, &item.coinAmount, &item.firstMS); err != nil {
			rows.Close()
			return err
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	for index, item := range items {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO activity_template_rank_snapshots (
			  app_code, template_id, template_code, version_no, board_type, period_key, rank_no,
			  user_id, score, gift_count, coin_amount, first_scored_at_ms, snapshot_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			period.AppCode, period.TemplateID, period.TemplateCode, period.VersionNo, period.BoardType,
			period.PeriodKey, index+1, item.userID, item.score, item.giftCount, item.coinAmount, item.firstMS, nowMS); err != nil {
			return err
		}
	}
	return nil
}

func createActivityTemplateRewardJobs(ctx context.Context, tx *sql.Tx, runtime domain.Runtime, period domain.RankPeriod, nowMS int64) error {
	rows, err := tx.QueryContext(ctx, `
		SELECT rank_no, user_id, score FROM activity_template_rank_snapshots
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?
		ORDER BY rank_no`, period.AppCode, period.TemplateID, period.VersionNo, period.BoardType, period.PeriodKey)
	if err != nil {
		return err
	}
	type winner struct {
		rank          int32
		userID, score int64
	}
	winners := make([]winner, 0)
	for rows.Next() {
		var item winner
		if err := rows.Scan(&item.rank, &item.userID, &item.score); err != nil {
			rows.Close()
			return err
		}
		winners = append(winners, item)
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	for _, winner := range winners {
		reward, found := rankRewardForRank(runtime.Template.RankRewards, period.BoardType, winner.rank)
		if !found || reward.ResourceGroupID <= 0 {
			continue
		}
		rewardSnapshotJSON, err := encodeActivityTemplateRewardSnapshot(reward.RewardSnapshot)
		if err != nil {
			return err
		}
		identity := fmt.Sprintf("%s:%s:%d:%s:%s:%d:%d", period.AppCode, period.TemplateID, period.VersionNo,
			period.BoardType, period.PeriodKey, winner.rank, winner.userID)
		jobID := idgen.New("atreward")
		walletCommandID := stableRuntimeID("arank", identity)
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO activity_template_reward_jobs (
			  app_code, reward_job_id, template_id, template_code, version_no, board_type, period_key,
			  rank_no, user_id, score, resource_group_id, reward_snapshot_id, reward_snapshot_json,
			  wallet_command_id, status, max_attempts, next_retry_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?)`,
			period.AppCode, jobID, period.TemplateID, period.TemplateCode, period.VersionNo, period.BoardType,
			period.PeriodKey, winner.rank, winner.userID, winner.score, reward.ResourceGroupID,
			reward.RewardSnapshot.SnapshotID, rewardSnapshotJSON, walletCommandID,
			domain.ClaimStatusPending, activityTemplateRewardJobDefaultAttempts, nowMS, nowMS, nowMS); err != nil {
			return err
		}
	}
	return nil
}

func rankRewardForRank(rewards []domain.RankReward, boardType string, rank int32) (domain.RankReward, bool) {
	for _, reward := range rewards {
		if reward.BoardType == boardType && rank >= reward.RankFrom && rank <= reward.RankTo {
			return reward, true
		}
	}
	return domain.RankReward{}, false
}

func getActivityTemplateRankPeriod(ctx context.Context, q activityTemplateQuerier, appCode, templateID string, versionNo int64, boardType, periodKey string, forUpdate bool) (domain.RankPeriod, error) {
	query := `SELECT app_code, template_id, template_code, version_no, board_type, period_key,
	                 period_start_ms, period_end_ms, settle_after_ms, leaderboard_size, status, settled_at_ms
	          FROM activity_template_rank_periods
	          WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?`
	if forUpdate {
		query += " FOR UPDATE"
	}
	var item domain.RankPeriod
	err := q.QueryRowContext(ctx, query, appCode, templateID, versionNo, boardType, periodKey).
		Scan(&item.AppCode, &item.TemplateID, &item.TemplateCode, &item.VersionNo, &item.BoardType,
			&item.PeriodKey, &item.StartMS, &item.EndMS, &item.SettleAfterMS,
			&item.LeaderboardSize, &item.Status, &item.SettledAtMS)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.RankPeriod{}, xerr.New(xerr.NotFound, "activity template rank period not found")
	}
	return item, err
}

func periodLeaderboardSize(period domain.RankPeriod) int32 {
	if period.LeaderboardSize <= 0 {
		return 100
	}
	return period.LeaderboardSize
}

func getActivityTemplateSnapshotUserEntry(ctx context.Context, q activityTemplateQuerier, runtime domain.Runtime, boardType, periodKey string, userID int64) (domain.LeaderboardEntry, bool, error) {
	var item domain.LeaderboardEntry
	item.Snapshot = true
	err := q.QueryRowContext(ctx, `
		SELECT rank_no, user_id, score, gift_count, coin_amount, first_scored_at_ms
		FROM activity_template_rank_snapshots
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ? AND user_id = ?`,
		runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo, boardType, periodKey, userID).
		Scan(&item.RankNo, &item.UserID, &item.Score, &item.GiftCount, &item.CoinAmount, &item.FirstScoredAtMS)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.LeaderboardEntry{}, false, nil
	}
	return item, err == nil, err
}

func getActivityTemplateLiveUserEntry(ctx context.Context, q activityTemplateQuerier, runtime domain.Runtime, boardType, periodKey string, userID int64) (domain.LeaderboardEntry, bool, error) {
	table, dayClause := "activity_template_user_total_scores", ""
	args := []any{runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo, userID}
	if boardType == domain.BoardTypeDaily {
		table, dayClause = "activity_template_user_day_scores", " AND task_day = ?"
		args = append(args, periodKey)
	}
	var item domain.LeaderboardEntry
	err := q.QueryRowContext(ctx, `
		SELECT user_id, score, gift_count, coin_amount, first_scored_at_ms FROM `+table+`
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND user_id = ?`+dayClause, args...).
		Scan(&item.UserID, &item.Score, &item.GiftCount, &item.CoinAmount, &item.FirstScoredAtMS)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.LeaderboardEntry{}, false, nil
	}
	if err != nil {
		return domain.LeaderboardEntry{}, false, err
	}
	rankArgs := []any{runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo, item.Score, item.Score, item.FirstScoredAtMS, item.Score, item.FirstScoredAtMS, item.UserID}
	rankDayClause := ""
	if boardType == domain.BoardTypeDaily {
		rankDayClause = " AND task_day = ?"
		rankArgs = append(rankArgs, periodKey)
	}
	var ahead int64
	if err := q.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM `+table+`
		WHERE app_code = ? AND template_id = ? AND version_no = ?
		  AND (score > ? OR (score = ? AND first_scored_at_ms < ?) OR
		       (score = ? AND first_scored_at_ms = ? AND user_id < ?))`+rankDayClause, rankArgs...).Scan(&ahead); err != nil {
		return domain.LeaderboardEntry{}, false, err
	}
	item.RankNo = int32(ahead + 1)
	return item, true, nil
}

func scanActivityTemplateRewardJob(row activityTemplateScanner) (domain.RewardJob, error) {
	item, _, err := scanActivityTemplateRewardJobOne(row)
	return item, err
}

func scanActivityTemplateRewardJobOne(row activityTemplateScanner) (domain.RewardJob, bool, error) {
	var item domain.RewardJob
	var rewardSnapshotJSON string
	err := row.Scan(&item.AppCode, &item.RewardJobID, &item.TemplateID, &item.TemplateCode, &item.VersionNo,
		&item.BoardType, &item.PeriodKey, &item.RankNo, &item.UserID, &item.Score, &item.ResourceGroupID,
		&item.RewardSnapshot.SnapshotID, &rewardSnapshotJSON, &item.WalletCommandID, &item.WalletGrantID,
		&item.Status, &item.FailureReason, &item.AttemptCount, &item.MaxAttempts, &item.NextRetryAtMS,
		&item.CreatedAtMS, &item.UpdatedAtMS, &item.GrantedAtMS)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.RewardJob{}, false, nil
	}
	if err == nil {
		err = decodeActivityTemplateRewardSnapshot(rewardSnapshotJSON, &item.RewardSnapshot)
	}
	return item, err == nil, err
}

func getActivityTemplateRewardJobLease(ctx context.Context, q activityTemplateQuerier, appCode, rewardJobID string, forUpdate bool) (activityTemplateRewardJobLease, bool, error) {
	query := activityTemplateRewardJobLeaseSelect + " WHERE app_code = ? AND reward_job_id = ?"
	if forUpdate {
		query += " FOR UPDATE"
	}
	return scanActivityTemplateRewardJobLeaseOne(q.QueryRowContext(ctx, query, appCode, rewardJobID))
}

func scanActivityTemplateRewardJobLease(row activityTemplateScanner) (activityTemplateRewardJobLease, error) {
	item, _, err := scanActivityTemplateRewardJobLeaseOne(row)
	return item, err
}

func scanActivityTemplateRewardJobLeaseOne(row activityTemplateScanner) (activityTemplateRewardJobLease, bool, error) {
	var item activityTemplateRewardJobLease
	var rewardSnapshotJSON string
	err := row.Scan(&item.Job.AppCode, &item.Job.RewardJobID, &item.Job.TemplateID, &item.Job.TemplateCode,
		&item.Job.VersionNo, &item.Job.BoardType, &item.Job.PeriodKey, &item.Job.RankNo, &item.Job.UserID,
		&item.Job.Score, &item.Job.ResourceGroupID, &item.Job.RewardSnapshot.SnapshotID, &rewardSnapshotJSON,
		&item.Job.WalletCommandID, &item.Job.WalletGrantID, &item.Job.Status, &item.Job.FailureReason,
		&item.Job.AttemptCount, &item.Job.CreatedAtMS, &item.Job.UpdatedAtMS, &item.Job.GrantedAtMS,
		&item.MaxAttempts, &item.NextRetryAtMS, &item.LockedBy, &item.LockUntilMS)
	if errors.Is(err, sql.ErrNoRows) {
		return activityTemplateRewardJobLease{}, false, nil
	}
	if err == nil {
		item.Job.MaxAttempts = item.MaxAttempts
		item.Job.NextRetryAtMS = item.NextRetryAtMS
		err = decodeActivityTemplateRewardSnapshot(rewardSnapshotJSON, &item.Job.RewardSnapshot)
	}
	return item, err == nil, err
}

func normalizeActivityTemplateRewardLeaseOptions(lockTTL time.Duration, batchSize int32) (time.Duration, int32) {
	if lockTTL <= 0 {
		lockTTL = activityTemplateRewardJobDefaultLease
	}
	if batchSize <= 0 {
		batchSize = activityTemplateRewardJobDefaultBatch
	}
	if batchSize > activityTemplateRewardJobMaxBatch {
		batchSize = activityTemplateRewardJobMaxBatch
	}
	return lockTTL, batchSize
}

func normalizeActivityTemplateRewardMaxAttempts(value int32) int32 {
	if value <= 0 {
		return activityTemplateRewardJobDefaultAttempts
	}
	return value
}

// activityTemplateRewardRetryAtMS applies exponential backoff plus deterministic per-job jitter. Determinism
// makes incident replay and unit tests stable, while hashing the immutable job ID avoids a synchronized retry wave.
func activityTemplateRewardRetryAtMS(rewardJobID string, attemptCount int32, nowMS int64) int64 {
	if attemptCount <= 0 {
		attemptCount = 1
	}
	nominal := activityTemplateRewardJobRetryBase
	for attempt := int32(1); attempt < attemptCount && nominal < activityTemplateRewardJobRetryNominalCap; attempt++ {
		nominal *= 2
		if nominal > activityTemplateRewardJobRetryNominalCap {
			nominal = activityTemplateRewardJobRetryNominalCap
		}
	}
	jitterWindow := nominal / 4
	hasher := fnv.New64a()
	_, _ = hasher.Write([]byte(strings.TrimSpace(rewardJobID)))
	_, _ = hasher.Write([]byte{0})
	_, _ = hasher.Write([]byte(fmt.Sprintf("%d", attemptCount)))
	jitter := time.Duration(0)
	if jitterWindow > 0 {
		jitter = time.Duration(hasher.Sum64() % uint64(jitterWindow+1))
	}
	delay := nominal + jitter
	if delay > activityTemplateRewardJobRetryHardCap {
		delay = activityTemplateRewardJobRetryHardCap
	}
	return nowMS + delay.Milliseconds()
}

func requireActivityTemplateRewardJobMutation(result sql.Result, message string) error {
	affected, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if affected != 1 {
		return xerr.New(xerr.Conflict, message)
	}
	return nil
}

func normalizeRuntimePage(page, pageSize int32) (int32, int32) {
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 {
		pageSize = 20
	}
	if pageSize > 100 {
		pageSize = 100
	}
	return page, pageSize
}
