package mysql

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

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

const temporaryLevelDayMS = int64(24 * time.Hour / time.Millisecond)

func (r *Repository) activeTemporaryLevelForUpdate(ctx context.Context, tx *sql.Tx, userID int64, track string, nowMS int64) (domain.TemporaryLevel, bool, error) {
	row := tx.QueryRowContext(ctx, temporaryLevelSelectSQL()+`
		WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active'
		  AND started_at_ms <= ? AND expires_at_ms > ? ORDER BY generation DESC LIMIT 1 FOR UPDATE`,
		appcode.FromContext(ctx), userID, track, nowMS, nowMS)
	var item domain.TemporaryLevel
	err := row.Scan(&item.AppCode, &item.TemporaryLevelID, &item.CommandID, &item.UserID, &item.Track,
		&item.TargetLevel, &item.TargetRequiredValue, &item.BaselineTotalValue, &item.StartedAtMS, &item.ExpiresAtMS,
		&item.Status, &item.Generation, &item.OperatorAdminID, &item.Reason, &item.FinalLevel,
		&item.ActivationNoticeStatus, &item.ExpiryNoticeStatus, &item.AttemptCount, &item.FailureReason,
		&item.CreatedAtMS, &item.UpdatedAtMS)
	if err == sql.ErrNoRows {
		return domain.TemporaryLevel{}, false, nil
	}
	return item, err == nil, err
}

func (r *Repository) activeTemporaryLevelForRead(ctx context.Context, userID int64, track string, nowMS int64) (domain.TemporaryLevel, bool, error) {
	row := r.db.QueryRowContext(ctx, temporaryLevelSelectSQL()+`
		WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active'
		  AND started_at_ms <= ? AND expires_at_ms > ? ORDER BY generation DESC LIMIT 1`,
		appcode.FromContext(ctx), userID, track, nowMS, nowMS)
	var item domain.TemporaryLevel
	err := row.Scan(&item.AppCode, &item.TemporaryLevelID, &item.CommandID, &item.UserID, &item.Track,
		&item.TargetLevel, &item.TargetRequiredValue, &item.BaselineTotalValue, &item.StartedAtMS, &item.ExpiresAtMS,
		&item.Status, &item.Generation, &item.OperatorAdminID, &item.Reason, &item.FinalLevel,
		&item.ActivationNoticeStatus, &item.ExpiryNoticeStatus, &item.AttemptCount, &item.FailureReason,
		&item.CreatedAtMS, &item.UpdatedAtMS)
	if err == sql.ErrNoRows {
		return domain.TemporaryLevel{}, false, nil
	}
	return item, err == nil, err
}

func levelFromGrowthRewardSourceID(sourceID string) (int32, bool) {
	value := strings.TrimSpace(sourceID)
	if !strings.HasPrefix(value, "level:") {
		return 0, false
	}
	parsed, err := strconv.ParseInt(strings.TrimPrefix(value, "level:"), 10, 32)
	if err != nil || parsed < 0 {
		return 0, false
	}
	return int32(parsed), true
}

