package mysql

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

	mysqldriver "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/xerr"
	domain "hyapp/services/activity-service/internal/domain/growth"
)

const (
	// MySQL 会主动回滚死锁受害事务；重试必须从 BeginTx 开始重建完整事务，不能在旧 tx 上续跑。
	growthTransactionMaxAttempts = 5
	// 四次等待分别以 10/20/30/40ms 为基线，再加入 0-10ms 的稳定抖动，既限制请求预算也避免热点事件同步重撞。
	growthTransactionRetryStep         = 10 * time.Millisecond
	growthTransactionRetryJitterWindow = 10 * time.Millisecond
)

type queryer interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

// ListLevelOverview 返回 App 等级总览；缺失账户按 0 级、0 累计渲染。
func (r *Repository) ListLevelOverview(ctx context.Context, userID int64, nowMS int64) (domain.Overview, error) {
	if r == nil || r.db == nil {
		return domain.Overview{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tracks, err := r.listActiveLevelTracks(ctx, r.db)
	if err != nil {
		return domain.Overview{}, err
	}
	items := make([]domain.TrackOverview, 0, len(tracks))
	for _, track := range tracks {
		overview, err := r.levelTrackOverview(ctx, userID, track, nowMS)
		if err != nil {
			return domain.Overview{}, err
		}
		items = append(items, overview)
	}
	return domain.Overview{Tracks: items, ServerTimeMS: nowMS}, nil
}

// GetLevelTrack 返回单轨道完整阈值、等级段和当前用户摘要。
func (r *Repository) GetLevelTrack(ctx context.Context, userID int64, track string, nowMS int64) (domain.TrackDetail, error) {
	if r == nil || r.db == nil {
		return domain.TrackDetail{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	levelTrack, err := r.getActiveLevelTrack(ctx, r.db, track)
	if err != nil {
		return domain.TrackDetail{}, err
	}
	overview, err := r.levelTrackOverview(ctx, userID, levelTrack, nowMS)
	if err != nil {
		return domain.TrackDetail{}, err
	}
	rules, err := r.listActiveLevelRules(ctx, r.db, track)
	if err != nil {
		return domain.TrackDetail{}, err
	}
	tiers, err := r.listActiveLevelTiers(ctx, r.db, track)
	if err != nil {
		return domain.TrackDetail{}, err
	}
	return domain.TrackDetail{Overview: overview, Rules: rules, Tiers: tiers, ServerTimeMS: nowMS}, nil
}

// BatchGetUserLevelDisplayProfiles 返回资料卡和房间列表需要的等级展示投影。
func (r *Repository) BatchGetUserLevelDisplayProfiles(ctx context.Context, userIDs []int64, nowMS int64) (domain.LevelDisplayProfiles, error) {
	if r == nil || r.db == nil {
		return domain.LevelDisplayProfiles{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	userIDs = uniquePositiveInt64s(userIDs)
	if len(userIDs) == 0 {
		return domain.LevelDisplayProfiles{Profiles: []domain.UserLevelDisplayProfile{}, ServerTimeMS: nowMS}, nil
	}
	profiles := make(map[int64]domain.UserLevelDisplayProfile, len(userIDs))
	for _, userID := range userIDs {
		profiles[userID] = defaultLevelDisplayProfile(userID)
	}
	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,
		       wealth_level, wealth_tier_id, wealth_avatar_frame_resource_id, wealth_badge_resource_id, wealth_badge_source_level,
		       game_level, game_tier_id, game_avatar_frame_resource_id, game_badge_resource_id, game_badge_source_level,
		       charm_level, charm_tier_id, charm_avatar_frame_resource_id, charm_badge_resource_id, charm_badge_source_level,
		       updated_at_ms
		FROM user_level_display_profiles
		WHERE app_code = ? AND user_id IN (`+placeholders(len(userIDs))+`)`, args...)
	if err != nil {
		return domain.LevelDisplayProfiles{}, err
	}
	defer rows.Close()
	for rows.Next() {
		var item domain.UserLevelDisplayProfile
		var updatedAtMS int64
		if err := rows.Scan(
			&item.UserID,
			&item.Wealth.Level, &item.Wealth.TierID, &item.Wealth.AvatarFrameResourceID, &item.Wealth.BadgeResourceID, &item.Wealth.BadgeSourceLevel,
			&item.Game.Level, &item.Game.TierID, &item.Game.AvatarFrameResourceID, &item.Game.BadgeResourceID, &item.Game.BadgeSourceLevel,
			&item.Charm.Level, &item.Charm.TierID, &item.Charm.AvatarFrameResourceID, &item.Charm.BadgeResourceID, &item.Charm.BadgeSourceLevel,
			&updatedAtMS,
		); err != nil {
			return domain.LevelDisplayProfiles{}, err
		}
		item.Wealth.Track = domain.TrackWealth
		item.Game.Track = domain.TrackGame
		item.Charm.Track = domain.TrackCharm
		item.Wealth.UpdatedAtMS = updatedAtMS
		item.Game.UpdatedAtMS = updatedAtMS
		item.Charm.UpdatedAtMS = updatedAtMS
		profiles[item.UserID] = item
	}
	if err := rows.Err(); err != nil {
		return domain.LevelDisplayProfiles{}, err
	}
	adminProfiles, err := r.BatchGetUserLevelAdminProfiles(ctx, userIDs, nowMS)
	if err != nil {
		return domain.LevelDisplayProfiles{}, err
	}
	adminTracks := make(map[int64]map[string]domain.AdminTrackProfile, len(adminProfiles.Profiles))
	for _, profile := range adminProfiles.Profiles {
		byTrack := make(map[string]domain.AdminTrackProfile, len(profile.Tracks))
		for _, track := range profile.Tracks {
			byTrack[track.Track] = track
		}
		adminTracks[profile.UserID] = byTrack
	}
	rulesByTrack := make(map[string][]domain.Rule, 2)
	tiersByTrack := make(map[string][]domain.Tier, 2)
	for _, track := range []string{domain.TrackWealth, domain.TrackCharm} {
		rules, err := r.listActiveLevelRules(ctx, r.db, track)
		if err != nil {
			return domain.LevelDisplayProfiles{}, err
		}
		tiers, err := r.listActiveLevelTiers(ctx, r.db, track)
		if err != nil {
			return domain.LevelDisplayProfiles{}, err
		}
		rulesByTrack[track] = rules
		tiersByTrack[track] = tiers
	}
	result := make([]domain.UserLevelDisplayProfile, 0, len(userIDs))
	for _, userID := range userIDs {
		item := profiles[userID]
		// 物化资料仍是真实账户快照；整批 admin profile 只在读取时覆盖有效 wealth/charm overlay，
		// 既让到期毫秒立即生效，也避免房间资料批量接口按用户和轨道产生 N+1。
		for _, track := range []string{domain.TrackWealth, domain.TrackCharm} {
			adminTrack := adminTracks[userID][track]
			if adminTrack.TemporaryLevelID == "" {
				continue
			}
			rules := rulesByTrack[track]
			tiers := tiersByTrack[track]
			tier := tierForLevel(tiers, adminTrack.DisplayLevel)
			badgeID, badgeLevel := levelRuleBadgeForLevel(rules, adminTrack.DisplayLevel)
			display := domain.LevelDisplayTrackProfile{
				Track: track, Level: adminTrack.DisplayLevel, TierID: tier.TierID,
				AvatarFrameResourceID: levelRuleAvatarFrameForLevel(rules, adminTrack.DisplayLevel, tier.DisplayAvatarFrameResourceID),
				BadgeResourceID:       badgeID, BadgeSourceLevel: badgeLevel, UpdatedAtMS: nowMS,
			}
			if track == domain.TrackWealth {
				item.Wealth = display
			} else {
				item.Charm = display
			}
		}
		result = append(result, item)
	}
	return domain.LevelDisplayProfiles{Profiles: result, ServerTimeMS: nowMS}, nil
}

// ListLevelConfig 返回后台等级配置页需要的轨道、单级阈值和等级段展示配置。
func (r *Repository) ListLevelConfig(ctx context.Context, query domain.ConfigQuery, nowMS int64) (domain.Config, error) {
	if r == nil || r.db == nil {
		return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tracks, err := r.listLevelTracks(ctx, r.db, query.Track, query.Status)
	if err != nil {
		return domain.Config{}, err
	}
	rules, err := r.listLevelRules(ctx, r.db, query.Track, query.Status)
	if err != nil {
		return domain.Config{}, err
	}
	tiers, err := r.listLevelTiers(ctx, r.db, query.Track, query.Status)
	if err != nil {
		return domain.Config{}, err
	}
	return domain.Config{Tracks: tracks, Rules: rules, Tiers: tiers, ServerTimeMS: nowMS}, nil
}

// ListLevelRewards 分页返回用户等级奖励发放状态。
func (r *Repository) ListLevelRewards(ctx context.Context, query domain.RewardQuery) ([]domain.RewardJob, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where := []string{"app_code = ?", "user_id = ?"}
	args := []any{appcode.FromContext(ctx), query.UserID}
	if query.Track != "" {
		where = append(where, "track = ?")
		args = append(args, query.Track)
	}
	if query.Status != "" {
		where = append(where, "status = ?")
		args = append(args, query.Status)
	}
	clause := "WHERE " + strings.Join(where, " AND ")
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_jobs `+clause, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	pageSize := normalizePageSize(query.PageSize)
	page := query.Page
	if page <= 0 {
		page = 1
	}
	args = append(args, pageSize, (page-1)*pageSize)
	rows, err := r.db.QueryContext(ctx, levelRewardSelectSQL()+clause+`
		ORDER BY created_at_ms DESC, reward_job_id DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items, err := scanLevelRewards(rows)
	if err != nil {
		return nil, 0, err
	}
	return items, total, nil
}

// ConsumeLevelEvent 在同一事务内完成事件幂等、累计值、升级历史、奖励任务和展示投影。
func (r *Repository) ConsumeLevelEvent(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error) {
	var lastErr error
	for attempt := 0; attempt < growthTransactionMaxAttempts; attempt++ {
		result, err := r.consumeLevelEventOnce(ctx, event, nowMS)
		if err == nil {
			return result, nil
		}
		if !isRetryableGrowthTransactionError(err) {
			return domain.EventResult{}, err
		}
		lastErr = err
		if attempt == growthTransactionMaxAttempts-1 {
			break
		}
		// MQ 会并发回放同一热门用户的礼物事件；本地短退避先吸收瞬时死锁冲突，避免每次 1213 都升级为 broker 秒级重试。
		timer := time.NewTimer(growthTransactionRetryDelay(event.EventID, attempt))
		select {
		case <-ctx.Done():
			if !timer.Stop() {
				<-timer.C
			}
			return domain.EventResult{}, ctx.Err()
		case <-timer.C:
		}
	}
	return domain.EventResult{}, lastErr
}

// consumeLevelEventOnce 只执行一次完整事务；调用方只会在 MySQL 已回滚的 1213 上重新 BeginTx。
func (r *Repository) consumeLevelEventOnce(ctx context.Context, event domain.ValueEvent, nowMS int64) (domain.EventResult, error) {
	if r == nil || r.db == nil {
		return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.EventResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	inserted, err := r.insertLevelValueEvent(ctx, tx, event, nowMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	if !inserted {
		if err := tx.Commit(); err != nil {
			return domain.EventResult{}, err
		}
		return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate}, nil
	}

	account, err := r.ensureLevelAccountForUpdate(ctx, tx, event.UserID, event.Track, nowMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	previousLevel := account.CurrentLevel
	previousTierID := account.CurrentTierID
	nextTotal := account.TotalValue + event.ValueDelta
	rules, err := r.listActiveLevelRules(ctx, tx, event.Track)
	if err != nil {
		return domain.EventResult{}, err
	}
	tiers, err := r.listActiveLevelTiers(ctx, tx, event.Track)
	if err != nil {
		return domain.EventResult{}, err
	}
	nextLevel := levelForValue(rules, nextTotal)
	nextTier := tierForLevel(tiers, nextLevel)
	nextTierID := nextTier.TierID
	levelChanged := nextLevel != previousLevel || nextTierID != previousTierID
	levelUpdatedAtMS := account.LevelUpdatedAtMS
	if nextLevel != previousLevel {
		levelUpdatedAtMS = nowMS
	}
	tierUpdatedAtMS := account.TierUpdatedAtMS
	if nextTierID != previousTierID {
		tierUpdatedAtMS = nowMS
	}
	if err := r.updateLevelAccount(ctx, tx, event.UserID, event.Track, nextTotal, nextLevel, nextTierID, levelUpdatedAtMS, tierUpdatedAtMS, nowMS); err != nil {
		return domain.EventResult{}, err
	}
	rewardJobCount := int64(0)
	if levelChanged {
		if err := r.insertLevelHistory(ctx, tx, event, previousLevel, nextLevel, previousTierID, nextTierID, nextTotal, nowMS); err != nil {
			return domain.EventResult{}, err
		}
		count, err := r.createLevelRewardJobs(ctx, tx, event.UserID, event.Track, previousLevel, nextLevel, previousTierID, nextTier, rules, nowMS)
		if err != nil {
			return domain.EventResult{}, err
		}
		rewardJobCount = count
	}
	// While an overlay is active, only value events whose business time falls in [start,end) advance its displayed
	// value. The real account above was updated independently and remains the sole permanent growth fact.
	if temporary, found, err := r.activeTemporaryLevelForUpdate(ctx, tx, event.UserID, event.Track, nowMS); err != nil {
		return domain.EventResult{}, err
	} else if found && event.OccurredAtMS >= temporary.StartedAtMS && event.OccurredAtMS < temporary.ExpiresAtMS {
		windowValue, err := r.temporaryWindowValue(ctx, tx, event.UserID, event.Track, temporary.StartedAtMS, temporary.ExpiresAtMS)
		if err != nil {
			return domain.EventResult{}, err
		}
		previousDisplayLevel := levelForValue(rules, temporary.TargetRequiredValue+windowValue-event.ValueDelta)
		nextDisplayLevel := levelForValue(rules, temporary.TargetRequiredValue+windowValue)
		if nextDisplayLevel > previousDisplayLevel {
			count, err := r.createTemporaryRewardJobs(ctx, tx, event.UserID, event.Track, nextDisplayLevel, temporary.TemporaryLevelID, temporary.Generation, temporary.ExpiresAtMS, rules, tiers, nowMS)
			if err != nil {
				return domain.EventResult{}, err
			}
			rewardJobCount += count
		}
	}
	badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
	avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
	if err := r.upsertLevelDisplayProfile(ctx, tx, event.UserID, event.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
		return domain.EventResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.EventResult{}, err
	}
	return domain.EventResult{
		EventID:        event.EventID,
		Status:         domain.EventStatusConsumed,
		LevelChanged:   levelChanged,
		PreviousLevel:  previousLevel,
		NewLevel:       nextLevel,
		RewardJobCount: rewardJobCount,
	}, nil
}

// SetUserLevel 在一个事务里把用户某条轨道直接设置到目标等级阈值。
// 经理中心需要支持降级，所以这里不能复用只接受正增量的 ConsumeLevelEvent；但仍写 growth_level_value_events 保留幂等事件和审计来源。
func (r *Repository) SetUserLevel(ctx context.Context, command domain.SetUserLevelCommand, nowMS int64) (domain.SetUserLevelResult, error) {
	if r == nil || r.db == nil {
		return domain.SetUserLevelResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	eventID := "manager_level:" + command.CommandID
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.SetUserLevelResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	account, err := r.ensureLevelAccountForUpdate(ctx, tx, command.UserID, command.Track, nowMS)
	if err != nil {
		return domain.SetUserLevelResult{}, err
	}
	rules, err := r.listActiveLevelRules(ctx, tx, command.Track)
	if err != nil {
		return domain.SetUserLevelResult{}, err
	}
	targetValue, ok := requiredValueForLevel(rules, command.Level)
	if !ok {
		return domain.SetUserLevelResult{}, xerr.New(xerr.NotFound, "level rule not found")
	}
	tiers, err := r.listActiveLevelTiers(ctx, tx, command.Track)
	if err != nil {
		return domain.SetUserLevelResult{}, err
	}
	delta := targetValue - account.TotalValue
	inserted, err := r.insertLevelValueEvent(ctx, tx, domain.ValueEvent{
		EventID:         eventID,
		SourceEventID:   command.CommandID,
		SourceService:   "manager_center",
		SourceEventType: "ManagerSetUserLevel",
		UserID:          command.UserID,
		Track:           command.Track,
		MetricType:      "manager_set_level",
		ValueDelta:      delta,
		OccurredAtMS:    nowMS,
		DimensionsJSON: normalizeJSONMap(map[string]any{
			"operator_user_id": command.OperatorUserID,
			"target_level":     command.Level,
			"reason":           command.Reason,
		}),
	}, nowMS)
	if err != nil {
		return domain.SetUserLevelResult{}, err
	}
	if !inserted {
		if err := tx.Commit(); err != nil {
			return domain.SetUserLevelResult{}, err
		}
		return domain.SetUserLevelResult{
			EventID:      eventID,
			Status:       domain.EventStatusDuplicate,
			Track:        command.Track,
			NewLevel:     account.CurrentLevel,
			TotalValue:   account.TotalValue,
			ServerTimeMS: nowMS,
		}, nil
	}

	previousLevel := account.CurrentLevel
	previousTierID := account.CurrentTierID
	nextLevel := levelForValue(rules, targetValue)
	nextTier := tierForLevel(tiers, nextLevel)
	nextTierID := nextTier.TierID
	levelUpdatedAtMS := account.LevelUpdatedAtMS
	if nextLevel != previousLevel {
		levelUpdatedAtMS = nowMS
	}
	tierUpdatedAtMS := account.TierUpdatedAtMS
	if nextTierID != previousTierID {
		tierUpdatedAtMS = nowMS
	}
	if err := r.updateLevelAccount(ctx, tx, command.UserID, command.Track, targetValue, nextLevel, nextTierID, levelUpdatedAtMS, tierUpdatedAtMS, nowMS); err != nil {
		return domain.SetUserLevelResult{}, err
	}
	if err := r.insertLevelHistory(ctx, tx, domain.ValueEvent{EventID: eventID, UserID: command.UserID, Track: command.Track}, previousLevel, nextLevel, previousTierID, nextTierID, targetValue, nowMS); err != nil {
		return domain.SetUserLevelResult{}, err
	}
	// 经理直接设置等级时要补齐从 1 级到目标等级之间缺失的奖励；唯一键保证已发过的等级不会重复入队。
	rewardJobCount, err := r.createLevelRewardJobs(ctx, tx, command.UserID, command.Track, 0, nextLevel, 0, nextTier, rules, nowMS)
	if err != nil {
		return domain.SetUserLevelResult{}, err
	}
	badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, nextLevel)
	avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, nextLevel, nextTier.DisplayAvatarFrameResourceID)
	if err := r.upsertLevelDisplayProfile(ctx, tx, command.UserID, command.Track, nextLevel, nextTier, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS); err != nil {
		return domain.SetUserLevelResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.SetUserLevelResult{}, err
	}
	return domain.SetUserLevelResult{
		EventID:        eventID,
		Status:         domain.EventStatusConsumed,
		Track:          command.Track,
		PreviousLevel:  previousLevel,
		NewLevel:       nextLevel,
		TotalValue:     targetValue,
		RewardJobCount: rewardJobCount,
		ServerTimeMS:   nowMS,
	}, nil
}

func (r *Repository) UpsertLevelTrack(ctx context.Context, command domain.TrackCommand, nowMS int64) (domain.Track, bool, error) {
	if r == nil || r.db == nil {
		return domain.Track{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	result, err := r.db.ExecContext(ctx, `
		INSERT INTO growth_level_tracks (
			app_code, track, name, status, sort_order, display_config_json, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			name = VALUES(name), status = VALUES(status), sort_order = VALUES(sort_order),
			display_config_json = VALUES(display_config_json), updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), command.Track, command.Name, command.Status, command.SortOrder, command.DisplayConfigJSON, nowMS, nowMS,
	)
	if err != nil {
		return domain.Track{}, false, err
	}
	affected, _ := result.RowsAffected()
	item, err := r.getLevelTrack(ctx, r.db, command.Track)
	return item, affected == 1, err
}

func (r *Repository) UpsertLevelRule(ctx context.Context, command domain.RuleCommand, nowMS int64) (domain.Rule, bool, error) {
	if r == nil || r.db == nil {
		return domain.Rule{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Rule{}, false, err
	}
	defer func() { _ = tx.Rollback() }()
	result, err := tx.ExecContext(ctx, `
		INSERT INTO growth_level_rules (
			app_code, track, level, required_value, name, status, reward_resource_group_id,
			sort_order, display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			required_value = VALUES(required_value), name = VALUES(name), status = VALUES(status),
			reward_resource_group_id = VALUES(reward_resource_group_id), sort_order = VALUES(sort_order),
			display_config_json = VALUES(display_config_json), updated_by_admin_id = VALUES(updated_by_admin_id),
			updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), command.Track, command.Level, command.RequiredValue, command.Name, command.Status,
		command.RewardResourceGroupID, command.SortOrder, command.DisplayConfigJSON, command.OperatorAdminID,
		command.OperatorAdminID, nowMS, nowMS,
	)
	if err != nil {
		return domain.Rule{}, false, err
	}
	if err := r.validateActiveLevelRules(ctx, tx, command.Track); err != nil {
		return domain.Rule{}, false, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Rule{}, false, err
	}
	affected, _ := result.RowsAffected()
	item, err := r.getLevelRule(ctx, command.Track, command.Level)
	return item, affected == 1, err
}

func (r *Repository) UpsertLevelTier(ctx context.Context, command domain.TierCommand, nowMS int64) (domain.Tier, bool, error) {
	if r == nil || r.db == nil {
		return domain.Tier{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Tier{}, false, err
	}
	defer func() { _ = tx.Rollback() }()
	created := false
	tierID := command.TierID
	if tierID <= 0 {
		result, err := tx.ExecContext(ctx, `
			INSERT INTO growth_level_tiers (
				app_code, track, min_level, max_level, name, display_avatar_frame_resource_id,
				display_badge_resource_id, reward_resource_group_id, status, display_config_json,
				created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), command.Track, command.MinLevel, command.MaxLevel, command.Name,
			command.DisplayAvatarFrameResourceID, command.DisplayBadgeResourceID, command.RewardResourceGroupID,
			command.Status, command.DisplayConfigJSON, command.OperatorAdminID, command.OperatorAdminID, nowMS, nowMS,
		)
		if err != nil {
			return domain.Tier{}, false, err
		}
		tierID, err = result.LastInsertId()
		if err != nil {
			return domain.Tier{}, false, err
		}
		created = true
	} else {
		result, err := tx.ExecContext(ctx, `
			UPDATE growth_level_tiers
			SET track = ?, min_level = ?, max_level = ?, name = ?, display_avatar_frame_resource_id = ?,
			    display_badge_resource_id = ?, reward_resource_group_id = ?, status = ?, display_config_json = ?,
			    updated_by_admin_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND tier_id = ?`,
			command.Track, command.MinLevel, command.MaxLevel, command.Name, command.DisplayAvatarFrameResourceID,
			command.DisplayBadgeResourceID, command.RewardResourceGroupID, command.Status, command.DisplayConfigJSON,
			command.OperatorAdminID, nowMS, appcode.FromContext(ctx), tierID,
		)
		if err != nil {
			return domain.Tier{}, false, err
		}
		affected, _ := result.RowsAffected()
		if affected == 0 {
			return domain.Tier{}, false, xerr.New(xerr.NotFound, "level tier not found")
		}
	}
	if err := r.validateActiveLevelTiers(ctx, tx, command.Track); err != nil {
		return domain.Tier{}, false, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Tier{}, false, err
	}
	item, err := r.getLevelTier(ctx, tierID)
	return item, created, err
}

// BatchUpsertLevelConfig 在一个短事务里写入最多 500 行。两条语句都命中现有唯一键/主键，
// prepared statement 避免 101 级配置重复解析 SQL；每条轨道只在全部写入后校验一次，控制锁时长和查询次数。
func (r *Repository) BatchUpsertLevelConfig(ctx context.Context, command domain.BatchConfigCommand, nowMS int64) (domain.Config, error) {
	if r == nil || r.db == nil {
		return domain.Config{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Config{}, err
	}
	defer func() { _ = tx.Rollback() }()

	ruleStatement, err := tx.PrepareContext(ctx, `
		INSERT INTO growth_level_rules (
			app_code, track, level, required_value, name, status, reward_resource_group_id,
			sort_order, display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			required_value = VALUES(required_value), name = VALUES(name), status = VALUES(status),
			reward_resource_group_id = VALUES(reward_resource_group_id), sort_order = VALUES(sort_order),
			display_config_json = VALUES(display_config_json), updated_by_admin_id = VALUES(updated_by_admin_id),
			updated_at_ms = VALUES(updated_at_ms)`)
	if err != nil {
		return domain.Config{}, err
	}
	defer ruleStatement.Close()

	tierStatement, err := tx.PrepareContext(ctx, `
		UPDATE growth_level_tiers
		SET track = ?, min_level = ?, max_level = ?, name = ?, display_avatar_frame_resource_id = ?,
			display_badge_resource_id = ?, reward_resource_group_id = ?, status = ?, display_config_json = ?,
			updated_by_admin_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND tier_id = ?`)
	if err != nil {
		return domain.Config{}, err
	}
	defer tierStatement.Close()

	appCode := appcode.FromContext(ctx)
	touchedTracks := make(map[string]struct{}, 3)
	for _, item := range command.Rules {
		if _, err := ruleStatement.ExecContext(ctx, appCode, item.Track, item.Level, item.RequiredValue, item.Name, item.Status,
			item.RewardResourceGroupID, item.SortOrder, item.DisplayConfigJSON, item.OperatorAdminID,
			item.OperatorAdminID, nowMS, nowMS); err != nil {
			return domain.Config{}, err
		}
		touchedTracks[item.Track] = struct{}{}
	}
	for _, item := range command.Tiers {
		result, err := tierStatement.ExecContext(ctx, item.Track, item.MinLevel, item.MaxLevel, item.Name,
			item.DisplayAvatarFrameResourceID, item.DisplayBadgeResourceID, item.RewardResourceGroupID, item.Status,
			item.DisplayConfigJSON, item.OperatorAdminID, nowMS, appCode, item.TierID)
		if err != nil {
			return domain.Config{}, err
		}
		affected, _ := result.RowsAffected()
		if affected == 0 {
			return domain.Config{}, xerr.New(xerr.NotFound, "level tier not found")
		}
		touchedTracks[item.Track] = struct{}{}
	}
	for track := range touchedTracks {
		if err := r.validateActiveLevelRules(ctx, tx, track); err != nil {
			return domain.Config{}, err
		}
		if err := r.validateActiveLevelTiers(ctx, tx, track); err != nil {
			return domain.Config{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.Config{}, err
	}
	return r.ListLevelConfig(ctx, domain.ConfigQuery{}, nowMS)
}

func (r *Repository) ClaimPendingLevelRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]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 func() { _ = tx.Rollback() }()
	rows, err := tx.QueryContext(ctx, levelRewardSelectSQL()+`
		WHERE app_code = ? AND status IN ('pending', 'failed', 'running') AND next_retry_at_ms <= ?
		  AND (reward_origin = 'organic' OR is_permanent = 1 OR temporary_expires_at_ms > ?)
		  AND (locked_until_ms = 0 OR locked_until_ms <= ?)
		ORDER BY created_at_ms ASC, reward_job_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED`,
		appcode.FromContext(ctx), nowMS, nowMS, nowMS, batchSize,
	)
	if err != nil {
		return nil, err
	}
	jobs, err := scanLevelRewards(rows)
	_ = rows.Close()
	if err != nil {
		return nil, err
	}
	lockUntil := nowMS + lockTTL.Milliseconds()
	for _, job := range jobs {
		if _, err := tx.ExecContext(ctx, `
			UPDATE growth_level_reward_jobs
			SET status = 'running', attempt_count = attempt_count + 1, locked_by = ?, locked_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND reward_job_id = ?`,
			workerID, lockUntil, nowMS, appcode.FromContext(ctx), job.RewardJobID,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return jobs, nil
}

func (r *Repository) MarkLevelRewardGranted(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, 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() }()
	firstGrantID := ""
	for _, grant := range grants {
		if strings.TrimSpace(grant.WalletGrantID) == "" {
			continue
		}
		if firstGrantID == "" {
			firstGrantID = grant.WalletGrantID
		}
		// Each external action has its own wallet command. INSERT IGNORE makes a crash after wallet success safe to replay.
		_, err := tx.ExecContext(ctx, `
			INSERT IGNORE INTO growth_level_reward_wallet_grants
			(app_code, reward_job_id, grant_kind, wallet_grant_id, revoke_status, created_at_ms, updated_at_ms)
			VALUES (?, ?, ?, ?, 'none', ?, ?)`,
			appcode.FromContext(ctx), rewardJobID, grant.GrantKind, grant.WalletGrantID, nowMS, nowMS)
		if err != nil {
			return err
		}
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs
		SET status = 'granted', wallet_grant_id = ?, locked_by = '', locked_until_ms = 0,
		    failure_reason = '', updated_at_ms = ?
		WHERE app_code = ? AND reward_job_id = ?`,
		firstGrantID, nowMS, appcode.FromContext(ctx), rewardJobID,
	)
	if err != nil {
		return err
	}
	var temporaryID string
	var rewardLevel int32
	var permanent bool
	if err := tx.QueryRowContext(ctx, `SELECT temporary_level_id, reward_level, is_permanent FROM growth_level_reward_jobs WHERE app_code = ? AND reward_job_id = ? FOR UPDATE`, appcode.FromContext(ctx), rewardJobID).Scan(&temporaryID, &rewardLevel, &permanent); err != nil {
		return err
	}
	if temporaryID != "" && !permanent {
		var status string
		var finalLevel int32
		if err := tx.QueryRowContext(ctx, `SELECT status, final_level FROM growth_temporary_levels WHERE app_code = ? AND temporary_level_id = ?`, appcode.FromContext(ctx), temporaryID).Scan(&status, &finalLevel); err != nil {
			return err
		}
		if status != domain.TemporaryLevelStatusActive && rewardLevel > finalLevel {
			// A worker can finish after the overlay expires. Persist the grants, then immediately expose them to revoke worker.
			revokeStatus := domain.RevokeStatusPending
			if firstGrantID == "" {
				revokeStatus = domain.RevokeStatusSkipped
			}
			if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET revoke_status = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, revokeStatus, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
				return err
			}
			if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'pending', 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) MarkLevelRewardFailed(ctx context.Context, rewardJobID string, grants []domain.RewardWalletGrant, 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() }()
	for _, grant := range grants {
		if strings.TrimSpace(grant.WalletGrantID) == "" {
			continue
		}
		// Partial wallet success is a durable revoke fact even though the parent reward remains retryable.
		if _, err := tx.ExecContext(ctx, `
			INSERT IGNORE INTO growth_level_reward_wallet_grants
			(app_code, reward_job_id, grant_kind, wallet_grant_id, revoke_status, created_at_ms, updated_at_ms)
			VALUES (?, ?, ?, ?, 'none', ?, ?)`,
			appcode.FromContext(ctx), rewardJobID, grant.GrantKind, grant.WalletGrantID, nowMS, nowMS); err != nil {
			return err
		}
	}
	result, err := tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs
		SET status = 'failed', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0,
		    failure_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND reward_job_id = ? AND status = 'running'`,
		nextRetryAtMS, truncateGrowthFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID,
	)
	if err != nil {
		return err
	}
	if affected, err := result.RowsAffected(); err != nil {
		return err
	} else if affected == 0 {
		return xerr.New(xerr.Conflict, "level reward job is no longer running")
	}
	var temporaryID string
	var rewardLevel int32
	var permanent bool
	if err := tx.QueryRowContext(ctx, `SELECT temporary_level_id, reward_level, is_permanent FROM growth_level_reward_jobs WHERE app_code = ? AND reward_job_id = ? FOR UPDATE`, appcode.FromContext(ctx), rewardJobID).Scan(&temporaryID, &rewardLevel, &permanent); err != nil {
		return err
	}
	if temporaryID != "" && !permanent {
		var status string
		var finalLevel int32
		if err := tx.QueryRowContext(ctx, `SELECT status, final_level FROM growth_temporary_levels WHERE app_code = ? AND temporary_level_id = ?`, appcode.FromContext(ctx), temporaryID).Scan(&status, &finalLevel); err != nil {
			return err
		}
		if status != domain.TemporaryLevelStatusActive && rewardLevel > finalLevel {
			// Expiry/supersede can race the wallet calls. If it already won, expose the successful prefix directly to
			// the revoke worker instead of leaving a failed parent whose assets are invisible forever.
			var persistedGrantCount int
			if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM growth_level_reward_wallet_grants WHERE app_code = ? AND reward_job_id = ?`, appcode.FromContext(ctx), rewardJobID).Scan(&persistedGrantCount); err != nil {
				return err
			}
			revokeStatus := domain.RevokeStatusSkipped
			if persistedGrantCount > 0 {
				revokeStatus = domain.RevokeStatusPending
			}
			if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_jobs SET status = 'canceled', revoke_status = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, revokeStatus, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
				return err
			}
			if _, err := tx.ExecContext(ctx, `UPDATE growth_level_reward_wallet_grants SET revoke_status = 'pending', revoke_next_retry_at_ms = 0, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND revoke_status IN ('none','failed')`, nowMS, appcode.FromContext(ctx), rewardJobID); err != nil {
				return err
			}
		}
	}
	return tx.Commit()
}

func (r *Repository) levelTrackOverview(ctx context.Context, userID int64, track domain.Track, nowMS int64) (domain.TrackOverview, error) {
	account, exists, err := r.getLevelAccount(ctx, userID, track.Track)
	if err != nil {
		return domain.TrackOverview{}, err
	}
	if !exists {
		account = domain.Account{UserID: userID, Track: track.Track}
	}
	rules, err := r.listActiveLevelRules(ctx, r.db, track.Track)
	if err != nil {
		return domain.TrackOverview{}, err
	}
	tiers, err := r.listActiveLevelTiers(ctx, r.db, track.Track)
	if err != nil {
		return domain.TrackOverview{}, err
	}
	displayLevel := account.CurrentLevel
	displayValue := account.TotalValue
	if temporary, found, err := r.activeTemporaryLevelForRead(ctx, userID, track.Track, nowMS); err != nil {
		return domain.TrackOverview{}, err
	} else if found {
		increment, err := r.temporaryWindowValue(ctx, r.db, userID, track.Track, temporary.StartedAtMS, temporary.ExpiresAtMS)
		if err != nil {
			return domain.TrackOverview{}, err
		}
		displayValue = temporary.TargetRequiredValue + increment
		displayLevel = levelForValue(rules, displayValue)
		if displayLevel < temporary.TargetLevel {
			displayLevel = temporary.TargetLevel
		}
	}
	tier := tierForLevel(tiers, displayLevel)
	badgeResourceID, badgeSourceLevel := levelRuleBadgeForLevel(rules, displayLevel)
	avatarFrameResourceID := levelRuleAvatarFrameForLevel(rules, displayLevel, tier.DisplayAvatarFrameResourceID)
	currentRequired, nextLevel, nextRequired := levelProgress(rules, displayLevel)
	pendingCount, err := r.pendingLevelRewardCount(ctx, userID, track.Track)
	if err != nil {
		return domain.TrackOverview{}, err
	}
	return domain.TrackOverview{
		Track:                        track.Track,
		Name:                         track.Name,
		Level:                        displayLevel,
		TierID:                       tier.TierID,
		TotalValue:                   displayValue,
		CurrentLevelRequiredValue:    currentRequired,
		NextLevel:                    nextLevel,
		NextLevelRequiredValue:       nextRequired,
		DisplayAvatarFrameResourceID: avatarFrameResourceID,
		DisplayBadgeResourceID:       badgeResourceID,
		DisplayBadgeSourceLevel:      badgeSourceLevel,
		RewardPendingCount:           pendingCount,
		SortOrder:                    track.SortOrder,
	}, nil
}

func (r *Repository) insertLevelValueEvent(ctx context.Context, tx *sql.Tx, event domain.ValueEvent, nowMS int64) (bool, error) {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO growth_level_value_events (
			app_code, value_event_id, source_event_id, source_service, source_event_type,
			user_id, track, metric_type, value_delta, occurred_at_ms, dimensions_json, created_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), event.EventID, event.SourceEventID, event.SourceService, event.SourceEventType,
		event.UserID, event.Track, event.MetricType, event.ValueDelta, event.OccurredAtMS, event.DimensionsJSON, nowMS,
	)
	if err == nil {
		return true, nil
	}
	if isMySQLDuplicate(err) {
		return false, nil
	}
	return false, err
}

func (r *Repository) ensureLevelAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, track string, nowMS int64) (domain.Account, error) {
	// INSERT IGNORE 命中已有主键时会先持有共享记录锁，随后 FOR UPDATE 再升级排他锁；并发礼物落到同一
	// (app_code,user_id,track) 时，多个事务会各持 S 锁并互等 X 锁。空更新 upsert 从第一条语句就取得 X 锁，
	// 让热点账户串行进入后续读改写，同时不改变账户累计值或更新时间。
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_growth_level_accounts (
			app_code, user_id, track, total_value, current_level, current_tier_id,
			level_updated_at_ms, tier_updated_at_ms, version, updated_at_ms
		) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?)
		ON DUPLICATE KEY UPDATE user_id = VALUES(user_id)`,
		appcode.FromContext(ctx), userID, track, nowMS,
	)
	if err != nil {
		return domain.Account{}, err
	}
	row := tx.QueryRowContext(ctx, `
		SELECT app_code, user_id, track, total_value, current_level, current_tier_id,
		       level_updated_at_ms, tier_updated_at_ms, version, updated_at_ms
		FROM user_growth_level_accounts
		WHERE app_code = ? AND user_id = ? AND track = ?
		FOR UPDATE`,
		appcode.FromContext(ctx), userID, track,
	)
	return scanLevelAccount(row)
}

func isRetryableGrowthTransactionError(err error) bool {
	var mysqlErr *mysqldriver.MySQLError
	if !errors.As(err, &mysqlErr) {
		return false
	}
	// 1213 会立即回滚死锁受害事务，适合短退避后本地重建。1205 可能已等待完整的
	// innodb_lock_wait_timeout；在无 deadline 的 MQ 回调里重试它会让唯一消费协程继续占用数分钟，应交给 broker 延迟重投。
	return mysqlErr.Number == 1213
}

func growthTransactionRetryDelay(eventID string, attempt int) time.Duration {
	if attempt < 0 {
		attempt = 0
	}
	hash := fnv.New32a()
	_, _ = hash.Write([]byte(eventID))
	_, _ = hash.Write([]byte{byte(attempt)})
	jitterSlots := uint32(growthTransactionRetryJitterWindow/time.Millisecond) + 1
	jitter := time.Duration(hash.Sum32()%jitterSlots) * time.Millisecond
	return time.Duration(attempt+1)*growthTransactionRetryStep + jitter
}

func (r *Repository) updateLevelAccount(ctx context.Context, tx *sql.Tx, userID int64, track string, totalValue int64, level int32, tierID int64, levelUpdatedAtMS int64, tierUpdatedAtMS int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE user_growth_level_accounts
		SET total_value = ?, current_level = ?, current_tier_id = ?, level_updated_at_ms = ?,
		    tier_updated_at_ms = ?, version = version + 1, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND track = ?`,
		totalValue, level, tierID, levelUpdatedAtMS, tierUpdatedAtMS, nowMS, appcode.FromContext(ctx), userID, track,
	)
	return err
}

func (r *Repository) insertLevelHistory(ctx context.Context, tx *sql.Tx, event domain.ValueEvent, previousLevel int32, nextLevel int32, previousTierID int64, nextTierID int64, totalValue int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_growth_level_history (
			app_code, history_id, user_id, track, previous_level, new_level,
			previous_tier_id, new_tier_id, total_value, value_event_id, created_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), idgen.New("glhist"), event.UserID, event.Track, previousLevel, nextLevel,
		previousTierID, nextTierID, totalValue, event.EventID, nowMS,
	)
	return err
}

func (r *Repository) createLevelRewardJobs(ctx context.Context, tx *sql.Tx, userID int64, track string, previousLevel int32, nextLevel int32, previousTierID int64, nextTier domain.Tier, rules []domain.Rule, nowMS int64) (int64, error) {
	created := int64(0)
	for _, rule := range rules {
		avatarFrameResourceID := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
		shortBadgeResourceID := shortBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
		if rule.Level <= previousLevel || rule.Level > nextLevel || (rule.RewardResourceGroupID <= 0 && avatarFrameResourceID <= 0 && shortBadgeResourceID <= 0) {
			continue
		}
		changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceLevel, fmt.Sprintf("level:%d", rule.Level), rule.RewardResourceGroupID, nowMS)
		if err != nil {
			return 0, err
		}
		if changed {
			created++
		}
	}
	if nextTier.TierID > 0 && nextTier.TierID != previousTierID && nextTier.RewardResourceGroupID > 0 {
		changed, err := r.insertLevelRewardJob(ctx, tx, userID, track, domain.RewardSourceTier, fmt.Sprintf("tier:%d", nextTier.TierID), nextTier.RewardResourceGroupID, nowMS)
		if err != nil {
			return 0, err
		}
		if changed {
			created++
		}
	}
	return created, nil
}

func (r *Repository) insertLevelRewardJob(ctx context.Context, tx *sql.Tx, userID int64, track string, sourceType string, sourceID string, resourceGroupID int64, nowMS int64) (bool, error) {
	// Natural growth permanently earns only the matching reward attached to the currently active overlay. Superseded,
	// expired or already-revoking generations must finish revoke and get a fresh organic job; otherwise multiple old
	// grants (or an RPC-timeout revoke that actually succeeded) could all be promoted into duplicate permanent assets.
	promoted, err := tx.ExecContext(ctx, `
		UPDATE growth_level_reward_jobs j
		JOIN growth_temporary_levels t
		  ON t.app_code = j.app_code AND t.temporary_level_id = j.temporary_level_id
		SET j.is_permanent = 1, j.revoke_status = 'skipped', j.updated_at_ms = ?
		WHERE j.app_code = ? AND j.user_id = ? AND j.track = ?
		  AND j.reward_source_type = ? AND j.reward_source_id = ?
		  AND j.reward_origin = 'temporary' AND j.is_permanent = 0
		  AND j.status IN ('pending','failed','running','granted') AND j.revoke_status = 'none'
		  AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?`,
		nowMS, appcode.FromContext(ctx), userID, track, sourceType, sourceID, nowMS, nowMS)
	if err != nil {
		return false, err
	}
	if affected, err := promoted.RowsAffected(); err != nil {
		return false, err
	} else if affected > 0 {
		_, 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
			JOIN growth_temporary_levels t ON t.app_code = j.app_code AND t.temporary_level_id = j.temporary_level_id
			SET g.revoke_status = 'skipped', g.revoke_locked_by = '', g.revoke_locked_until_ms = 0, g.updated_at_ms = ?
			WHERE j.app_code = ? AND j.user_id = ? AND j.track = ? AND j.reward_source_type = ? AND j.reward_source_id = ?
			  AND j.reward_origin = 'temporary' AND j.is_permanent = 1
			  AND t.status = 'active' AND t.started_at_ms <= ? AND t.expires_at_ms > ?
			  AND g.revoke_status IN ('none','pending','failed')`,
			nowMS, appcode.FromContext(ctx), userID, track, sourceType, sourceID, nowMS, nowMS)
		return false, err
	}
	rewardJobID := idgen.New("greward")
	rewardLevel, _ := levelFromGrowthRewardSourceID(sourceID)
	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, grant_generation, reward_level, is_permanent,
			status, next_retry_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'organic', 0, ?, 1, 'pending', 0, ?, ?)`,
		appcode.FromContext(ctx), rewardJobID, userID, track, sourceType, sourceID, resourceGroupID,
		"growth_level_reward:"+rewardJobID, rewardLevel, nowMS, nowMS,
	)
	if err != nil {
		return false, err
	}
	affected, err := result.RowsAffected()
	return affected > 0, err
}

func (r *Repository) upsertLevelDisplayProfile(ctx context.Context, tx *sql.Tx, userID int64, track string, level int32, tier domain.Tier, avatarFrameResourceID int64, badgeResourceID int64, badgeSourceLevel int32, nowMS int64) error {
	columnPrefix := ""
	switch track {
	case domain.TrackWealth:
		columnPrefix = "wealth"
	case domain.TrackGame:
		columnPrefix = "game"
	case domain.TrackCharm:
		columnPrefix = "charm"
	default:
		return xerr.New(xerr.InvalidArgument, "track is invalid")
	}
	query := fmt.Sprintf(`
		INSERT INTO user_level_display_profiles (
			app_code, user_id, %s_level, %s_tier_id, %s_avatar_frame_resource_id, %s_badge_resource_id, %s_badge_source_level, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			%s_level = VALUES(%s_level),
			%s_tier_id = VALUES(%s_tier_id),
			%s_avatar_frame_resource_id = VALUES(%s_avatar_frame_resource_id),
			%s_badge_resource_id = VALUES(%s_badge_resource_id),
			%s_badge_source_level = VALUES(%s_badge_source_level),
			updated_at_ms = VALUES(updated_at_ms)`,
		columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
		columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix, columnPrefix,
	)
	_, err := tx.ExecContext(ctx, query, appcode.FromContext(ctx), userID, level, tier.TierID, avatarFrameResourceID, badgeResourceID, badgeSourceLevel, nowMS)
	return err
}

func (r *Repository) listActiveLevelTracks(ctx context.Context, q queryer) ([]domain.Track, error) {
	rows, err := q.QueryContext(ctx, `
		SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms
		FROM growth_level_tracks
		WHERE app_code = ? AND status = 'active'
		ORDER BY sort_order DESC, track ASC`,
		appcode.FromContext(ctx),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanLevelTracks(rows)
}

func (r *Repository) listLevelTracks(ctx context.Context, q queryer, track string, status string) ([]domain.Track, error) {
	where := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if track != "" {
		where = append(where, "track = ?")
		args = append(args, track)
	}
	if status != "" {
		where = append(where, "status = ?")
		args = append(args, status)
	}
	rows, err := q.QueryContext(ctx, `
		SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms
		FROM growth_level_tracks
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY sort_order DESC, track ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanLevelTracks(rows)
}

func (r *Repository) getActiveLevelTrack(ctx context.Context, q queryer, track string) (domain.Track, error) {
	item, err := r.getLevelTrackWithQuery(ctx, q, track, true)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Track{}, xerr.New(xerr.NotFound, "level track not found")
	}
	return item, err
}

func (r *Repository) getLevelTrack(ctx context.Context, q queryer, track string) (domain.Track, error) {
	item, err := r.getLevelTrackWithQuery(ctx, q, track, false)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Track{}, xerr.New(xerr.NotFound, "level track not found")
	}
	return item, err
}

func (r *Repository) getLevelTrackWithQuery(ctx context.Context, q queryer, track string, activeOnly bool) (domain.Track, error) {
	statusClause := ""
	if activeOnly {
		statusClause = " AND status = 'active'"
	}
	row := q.QueryRowContext(ctx, `
		SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms
		FROM growth_level_tracks
		WHERE app_code = ? AND track = ?`+statusClause,
		appcode.FromContext(ctx), track,
	)
	return scanLevelTrack(row)
}

func (r *Repository) listActiveLevelRules(ctx context.Context, q queryer, track string) ([]domain.Rule, error) {
	rows, err := q.QueryContext(ctx, levelRuleSelectSQL()+`
		WHERE app_code = ? AND track = ? AND status = 'active'
		ORDER BY level ASC`,
		appcode.FromContext(ctx), track,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanLevelRules(rows)
}

func (r *Repository) listLevelRules(ctx context.Context, q queryer, track string, status string) ([]domain.Rule, error) {
	where := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if track != "" {
		where = append(where, "track = ?")
		args = append(args, track)
	}
	if status != "" {
		where = append(where, "status = ?")
		args = append(args, status)
	}
	rows, err := q.QueryContext(ctx, levelRuleSelectSQL()+`
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY track ASC, level ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanLevelRules(rows)
}

func (r *Repository) listActiveLevelTiers(ctx context.Context, q queryer, track string) ([]domain.Tier, error) {
	rows, err := q.QueryContext(ctx, levelTierSelectSQL()+`
		WHERE app_code = ? AND track = ? AND status = 'active'
		ORDER BY min_level ASC, max_level ASC`,
		appcode.FromContext(ctx), track,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanLevelTiers(rows)
}

func (r *Repository) listLevelTiers(ctx context.Context, q queryer, track string, status string) ([]domain.Tier, error) {
	where := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if track != "" {
		where = append(where, "track = ?")
		args = append(args, track)
	}
	if status != "" {
		where = append(where, "status = ?")
		args = append(args, status)
	}
	rows, err := q.QueryContext(ctx, levelTierSelectSQL()+`
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY track ASC, min_level ASC, max_level ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	return scanLevelTiers(rows)
}

func (r *Repository) getLevelRule(ctx context.Context, track string, level int32) (domain.Rule, error) {
	row := r.db.QueryRowContext(ctx, levelRuleSelectSQL()+`
		WHERE app_code = ? AND track = ? AND level = ?`,
		appcode.FromContext(ctx), track, level,
	)
	return scanLevelRule(row)
}

func (r *Repository) getLevelTier(ctx context.Context, tierID int64) (domain.Tier, error) {
	row := r.db.QueryRowContext(ctx, levelTierSelectSQL()+`
		WHERE app_code = ? AND tier_id = ?`,
		appcode.FromContext(ctx), tierID,
	)
	return scanLevelTier(row)
}

func (r *Repository) getLevelAccount(ctx context.Context, userID int64, track string) (domain.Account, bool, error) {
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, user_id, track, total_value, current_level, current_tier_id,
		       level_updated_at_ms, tier_updated_at_ms, version, updated_at_ms
		FROM user_growth_level_accounts
		WHERE app_code = ? AND user_id = ? AND track = ?`,
		appcode.FromContext(ctx), userID, track,
	)
	account, err := scanLevelAccount(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Account{}, false, nil
	}
	return account, err == nil, err
}

func (r *Repository) validateActiveLevelRules(ctx context.Context, tx *sql.Tx, track string) error {
	rules, err := r.listActiveLevelRules(ctx, tx, track)
	if err != nil {
		return err
	}
	lastRequired := int64(-1)
	for _, rule := range rules {
		if rule.RequiredValue <= lastRequired {
			return xerr.New(xerr.Conflict, "active level required_value must be strictly increasing")
		}
		lastRequired = rule.RequiredValue
	}
	return nil
}

func (r *Repository) validateActiveLevelTiers(ctx context.Context, tx *sql.Tx, track string) error {
	tiers, err := r.listActiveLevelTiers(ctx, tx, track)
	if err != nil {
		return err
	}
	lastMax := int32(0)
	for index, tier := range tiers {
		if index > 0 && tier.MinLevel <= lastMax {
			return xerr.New(xerr.Conflict, "active level tiers cannot overlap")
		}
		lastMax = tier.MaxLevel
	}
	return nil
}

func (r *Repository) pendingLevelRewardCount(ctx context.Context, userID int64, track string) (int64, error) {
	var count int64
	err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM growth_level_reward_jobs
		WHERE app_code = ? AND user_id = ? AND track = ? AND status IN ('pending', 'failed', 'running')`,
		appcode.FromContext(ctx), userID, track,
	).Scan(&count)
	return count, err
}

func levelForValue(rules []domain.Rule, totalValue int64) int32 {
	level := int32(0)
	for _, rule := range rules {
		if totalValue >= rule.RequiredValue && rule.Level > level {
			level = rule.Level
		}
	}
	return level
}

func requiredValueForLevel(rules []domain.Rule, level int32) (int64, bool) {
	for _, rule := range rules {
		if rule.Level == level && rule.Status == domain.StatusActive {
			return rule.RequiredValue, true
		}
	}
	return 0, false
}

func tierForLevel(tiers []domain.Tier, level int32) domain.Tier {
	if level < 0 {
		return domain.Tier{}
	}
	for _, tier := range tiers {
		if level >= tier.MinLevel && level <= tier.MaxLevel {
			return tier
		}
	}
	return domain.Tier{}
}

func normalizeJSONMap(value map[string]any) string {
	data, err := json.Marshal(value)
	if err != nil {
		return "{}"
	}
	return string(data)
}

func tierByID(tiers []domain.Tier, tierID int64) domain.Tier {
	if tierID <= 0 {
		return domain.Tier{}
	}
	for _, tier := range tiers {
		if tier.TierID == tierID {
			return tier
		}
	}
	return domain.Tier{}
}

func levelProgress(rules []domain.Rule, currentLevel int32) (int64, int32, int64) {
	currentRequired := int64(0)
	nextLevel := int32(0)
	nextRequired := int64(0)
	for _, rule := range rules {
		if rule.Level == currentLevel {
			currentRequired = rule.RequiredValue
			continue
		}
		if rule.Level > currentLevel && (nextLevel == 0 || rule.Level < nextLevel) {
			nextLevel = rule.Level
			nextRequired = rule.RequiredValue
		}
	}
	return currentRequired, nextLevel, nextRequired
}

func levelRuleBadgeForLevel(rules []domain.Rule, currentLevel int32) (int64, int32) {
	if currentLevel < 0 {
		return 0, 0
	}
	badgeResourceID := int64(0)
	badgeSourceLevel := int32(0)
	for _, rule := range rules {
		if rule.Level > currentLevel || rule.Level < badgeSourceLevel {
			continue
		}
		candidate := longBadgeResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
		if candidate <= 0 {
			continue
		}
		// 等级徽章按当前等级以内最高配置生效；后续低等级配置不再覆盖更高等级展示。
		badgeResourceID = candidate
		badgeSourceLevel = rule.Level
	}
	return badgeResourceID, badgeSourceLevel
}

func levelRuleAvatarFrameForLevel(rules []domain.Rule, currentLevel int32, fallbackResourceID int64) int64 {
	if currentLevel < 0 {
		return fallbackResourceID
	}
	avatarFrameResourceID := int64(0)
	sourceLevel := int32(0)
	for _, rule := range rules {
		if rule.Level > currentLevel || rule.Level < sourceLevel {
			continue
		}
		candidate := avatarFrameResourceIDFromDisplayConfig(rule.DisplayConfigJSON)
		if candidate <= 0 {
			continue
		}
		// 等级头像框按当前等级以内最高配置覆盖；没有每级配置时保留等级段头像框兼容旧展示。
		avatarFrameResourceID = candidate
		sourceLevel = rule.Level
	}
	if avatarFrameResourceID > 0 {
		return avatarFrameResourceID
	}
	return fallbackResourceID
}

func longBadgeResourceIDFromDisplayConfig(raw string) int64 {
	return positiveInt64FromDisplayConfig(raw, "long_badge_resource_id")
}

func avatarFrameResourceIDFromDisplayConfig(raw string) int64 {
	return positiveInt64FromDisplayConfig(raw, "avatar_frame_resource_id")
}

func shortBadgeResourceIDFromDisplayConfig(raw string) int64 {
	return positiveInt64FromDisplayConfig(raw, "short_badge_resource_id")
}

func positiveInt64FromDisplayConfig(raw string, key string) int64 {
	payload := map[string]any{}
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
		return 0
	}
	value, ok := payload[key]
	if !ok {
		return 0
	}
	switch typed := value.(type) {
	case float64:
		if typed > 0 {
			return int64(typed)
		}
	case string:
		parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
		if err == nil && parsed > 0 {
			return parsed
		}
	}
	return 0
}

func defaultLevelDisplayProfile(userID int64) domain.UserLevelDisplayProfile {
	return domain.UserLevelDisplayProfile{
		UserID: userID,
		Wealth: domain.LevelDisplayTrackProfile{
			Track: domain.TrackWealth,
		},
		Game: domain.LevelDisplayTrackProfile{
			Track: domain.TrackGame,
		},
		Charm: domain.LevelDisplayTrackProfile{
			Track: domain.TrackCharm,
		},
	}
}

func uniquePositiveInt64s(values []int64) []int64 {
	seen := make(map[int64]struct{}, len(values))
	result := make([]int64, 0, len(values))
	for _, value := range values {
		if value <= 0 {
			continue
		}
		if _, exists := seen[value]; exists {
			continue
		}
		seen[value] = struct{}{}
		result = append(result, value)
	}
	return result
}

func placeholders(count int) string {
	if count <= 0 {
		return ""
	}
	items := make([]string, count)
	for index := range items {
		items[index] = "?"
	}
	return strings.Join(items, ",")
}

func scanLevelTracks(rows *sql.Rows) ([]domain.Track, error) {
	items := make([]domain.Track, 0)
	for rows.Next() {
		item, err := scanLevelTrack(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func scanLevelTrack(row rowScanner) (domain.Track, error) {
	var item domain.Track
	err := row.Scan(&item.AppCode, &item.Track, &item.Name, &item.Status, &item.SortOrder, &item.DisplayConfigJSON, &item.CreatedAtMS, &item.UpdatedAtMS)
	return item, err
}

func scanLevelRules(rows *sql.Rows) ([]domain.Rule, error) {
	items := make([]domain.Rule, 0)
	for rows.Next() {
		item, err := scanLevelRule(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func scanLevelRule(row rowScanner) (domain.Rule, error) {
	var item domain.Rule
	err := row.Scan(
		&item.AppCode, &item.Track, &item.Level, &item.RequiredValue, &item.Name, &item.Status,
		&item.RewardResourceGroupID, &item.SortOrder, &item.DisplayConfigJSON, &item.CreatedByAdminID,
		&item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS,
	)
	return item, err
}

func scanLevelTiers(rows *sql.Rows) ([]domain.Tier, error) {
	items := make([]domain.Tier, 0)
	for rows.Next() {
		item, err := scanLevelTier(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func scanLevelTier(row rowScanner) (domain.Tier, error) {
	var item domain.Tier
	err := row.Scan(
		&item.AppCode, &item.TierID, &item.Track, &item.MinLevel, &item.MaxLevel, &item.Name,
		&item.DisplayAvatarFrameResourceID, &item.DisplayBadgeResourceID, &item.RewardResourceGroupID,
		&item.Status, &item.DisplayConfigJSON, &item.CreatedByAdminID, &item.UpdatedByAdminID,
		&item.CreatedAtMS, &item.UpdatedAtMS,
	)
	return item, err
}

func scanLevelAccount(row rowScanner) (domain.Account, error) {
	var item domain.Account
	err := row.Scan(
		&item.AppCode, &item.UserID, &item.Track, &item.TotalValue, &item.CurrentLevel, &item.CurrentTierID,
		&item.LevelUpdatedAtMS, &item.TierUpdatedAtMS, &item.Version, &item.UpdatedAtMS,
	)
	return item, err
}

func scanLevelRewards(rows *sql.Rows) ([]domain.RewardJob, error) {
	items := make([]domain.RewardJob, 0)
	for rows.Next() {
		item, err := scanLevelReward(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func scanLevelReward(row rowScanner) (domain.RewardJob, error) {
	var item domain.RewardJob
	err := row.Scan(
		&item.AppCode, &item.RewardJobID, &item.UserID, &item.Track, &item.RewardSourceType,
		&item.RewardSourceID, &item.ResourceGroupID, &item.WalletCommandID, &item.WalletGrantID,
		&item.RewardOrigin, &item.TemporaryLevelID, &item.GrantGeneration, &item.RewardLevel,
		&item.Permanent, &item.TemporaryExpiresAtMS, &item.RevokeStatus,
		&item.Status, &item.AttemptCount, &item.FailureReason, &item.CreatedAtMS, &item.UpdatedAtMS,
	)
	return item, err
}

func levelRuleSelectSQL() string {
	return `SELECT app_code, track, level, required_value, name, status, reward_resource_group_id,
	               sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_by_admin_id,
	               updated_by_admin_id, created_at_ms, updated_at_ms
	        FROM growth_level_rules `
}

func levelTierSelectSQL() string {
	return `SELECT app_code, tier_id, track, min_level, max_level, name, display_avatar_frame_resource_id,
	               display_badge_resource_id, reward_resource_group_id, status,
	               COALESCE(CAST(display_config_json AS CHAR), '{}'), created_by_admin_id,
	               updated_by_admin_id, created_at_ms, updated_at_ms
	        FROM growth_level_tiers `
}

func levelRewardSelectSQL() string {
	return `SELECT app_code, reward_job_id, user_id, track, reward_source_type, reward_source_id,
	               resource_group_id, wallet_command_id, wallet_grant_id, reward_origin, temporary_level_id,
	               grant_generation, reward_level, is_permanent, temporary_expires_at_ms, revoke_status, status, attempt_count,
	               failure_reason, created_at_ms, updated_at_ms
	        FROM growth_level_reward_jobs `
}

func truncateGrowthFailure(value string) string {
	value = strings.TrimSpace(value)
	if len(value) <= 255 {
		return value
	}
	return value[:255]
}