// BatchGetUserLevelAdminProfiles 一次读取整批真实账户和有效 overlay。列表页不能按用户×轨道产生 N+1 查询；
// expires_at_ms 仍在 SQL 中按 [start,end) 判定，保证 cron 延迟时读路径也不会继续展示临时等级。
func (r *Repository) BatchGetUserLevelAdminProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.AdminLevelProfiles, error) {
	if r == nil || r.db == nil {
		return domain.AdminLevelProfiles{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	userIDs = uniquePositiveInt64s(userIDs)
	if len(userIDs) == 0 {
		return domain.AdminLevelProfiles{Profiles: []domain.AdminUserLevelProfile{}, ServerTimeMS: nowMS}, nil
	}
	profiles := make([]domain.AdminUserLevelProfile, len(userIDs))
	profileIndex := make(map[int64]int, len(userIDs))
	for index, userID := range userIDs {
		profiles[index] = domain.AdminUserLevelProfile{UserID: userID, Tracks: []domain.AdminTrackProfile{
			{Track: domain.TrackWealth}, {Track: domain.TrackGame}, {Track: domain.TrackCharm},
		}, ServerTimeMS: nowMS}
		profileIndex[userID] = index
	}
	trackIndex := map[string]int{domain.TrackWealth: 0, domain.TrackGame: 1, domain.TrackCharm: 2}
	args := make([]any, 0, len(userIDs)+1)
	args = append(args, appcode.FromContext(ctx))
	for _, userID := range userIDs {
		args = append(args, userID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT user_id, track, total_value, current_level
		FROM user_growth_level_accounts
		WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)
		  AND track IN ('wealth','game','charm')`, args...)
	if err != nil {
		return domain.AdminLevelProfiles{}, err
	}
	for rows.Next() {
		var userID int64
		var track string
		var totalValue int64
		var level int32
		if err := rows.Scan(&userID, &track, &totalValue, &level); err != nil {
			_ = rows.Close()
			return domain.AdminLevelProfiles{}, err
		}
		profilePosition, profileFound := profileIndex[userID]
		trackPosition, trackFound := trackIndex[track]
		if !profileFound || !trackFound {
			continue
		}
		item := &profiles[profilePosition].Tracks[trackPosition]
		item.RealTotalValue = totalValue
		item.RealLevel = level
		item.DisplayValue = totalValue
		item.DisplayLevel = level
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return domain.AdminLevelProfiles{}, err
	}
	if err := rows.Close(); err != nil {
		return domain.AdminLevelProfiles{}, err
	}

	rulesByTrack := make(map[string][]domain.Rule, 2)
	for _, track := range []string{domain.TrackWealth, domain.TrackCharm} {
		rules, err := r.listActiveLevelRules(ctx, r.db, track)
		if err != nil {
			return domain.AdminLevelProfiles{}, err
		}
		rulesByTrack[track] = rules
	}
	overlayArgs := make([]any, 0, len(userIDs)+3)
	overlayArgs = append(overlayArgs, appcode.FromContext(ctx), nowMS, nowMS)
	for _, userID := range userIDs {
		overlayArgs = append(overlayArgs, userID)
	}
	rows, err = r.db.QueryContext(ctx, `
		SELECT t.user_id, t.track, t.temporary_level_id, t.target_level, t.target_required_value,
		       t.started_at_ms, t.expires_at_ms, t.generation, COALESCE(SUM(e.value_delta), 0)
		FROM growth_temporary_levels t
		LEFT JOIN growth_level_value_events e
		  ON e.app_code = t.app_code AND e.user_id = t.user_id AND e.track = t.track
		 AND e.occurred_at_ms >= t.started_at_ms AND e.occurred_at_ms < t.expires_at_ms
		WHERE t.app_code = ? AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?
		  AND t.user_id IN (`+placeholders(len(userIDs))+`)
		GROUP BY t.user_id, t.track, t.temporary_level_id, t.target_level, t.target_required_value,
		         t.started_at_ms, t.expires_at_ms, t.generation
		ORDER BY t.user_id ASC, t.track ASC, t.generation DESC`, overlayArgs...)
	if err != nil {
		return domain.AdminLevelProfiles{}, err
	}
	seenOverlay := make(map[string]struct{}, len(userIDs)*2)
	for rows.Next() {
		var userID int64
		var track string
		var temporaryID string
		var targetLevel int32
		var targetRequiredValue int64
		var startedAtMS, expiresAtMS int64
		var generation int32
		var windowIncrement int64
		if err := rows.Scan(&userID, &track, &temporaryID, &targetLevel, &targetRequiredValue, &startedAtMS, &expiresAtMS, &generation, &windowIncrement); err != nil {
			_ = rows.Close()
			return domain.AdminLevelProfiles{}, err
		}
		key := fmt.Sprintf("%d:%s", userID, track)
		if _, exists := seenOverlay[key]; exists {
			continue
		}
		seenOverlay[key] = struct{}{}
		profilePosition, profileFound := profileIndex[userID]
		trackPosition, trackFound := trackIndex[track]
		if !profileFound || !trackFound {
			continue
		}
		item := &profiles[profilePosition].Tracks[trackPosition]
		item.TemporaryLevelID = temporaryID
		item.TemporaryTargetLevel = targetLevel
		item.StartedAtMS = startedAtMS
		item.ExpiresAtMS = expiresAtMS
		item.DisplayValue = targetRequiredValue + windowIncrement
		item.DisplayLevel = levelForValue(rulesByTrack[track], item.DisplayValue)
		if item.DisplayLevel < targetLevel {
			// 目标规则在下发时已校验并快照阈值；有效期内后续禁用配置不能让已生效 overlay 倒退。
			item.DisplayLevel = targetLevel
		}
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return domain.AdminLevelProfiles{}, err
	}
	if err := rows.Close(); err != nil {
		return domain.AdminLevelProfiles{}, err
	}
	return domain.AdminLevelProfiles{Profiles: profiles, ServerTimeMS: nowMS}, nil
}

func (r *Repository) adminTrackProfile(ctx context.Context, q queryer, userID int64, track string, nowMS int64) (domain.AdminTrackProfile, error) {
	item := domain.AdminTrackProfile{Track: track}
	row := q.QueryRowContext(ctx, `
		SELECT total_value, current_level
		FROM user_growth_level_accounts
		WHERE app_code = ? AND user_id = ? AND track = ?`, appcode.FromContext(ctx), userID, track)
	if err := row.Scan(&item.RealTotalValue, &item.RealLevel); err != nil && err != sql.ErrNoRows {
		return domain.AdminTrackProfile{}, err
	}
	item.DisplayLevel = item.RealLevel
	item.DisplayValue = item.RealTotalValue
	var targetRequired int64
	err := q.QueryRowContext(ctx, `
		SELECT temporary_level_id, target_level, target_required_value, started_at_ms, expires_at_ms
		FROM growth_temporary_levels
		WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active'
		  AND started_at_ms <= ? AND expires_at_ms > ?
		ORDER BY generation DESC LIMIT 1`,
		appcode.FromContext(ctx), userID, track, nowMS, nowMS,
	).Scan(&item.TemporaryLevelID, &item.TemporaryTargetLevel, &targetRequired, &item.StartedAtMS, &item.ExpiresAtMS)
	if err == sql.ErrNoRows {
		return item, nil
	}
	if err != nil {
		return domain.AdminTrackProfile{}, err
	}
	increment, err := r.temporaryWindowValue(ctx, q, userID, track, item.StartedAtMS, item.ExpiresAtMS)
	if err != nil {
		return domain.AdminTrackProfile{}, err
	}
	rules, err := r.listActiveLevelRules(ctx, q, track)
	if err != nil {
		return domain.AdminTrackProfile{}, err
	}
	item.DisplayValue = targetRequired + increment
	item.DisplayLevel = levelForValue(rules, item.DisplayValue)
	if item.DisplayLevel < item.TemporaryTargetLevel {
		item.DisplayLevel = item.TemporaryTargetLevel
	}
	return item, nil
}

func (r *Repository) temporaryWindowValue(ctx context.Context, q queryer, userID int64, track string, startMS int64, endMS int64) (int64, error) {
	var value int64
	// occurred_at_ms, not processing time, defines the experience window. Late events outside the window cannot leak into overlay progress.
	err := q.QueryRowContext(ctx, `
		SELECT COALESCE(SUM(value_delta), 0)
		FROM growth_level_value_events
		WHERE app_code = ? AND user_id = ? AND track = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
		appcode.FromContext(ctx), userID, track, startMS, endMS,
	).Scan(&value)
	return value, err
}

// AdjustTemporaryUserLevels locks both requested tracks in deterministic order and commits command idempotency,
// supersession, overlay rows and temporary reward jobs as one transaction.
func (r *Repository) AdjustTemporaryUserLevels(ctx context.Context, command domain.AdjustTemporaryLevelsCommand, nowMS int64) (domain.AdminUserLevelProfile, error) {
	if r == nil || r.db == nil {
		return domain.AdminUserLevelProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.AdminUserLevelProfile{}, err
	}
	defer func() { _ = tx.Rollback() }()

	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO growth_temporary_level_commands
		(app_code, command_id, request_hash, user_id, operator_admin_id, created_at_ms)
		VALUES (?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), command.CommandID, command.RequestHash, command.UserID, command.OperatorAdminID, nowMS)
	if err != nil {
		return domain.AdminUserLevelProfile{}, err
	}
	inserted, err := result.RowsAffected()
	if err != nil {
		return domain.AdminUserLevelProfile{}, err
	}
	if inserted == 0 {
		var requestHash string
		var userID int64
		if err := tx.QueryRowContext(ctx, `SELECT request_hash, user_id FROM growth_temporary_level_commands WHERE app_code = ? AND command_id = ? FOR UPDATE`, appcode.FromContext(ctx), command.CommandID).Scan(&requestHash, &userID); err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		if requestHash != command.RequestHash || userID != command.UserID {
			return domain.AdminUserLevelProfile{}, xerr.New(xerr.IdempotencyConflict, "temporary level command payload conflicts with existing command")
		}
		if err := tx.Commit(); err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		return r.singleAdminLevelProfile(ctx, command.UserID, nowMS)
	}

	for _, adjustment := range command.Adjustments {
		account, err := r.ensureLevelAccountForUpdate(ctx, tx, command.UserID, adjustment.Track, nowMS)
		if err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		rules, err := r.listActiveLevelRules(ctx, tx, adjustment.Track)
		if err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		targetRequired, found := requiredValueForLevel(rules, adjustment.Level)
		if !found {
			return domain.AdminUserLevelProfile{}, xerr.New(xerr.NotFound, "active level rule not found")
		}
		if adjustment.Level <= account.CurrentLevel {
			return domain.AdminUserLevelProfile{}, xerr.New(xerr.Conflict, "temporary target level must be higher than real level")
		}
		// 新调整只能覆盖仍在有效期内的 overlay；先把已越过 end 边界但 cron 尚未处理的旧事实按正常到期语义物化。
		if err := r.materializeExpiredTemporaryLevelsForUpdate(ctx, tx, command.UserID, adjustment.Track, rules, nowMS); err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		generation, err := r.nextTemporaryGeneration(ctx, tx, command.UserID, adjustment.Track)
		if err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		// Superseded records never emit an expiry notice. Common rewards are moved to the new generation below;
		// only rewards outside the new target remain associated with the old record and enter revoke processing.
		oldIDs, err := r.supersedeTemporaryLevels(ctx, tx, command.UserID, adjustment.Track, nowMS)
		if err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		temporaryID := idgen.New("tmplevel")
		durationMS := int64(adjustment.DurationDays) * temporaryLevelDayMS
		if durationMS <= 0 || durationMS/temporaryLevelDayMS != int64(adjustment.DurationDays) || nowMS > math.MaxInt64-durationMS {
			return domain.AdminUserLevelProfile{}, xerr.New(xerr.InvalidArgument, "temporary level duration exceeds epoch millisecond range")
		}
		expiresAtMS := nowMS + durationMS
		_, err = tx.ExecContext(ctx, `
			INSERT INTO growth_temporary_levels (
			  app_code, temporary_level_id, command_id, user_id, track, target_level, target_required_value,
			  baseline_total_value, started_at_ms, expires_at_ms, status, generation, operator_admin_id, reason,
			  activation_notice_status, expiry_notice_status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, 'pending', 'skipped', ?, ?)`,
			appcode.FromContext(ctx), temporaryID, command.CommandID, command.UserID, adjustment.Track,
			adjustment.Level, targetRequired, account.TotalValue, nowMS, expiresAtMS, generation,
			command.OperatorAdminID, command.Reason, nowMS, nowMS,
		)
		if err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		for _, oldID := range oldIDs {
			if err := r.carryTemporaryRewardsToGeneration(ctx, tx, oldID, temporaryID, generation, adjustment.Level, expiresAtMS, nowMS); err != nil {
				return domain.AdminUserLevelProfile{}, err
			}
			if err := r.prepareTemporaryRewardsForRevocation(ctx, tx, oldID, 0, nowMS); err != nil {
				return domain.AdminUserLevelProfile{}, err
			}
		}
		tiers, err := r.listActiveLevelTiers(ctx, tx, adjustment.Track)
		if err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
		if _, err := r.createTemporaryRewardJobs(ctx, tx, command.UserID, adjustment.Track, adjustment.Level, temporaryID, generation, expiresAtMS, rules, tiers, nowMS); err != nil {
			return domain.AdminUserLevelProfile{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.AdminUserLevelProfile{}, err
	}
	return r.singleAdminLevelProfile(ctx, command.UserID, nowMS)
}

// carryTemporaryRewardsToGeneration transfers rewards that remain covered by the replacement target instead of
// revoking and regranting them. This makes one source/level have exactly one live temporary generation, so a later
// organic promotion cannot accidentally preserve both the old and the new wallet grants.
func (r *Repository) carryTemporaryRewardsToGeneration(ctx context.Context, tx *sql.Tx, oldTemporaryID string, newTemporaryID string, generation int32, targetLevel int32, expiresAtMS int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs
		SET temporary_level_id = ?, grant_generation = ?, temporary_expires_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND temporary_level_id = ? AND reward_origin = 'temporary'
		  AND is_permanent = 0 AND reward_level <= ?
		  AND status IN ('pending','failed','running','granted')
		  AND revoke_status = 'none'`,
		newTemporaryID, generation, expiresAtMS, nowMS,
		appcode.FromContext(ctx), oldTemporaryID, targetLevel,
	)
	return err
}

func (r *Repository) singleAdminLevelProfile(ctx context.Context, userID int64, nowMS int64) (domain.AdminUserLevelProfile, error) {
	profiles, err := r.BatchGetUserLevelAdminProfiles(ctx, []int64{userID}, nowMS)
	if err != nil {
		return domain.AdminUserLevelProfile{}, err
	}
	if len(profiles.Profiles) == 0 {
		return domain.AdminUserLevelProfile{UserID: userID, ServerTimeMS: nowMS}, nil
	}
	return profiles.Profiles[0], nil
}

func (r *Repository) nextTemporaryGeneration(ctx context.Context, tx *sql.Tx, userID int64, track string) (int32, error) {
	var generation int32
	err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(generation), 0) + 1 FROM growth_temporary_levels WHERE app_code = ? AND user_id = ? AND track = ?`, appcode.FromContext(ctx), userID, track).Scan(&generation)
	return generation, err
}

func (r *Repository) supersedeTemporaryLevels(ctx context.Context, tx *sql.Tx, userID int64, track string, nowMS int64) ([]string, error) {
	rows, err := tx.QueryContext(ctx, `SELECT temporary_level_id FROM growth_temporary_levels WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active' AND expires_at_ms > ? FOR UPDATE`, appcode.FromContext(ctx), userID, track, nowMS)
	if err != nil {
		return nil, err
	}
	var ids []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			_ = rows.Close()
			return nil, err
		}
		ids = append(ids, id)
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	if len(ids) > 0 {
		_, err = tx.ExecContext(ctx, `UPDATE growth_temporary_levels SET status = 'superseded', expiry_notice_status = 'skipped', locked_by = '', locked_until_ms = 0, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active' AND expires_at_ms > ?`, nowMS, appcode.FromContext(ctx), userID, track, nowMS)
	}
	return ids, err
}

func (r *Repository) materializeExpiredTemporaryLevelsForUpdate(ctx context.Context, tx *sql.Tx, userID int64, track string, rules []domain.Rule, nowMS int64) error {
	rows, err := tx.QueryContext(ctx, temporaryLevelSelectSQL()+`
		WHERE app_code = ? AND user_id = ? AND track = ? AND status = 'active' AND expires_at_ms <= ?
		ORDER BY generation ASC FOR UPDATE`, appcode.FromContext(ctx), userID, track, nowMS)
	if err != nil {
		return err
	}
	items, err := scanTemporaryLevels(rows)
	_ = rows.Close()
	if err != nil {
		return err
	}
	for _, item := range items {
		increment, err := r.temporaryWindowValue(ctx, tx, item.UserID, item.Track, item.StartedAtMS, item.ExpiresAtMS)
		if err != nil {
			return err
		}
		item.FinalLevel = levelForValue(rules, item.BaselineTotalValue+increment)
		if item.ActivationNoticeStatus == domain.NoticeStatusPending || item.ActivationNoticeStatus == domain.NoticeStatusFailed {
			item.ActivationNoticeStatus = domain.NoticeStatusSkipped
		}
		if item.FinalLevel < item.TargetLevel {
			item.ExpiryNoticeStatus = domain.NoticeStatusPending
		} else {
			item.ExpiryNoticeStatus = domain.NoticeStatusSkipped
		}
		if err := r.prepareTemporaryRewardsForRevocation(ctx, tx, item.TemporaryLevelID, item.FinalLevel, nowMS); err != nil {
			return err
		}
		if _, err := tx.ExecContext(ctx, `
			UPDATE growth_temporary_levels
			SET status = 'expired', final_level = ?, activation_notice_status = ?, expiry_notice_status = ?,
			    locked_by = '', locked_until_ms = 0, next_retry_at_ms = 0, updated_at_ms = ?
			WHERE app_code = ? AND temporary_level_id = ? AND status = 'active'`,
			item.FinalLevel, item.ActivationNoticeStatus, item.ExpiryNoticeStatus, nowMS,
			appcode.FromContext(ctx), item.TemporaryLevelID,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) createTemporaryRewardJobs(ctx context.Context, tx *sql.Tx, userID int64, track string, targetLevel int32, temporaryID string, generation int32, expiresAtMS int64, rules []domain.Rule, tiers []domain.Tier, nowMS int64) (int64, error) {
	var created int64
	for _, rule := range rules {
		if rule.Level < 1 || rule.Level > targetLevel || (rule.RewardResourceGroupID <= 0 && avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON) <= 0 && shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON) <= 0) {
			continue
		}
		changed, err := r.insertTemporaryRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.Level, rule.RewardResourceGroupID, temporaryID, generation, expiresAtMS, nowMS)
		if err != nil {
			return 0, err
		}
		if changed {
			created++
		}
	}
	for _, tier := range tiers {
		if tier.MinLevel < 1 || tier.MinLevel > targetLevel || tier.RewardResourceGroupID <= 0 {
			continue
		}
		changed, err := r.insertTemporaryRewardJob(ctx, tx, userID, track, domain.RewardSourceTier, fmt.Sprintf("tier:%d", tier.TierID), tier.MinLevel, tier.RewardResourceGroupID, temporaryID, generation, expiresAtMS, nowMS)
		if err != nil {
			return 0, err
		}
		if changed {
			created++
		}
	}
	return created, nil
}

func (r *Repository) insertTemporaryRewardJob(ctx context.Context, tx *sql.Tx, userID int64, track string, sourceType string, sourceID string, rewardLevel int32, resourceGroupID int64, temporaryID string, generation int32, expiresAtMS int64, nowMS int64) (bool, error) {
	// Any organic/permanent row means this user already owns the reward; temporary generations must not duplicate it.
	var permanentCount int
	if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_jobs WHERE app_code = ? AND user_id = ? AND track = ? AND reward_source_type = ? AND reward_source_id = ? AND is_permanent = 1 AND status <> 'canceled'`, appcode.FromContext(ctx), userID, track, sourceType, sourceID).Scan(&permanentCount); err != nil {
		return false, err
	}
	if permanentCount > 0 {
		return false, nil
	}
	rewardJobID := idgen.New("greward")
	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO growth_level_reward_jobs (
		  app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id, resource_group_id,
		  wallet_command_id, reward_origin, temporary_level_id, grant_generation, reward_level,
		  is_permanent, temporary_expires_at_ms, revoke_status, status, next_retry_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'temporary', ?, ?, ?, 0, ?, 'none', 'pending', 0, ?, ?)`,
		appcode.FromContext(ctx), rewardJobID, userID, track, sourceType, sourceID, resourceGroupID,
		"growth_level_reward:"+rewardJobID, temporaryID, generation, rewardLevel, expiresAtMS, nowMS, nowMS)
	if err != nil {
		return false, err
	}
	affected, err := result.RowsAffected()
	return affected > 0, err
}

// ClaimTemporaryLevelWork first materializes expiry facts in the same transaction, then leases only message work.
// Reward revocations are leased from their own child rows because one reward job may contain three wallet grants.
func (r *Repository) ClaimTemporaryLevelWork(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.TemporaryLevelWork, 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 func() { _ = tx.Rollback() }()
	rows, err := tx.QueryContext(ctx, temporaryLevelSelectSQL()+`
		WHERE app_code = ? AND next_retry_at_ms <= ? AND (locked_until_ms = 0 OR locked_until_ms <= ?)
		  AND ((status = 'active' AND expires_at_ms <= ?)
		    OR (status = 'active' AND expires_at_ms > ? AND activation_notice_status IN ('pending','failed'))
		    OR (status = 'expired' AND expiry_notice_status IN ('pending','failed')))
		ORDER BY expires_at_ms ASC, created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`,
		appcode.FromContext(ctx), nowMS, nowMS, nowMS, nowMS, batchSize)
	if err != nil {
		return nil, err
	}
	levels, err := scanTemporaryLevels(rows)
	_ = rows.Close()
	if err != nil {
		return nil, err
	}
	works := make([]domain.TemporaryLevelWork, 0, len(levels))
	lockUntil := nowMS + lockTTL.Milliseconds()
	for _, item := range levels {
		if item.Status == domain.TemporaryLevelStatusActive && item.ExpiresAtMS <= nowMS {
			increment, err := r.temporaryWindowValue(ctx, tx, item.UserID, item.Track, item.StartedAtMS, item.ExpiresAtMS)
			if err != nil {
				return nil, err
			}
			rules, err := r.listActiveLevelRules(ctx, tx, item.Track)
			if err != nil {
				return nil, err
			}
			item.FinalLevel = levelForValue(rules, item.BaselineTotalValue+increment)
			item.Status = domain.TemporaryLevelStatusExpired
			if item.ActivationNoticeStatus == domain.NoticeStatusPending || item.ActivationNoticeStatus == domain.NoticeStatusFailed {
				// A stale activation message after expiry is misleading; expiry truth is still sent when the user fell below target.
				item.ActivationNoticeStatus = domain.NoticeStatusSkipped
			}
			if item.FinalLevel < item.TargetLevel {
				item.ExpiryNoticeStatus = domain.NoticeStatusPending
			} else {
				item.ExpiryNoticeStatus = domain.NoticeStatusSkipped
			}
			if err := r.prepareTemporaryRewardsForRevocation(ctx, tx, item.TemporaryLevelID, item.FinalLevel, nowMS); err != nil {
				return nil, err
			}
			_, err = tx.ExecContext(ctx, `
				UPDATE growth_temporary_levels
				SET status = 'expired', final_level = ?, activation_notice_status = ?, expiry_notice_status = ?, updated_at_ms = ?
				WHERE app_code = ? AND temporary_level_id = ?`, item.FinalLevel, item.ActivationNoticeStatus, item.ExpiryNoticeStatus, nowMS, appcode.FromContext(ctx), item.TemporaryLevelID)
			if err != nil {
				return nil, err
			}
		}
		work := domain.TemporaryLevelWork{TemporaryLevel: item}
		work.SendActivationNotice = item.Status == domain.TemporaryLevelStatusActive && item.ExpiresAtMS > nowMS && (item.ActivationNoticeStatus == domain.NoticeStatusPending || item.ActivationNoticeStatus == domain.NoticeStatusFailed)
		work.SendExpiryNotice = item.Status == domain.TemporaryLevelStatusExpired && (item.ExpiryNoticeStatus == domain.NoticeStatusPending || item.ExpiryNoticeStatus == domain.NoticeStatusFailed)
		_, err := tx.ExecContext(ctx, `UPDATE growth_temporary_levels SET locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, updated_at_ms = ? WHERE app_code = ? AND temporary_level_id = ?`, workerID, lockUntil, nowMS, appcode.FromContext(ctx), item.TemporaryLevelID)
		if err != nil {
			return nil, err
		}
		works = append(works, work)
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return works, nil
}

// prepareTemporaryRewardsForRevocation promotes rewards earned by the real final level and schedules every wallet
// grant above it for revoke. Ungranted jobs above the final level are canceled, preventing a late worker grant.
func (r *Repository) prepareTemporaryRewardsForRevocation(ctx context.Context, tx *sql.Tx, temporaryID string, finalLevel int32, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs
		SET is_permanent = 1, revoke_status = 'skipped', updated_at_ms = ?
		WHERE app_code = ? AND temporary_level_id = ? AND is_permanent = 0 AND reward_level <= ?`,
		nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs
		SET status = 'canceled',
		    revoke_status = CASE
		      WHEN EXISTS (SELECT 1 FROM growth_level_reward_wallet_grants g WHERE g.app_code = growth_level_reward_jobs.app_code AND g.reward_job_id = growth_level_reward_jobs.reward_job_id)
		      THEN 'pending' ELSE 'skipped' END,
		    locked_by = '', locked_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND temporary_level_id = ? AND is_permanent = 0 AND reward_level > ? AND status IN ('pending','failed')`,
		nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs j
		SET revoke_status = CASE
		      WHEN EXISTS (SELECT 1 FROM growth_level_reward_wallet_grants g WHERE g.app_code = j.app_code AND g.reward_job_id = j.reward_job_id)
		      THEN 'pending' ELSE 'skipped' END,
		    updated_at_ms = ?
		WHERE j.app_code = ? AND j.temporary_level_id = ? AND j.is_permanent = 0 AND j.reward_level > ? AND j.status = 'granted'`,
		nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE growth_level_reward_wallet_grants g
		JOIN growth_level_reward_jobs j ON j.app_code = g.app_code AND j.reward_job_id = g.reward_job_id
		SET g.revoke_status = 'pending', g.revoke_next_retry_at_ms = 0, g.updated_at_ms = ?
		WHERE j.app_code = ? AND j.temporary_level_id = ? AND j.is_permanent = 0 AND j.reward_level > ?
		  AND j.status IN ('granted','canceled') AND g.revoke_status IN ('none','failed')`,
		nowMS, appcode.FromContext(ctx), temporaryID, finalLevel)
	return err
}

func (r *Repository) MarkTemporaryLevelNotices(ctx context.Context, temporaryLevelID string, activationStatus string, expiryStatus string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	sets := []string{"locked_by = ''", "locked_until_ms = 0", "failure_reason = ?", "next_retry_at_ms = ?", "updated_at_ms = ?"}
	args := []any{truncateGrowthFailure(failureReason), nextRetryAtMS, nowMS}
	if activationStatus != "" {
		sets = append(sets, "activation_notice_status = ?")
		args = append(args, activationStatus)
	}
	if expiryStatus != "" {
		sets = append(sets, "expiry_notice_status = ?")
		args = append(args, expiryStatus)
	}
	args = append(args, appcode.FromContext(ctx), temporaryLevelID)
	_, err := r.db.ExecContext(ctx, `UPDATE growth_temporary_levels SET `+strings.Join(sets, ", ")+` WHERE app_code = ? AND temporary_level_id = ?`, args...)
	return err
}

func (r *Repository) ClaimLevelRewardRevocations(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardRevocation, 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 func() { _ = tx.Rollback() }()
	rows, err := tx.QueryContext(ctx, `
		SELECT g.reward_job_id, g.wallet_grant_id, j.temporary_level_id, j.user_id, j.track, g.revoke_attempt_count
		FROM growth_level_reward_wallet_grants g
		JOIN growth_level_reward_jobs j ON j.app_code = g.app_code AND j.reward_job_id = g.reward_job_id
		WHERE g.app_code = ? AND g.revoke_status IN ('pending','failed','running') AND g.revoke_next_retry_at_ms <= ?
		  AND (g.revoke_locked_until_ms = 0 OR g.revoke_locked_until_ms <= ?)
		ORDER BY g.created_at_ms ASC LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS, nowMS, batchSize)
	if err != nil {
		return nil, err
	}
	items := make([]domain.RewardRevocation, 0)
	for rows.Next() {
		var item domain.RewardRevocation
		if err := rows.Scan(&item.RewardJobID, &item.WalletGrantID, &item.TemporaryLevelID, &item.UserID, &item.Track, &item.AttemptCount); err != nil {
			_ = rows.Close()
			return nil, err
		}
		items = append(items, item)
	}
	_ = rows.Close()
	lockUntil := nowMS + lockTTL.Milliseconds()
	for _, item := range items {
		_, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'running', revoke_attempt_count = revoke_attempt_count + 1, revoke_locked_by = ?, revoke_locked_until_ms = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND wallet_grant_id = ?`, workerID, lockUntil, nowMS, appcode.FromContext(ctx), item.RewardJobID, item.WalletGrantID)
		if err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return items, nil
}

func (r *Repository) MarkLevelRewardRevoked(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	_, err = tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'revoked', revoked_at_ms = ?, revoke_locked_by = '', revoke_locked_until_ms = 0, revoke_failure_reason = '', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND wallet_grant_id = ?`, nowMS, nowMS, appcode.FromContext(ctx), rewardJobID, walletGrantID)
	if err != nil {
		return err
	}
	var remaining int
	if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_wallet_grants WHERE app_code = ? AND reward_job_id = ? AND revoke_status NOT IN ('revoked','skipped')`, appcode.FromContext(ctx), rewardJobID).Scan(&remaining); err != nil {
		return err
	}
	if remaining == 0 {
		if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = 'revoked', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
			return err
		}
	}
	return tx.Commit()
}

func (r *Repository) MarkLevelRewardRevokeFailed(ctx context.Context, rewardJobID string, walletGrantID string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()
	if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'failed', revoke_next_retry_at_ms = ?, revoke_locked_by = '', revoke_locked_until_ms = 0, revoke_failure_reason = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND wallet_grant_id = ?`, nextRetryAtMS, truncateGrowthFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID, walletGrantID); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = 'failed', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND revoke_status <> 'revoked'`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
		return err
	}
	return tx.Commit()
}

func temporaryLevelSelectSQL() string {
	return `SELECT app_code, temporary_level_id, command_id, user_id, track, target_level, target_required_value,
	               baseline_total_value, started_at_ms, expires_at_ms, status, generation, operator_admin_id, reason,
	               final_level, activation_notice_status, expiry_notice_status, attempt_count, failure_reason,
	               created_at_ms, updated_at_ms FROM growth_temporary_levels `
}

func scanTemporaryLevels(rows *sql.Rows) ([]domain.TemporaryLevel, error) {
	items := make([]domain.TemporaryLevel, 0)
	for rows.Next() {
		var item domain.TemporaryLevel
		if err := rows.Scan(&item.AppCode, &item.TemporaryLevelID, &item.CommandID, &item.UserID, &item.Track,
			&item.TargetLevel, &item.TargetRequiredValue, &item.BaselineTotalValue, &item.StartedAtMS, &item.ExpiresAtMS,
			&item.Status, &item.Generation, &item.OperatorAdminID, &item.Reason, &item.FinalLevel,
			&item.ActivationNoticeStatus, &item.ExpiryNoticeStatus, &item.AttemptCount, &item.FailureReason,
			&item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}
