package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"strings"
	"time"

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

// ListAchievements returns user-facing achievements with current progress and reward state.
func (r *Repository) ListAchievements(ctx context.Context, query domain.ListQuery, nowMS int64) ([]domain.UserAchievement, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where, args := achievementDefinitionWhere(ctx, query, nowMS)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM achievement_definitions `+where, 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, achievementDefinitionSelectSQL()+where+`
		ORDER BY sort_order DESC, updated_at_ms DESC, achievement_id ASC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	definitions, err := scanAchievementDefinitions(rows)
	_ = rows.Close()
	if err != nil {
		return nil, 0, err
	}
	for index := range definitions {
		conditions, err := r.listAchievementConditions(ctx, r.db, definitions[index].AchievementID, definitions[index].Version)
		if err != nil {
			return nil, 0, err
		}
		definitions[index].Conditions = conditions
	}
	items := make([]domain.UserAchievement, 0, len(definitions))
	for _, definition := range definitions {
		item, err := r.userAchievementFromDefinition(ctx, query.UserID, definition)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, nil
}

// ListAchievementDefinitions returns the operator-facing rule catalog without binding it to a user progress row.
func (r *Repository) ListAchievementDefinitions(ctx context.Context, query domain.ListQuery, nowMS int64) ([]domain.Definition, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where, args := achievementDefinitionAdminWhere(ctx, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM achievement_definitions `+where, 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, achievementDefinitionSelectSQL()+where+`
		ORDER BY sort_order DESC, updated_at_ms DESC, achievement_id ASC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	definitions, err := scanAchievementDefinitions(rows)
	_ = rows.Close()
	if err != nil {
		return nil, 0, err
	}
	for index := range definitions {
		conditions, err := r.listAchievementConditions(ctx, r.db, definitions[index].AchievementID, definitions[index].Version)
		if err != nil {
			return nil, 0, err
		}
		definitions[index].Conditions = conditions
	}
	return definitions, total, nil
}

// ConsumeAchievementEvent applies one server-side fact to matching achievements.
func (r *Repository) ConsumeAchievementEvent(ctx context.Context, event domain.Event, 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, existingStatus, err := r.insertAchievementEventConsumption(ctx, tx, event, nowMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	if !inserted {
		return domain.EventResult{EventID: event.EventID, Status: existingStatus}, nil
	}

	definitions, err := r.listAchievementDefinitionsForEvent(ctx, tx, event.MetricType, event.OccurredAtMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	matched := int32(0)
	unlocked := int32(0)
	rewardJobs := int64(0)
	for _, definition := range definitions {
		cycleKey := achievementCycleKey(definition)
		conditionMatched := false
		for _, condition := range definition.Conditions {
			if condition.MetricType != event.MetricType {
				continue
			}
			if err := r.applyAchievementProgressDelta(ctx, tx, event.UserID, definition, condition, cycleKey, event.Value, nowMS); err != nil {
				return domain.EventResult{}, err
			}
			conditionMatched = true
		}
		if !conditionMatched {
			continue
		}
		matched++
		created, jobCreated, err := r.unlockAchievementIfComplete(ctx, tx, event.UserID, definition, cycleKey, nowMS)
		if err != nil {
			return domain.EventResult{}, err
		}
		if created {
			unlocked++
		}
		if jobCreated {
			rewardJobs++
		}
	}
	status := domain.EventStatusConsumed
	skipReason := ""
	if matched == 0 {
		status = domain.EventStatusSkipped
		skipReason = "no_matching_achievement"
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE achievement_event_consumption
		SET status = ?, skip_reason = ?, consumed_at_ms = ?
		WHERE app_code = ? AND event_id = ?`,
		status, skipReason, nowMS, appcode.FromContext(ctx), event.EventID,
	); err != nil {
		return domain.EventResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.EventResult{}, err
	}
	return domain.EventResult{
		EventID:                  event.EventID,
		Status:                   status,
		MatchedAchievementCount:  matched,
		UnlockedAchievementCount: unlocked,
		RewardJobCount:           rewardJobs,
	}, nil
}

type walletBadgeGrantPayload struct {
	GrantID     string                 `json:"grant_id"`
	GrantSource string                 `json:"grant_source"`
	Items       []walletBadgeGrantItem `json:"items"`
}

type walletBadgeGrantItem struct {
	ResourceID           int64  `json:"resource_id"`
	ResourceType         string `json:"resource_type"`
	EntitlementID        string `json:"entitlement_id"`
	ResourceSnapshotJSON string `json:"resource_snapshot_json"`
	MetadataJSON         string `json:"metadata_json"`
}

type walletBadgeMetadata struct {
	BadgeForm   string `json:"badge_form"`
	DefaultSlot string `json:"default_slot"`
}

// ConsumeWalletBadgeGrantEvent projects wallet system-granted badge resources into profile and honor display slots.
func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event domain.Event, 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, existingStatus, err := r.insertAchievementEventConsumption(ctx, tx, event, nowMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	if !inserted {
		return domain.EventResult{EventID: event.EventID, Status: existingStatus}, nil
	}
	payload, err := parseWalletBadgeGrantPayload(event.DimensionsJSON)
	if err != nil {
		if updateErr := r.updateAchievementEventConsumption(ctx, tx, event.EventID, domain.EventStatusSkipped, "invalid_wallet_badge_payload", nowMS); updateErr != nil {
			return domain.EventResult{}, updateErr
		}
		if err := tx.Commit(); err != nil {
			return domain.EventResult{}, err
		}
		return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil
	}
	if shouldSkipWalletBadgeGrantSource(payload.GrantSource) {
		if updateErr := r.updateAchievementEventConsumption(ctx, tx, event.EventID, domain.EventStatusSkipped, "owner_projection_source", nowMS); updateErr != nil {
			return domain.EventResult{}, updateErr
		}
		if err := tx.Commit(); err != nil {
			return domain.EventResult{}, err
		}
		return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil
	}
	projected := int32(0)
	sourceType := walletBadgeDisplaySourceType(payload.GrantSource)
	for _, item := range payload.Items {
		if strings.ToLower(strings.TrimSpace(item.ResourceType)) != "badge" || item.ResourceID <= 0 {
			continue
		}
		slot, badgeForm := walletBadgeSlotAndForm(item)
		switch slot {
		case domain.BadgeSlotProfileStrip:
			count, err := r.badgeDisplaySlotCount(ctx, tx, event.UserID, domain.BadgeSlotProfileStrip)
			if err != nil {
				return domain.EventResult{}, err
			}
			if count >= 6 {
				continue
			}
			if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotProfileStrip, int32(count+1), badgeForm, item.ResourceID, item.EntitlementID, sourceType, payload.GrantID, "auto", nowMS); err != nil {
				return domain.EventResult{}, err
			}
			projected++
		default:
			position, err := r.nextBadgeDisplayPosition(ctx, tx, event.UserID, domain.BadgeSlotHonorWall)
			if err != nil {
				return domain.EventResult{}, err
			}
			if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotHonorWall, position, domain.BadgeFormTile, item.ResourceID, item.EntitlementID, sourceType, payload.GrantID, "auto", nowMS); err != nil {
				return domain.EventResult{}, err
			}
			projected++
			count, err := r.badgeDisplaySlotCount(ctx, tx, event.UserID, domain.BadgeSlotProfileTile)
			if err != nil {
				return domain.EventResult{}, err
			}
			if count < 6 {
				if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotProfileTile, int32(count+1), domain.BadgeFormTile, item.ResourceID, item.EntitlementID, sourceType, payload.GrantID, "auto", nowMS); err != nil {
					return domain.EventResult{}, err
				}
				projected++
			}
		}
	}
	status := domain.EventStatusConsumed
	skipReason := ""
	if projected == 0 {
		status = domain.EventStatusSkipped
		skipReason = "no_badge_display_slot_projected"
	}
	if err := r.updateAchievementEventConsumption(ctx, tx, event.EventID, status, skipReason, nowMS); err != nil {
		return domain.EventResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.EventResult{}, err
	}
	return domain.EventResult{EventID: event.EventID, Status: status, MatchedAchievementCount: projected}, nil
}

func (r *Repository) ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error) {
	if r == nil || r.db == nil {
		return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	items, err := r.listBadgeDisplayItems(ctx, r.db, userID, "")
	if err != nil {
		return domain.BadgeProfile{}, err
	}
	profile := domain.BadgeProfile{ServerTimeMS: nowMS}
	for _, item := range items {
		switch item.Slot {
		case domain.BadgeSlotProfileStrip:
			profile.StripBadges = append(profile.StripBadges, item)
		case domain.BadgeSlotProfileTile:
			profile.ProfileTileBadges = append(profile.ProfileTileBadges, item)
		case domain.BadgeSlotHonorWall:
			profile.HonorBadges = append(profile.HonorBadges, item)
		}
	}
	return profile, nil
}

func (r *Repository) DeleteBadgeDisplayItems(ctx context.Context, userID int64, items []domain.BadgeDisplayItem) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if userID <= 0 || len(items) == 0 {
		return nil
	}
	const batchSize = 50
	for start := 0; start < len(items); start += batchSize {
		end := min(start+batchSize, len(items))
		predicates := make([]string, 0, end-start)
		args := []any{appcode.FromContext(ctx), userID}
		for _, item := range items[start:end] {
			if item.Slot == "" || item.Position <= 0 || item.ResourceID <= 0 || item.EntitlementID == "" {
				continue
			}
			// 主键前缀先把扫描范围锁定到单个用户，再用 slot/position 精确命中；附加
			// resource_id/entitlement_id 可防止并发新投影复用同一位置时被旧回收请求误删。
			predicates = append(predicates, `(slot = ? AND position = ? AND resource_id = ? AND entitlement_id = ?)`)
			args = append(args, item.Slot, item.Position, item.ResourceID, item.EntitlementID)
		}
		if len(predicates) == 0 {
			continue
		}
		if _, err := r.db.ExecContext(ctx, `
			DELETE FROM user_badge_display_slots
			WHERE app_code = ? AND user_id = ?
			  AND (`+strings.Join(predicates, " OR ")+`)`, args...); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, nowMS int64) (domain.BadgeProfile, error) {
	if r == nil || r.db == nil {
		return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.BadgeProfile{}, err
	}
	defer func() { _ = tx.Rollback() }()

	seen := map[int64]bool{}
	for _, item := range command.Items {
		if seen[item.ResourceID] {
			return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "duplicate badge resource")
		}
		seen[item.ResourceID] = true
		ok, err := r.userHasBadgeDisplayResource(ctx, tx, command.UserID, item.ResourceID)
		if err != nil {
			return domain.BadgeProfile{}, err
		}
		if !ok {
			return domain.BadgeProfile{}, xerr.New(xerr.NotFound, "badge resource not available for display")
		}
	}
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM user_badge_display_slots
		WHERE app_code = ? AND user_id = ? AND slot = ?`,
		appcode.FromContext(ctx), command.UserID, command.Slot,
	); err != nil {
		return domain.BadgeProfile{}, err
	}
	for index, item := range command.Items {
		position := item.Position
		if position <= 0 {
			position = int32(index + 1)
		}
		if err := r.insertBadgeDisplaySlot(ctx, tx, command.UserID, command.Slot, position, domain.BadgeFormTile, item.ResourceID, item.EntitlementID, item.SourceType, item.SourceID, "user", nowMS); err != nil {
			return domain.BadgeProfile{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.BadgeProfile{}, err
	}
	return r.ListBadgeProfile(ctx, command.UserID, nowMS)
}

func (r *Repository) UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand, nowMS int64) (domain.Definition, bool, error) {
	if r == nil || r.db == nil {
		return domain.Definition{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Definition{}, false, err
	}
	defer func() { _ = tx.Rollback() }()

	created := false
	achievementID := command.AchievementID
	version := int64(1)
	if achievementID == "" {
		achievementID = idgen.New("ach")
		created = true
		_, err = tx.ExecContext(ctx, `
			INSERT INTO achievement_definitions (
				app_code, achievement_id, collection_id, collection_type, achievement_type,
				title, description, status, version, primary_badge_resource_id, reward_resource_group_id,
				auto_pin_policy, honor_visibility, effective_from_ms, effective_to_ms, sort_order,
				display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), achievementID, command.CollectionID, command.CollectionType, command.AchievementType,
			command.Title, command.Description, command.Status, version, command.PrimaryBadgeResourceID, command.RewardResourceGroupID,
			command.AutoPinPolicy, command.HonorVisibility, command.EffectiveFromMS, command.EffectiveToMS, command.SortOrder,
			command.DisplayConfigJSON, command.OperatorAdminID, command.OperatorAdminID, nowMS, nowMS,
		)
	} else {
		current, err := r.getAchievementDefinitionForUpdate(ctx, tx, achievementID)
		if err != nil {
			return domain.Definition{}, false, err
		}
		version = current.Version + 1
		_, err = tx.ExecContext(ctx, `
			UPDATE achievement_definitions
			SET collection_id = ?, collection_type = ?, achievement_type = ?, title = ?, description = ?,
			    status = ?, version = ?, primary_badge_resource_id = ?, reward_resource_group_id = ?,
			    auto_pin_policy = ?, honor_visibility = ?, effective_from_ms = ?, effective_to_ms = ?,
			    sort_order = ?, display_config_json = ?, updated_by_admin_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND achievement_id = ?`,
			command.CollectionID, command.CollectionType, command.AchievementType, command.Title, command.Description,
			command.Status, version, command.PrimaryBadgeResourceID, command.RewardResourceGroupID,
			command.AutoPinPolicy, command.HonorVisibility, command.EffectiveFromMS, command.EffectiveToMS,
			command.SortOrder, command.DisplayConfigJSON, command.OperatorAdminID, nowMS, appcode.FromContext(ctx), achievementID,
		)
	}
	if err != nil {
		return domain.Definition{}, false, err
	}
	if _, err := tx.ExecContext(ctx, `
		DELETE FROM achievement_conditions
		WHERE app_code = ? AND achievement_id = ?`,
		appcode.FromContext(ctx), achievementID,
	); err != nil {
		return domain.Definition{}, false, err
	}
	for _, condition := range command.Conditions {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO achievement_conditions (
				app_code, condition_id, achievement_id, version, metric_type, target_value,
				target_unit, dimension_filter_json, created_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), idgen.New("achcond"), achievementID, version, condition.MetricType,
			condition.TargetValue, condition.TargetUnit, condition.DimensionFilterJSON, nowMS,
		); err != nil {
			return domain.Definition{}, false, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.Definition{}, false, err
	}
	definition, err := r.getAchievementDefinition(ctx, r.db, achievementID)
	return definition, created, err
}

func (r *Repository) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, error) {
	if r == nil || r.db == nil {
		return domain.Definition{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Definition{}, err
	}
	defer func() { _ = tx.Rollback() }()

	current, err := r.getAchievementDefinitionForUpdate(ctx, tx, achievementID)
	if err != nil {
		return domain.Definition{}, err
	}
	version := current.Version
	if current.Status != domain.StatusArchived {
		version = current.Version + 1
		// 归档保留定义主键和历史用户事实，只让 App 与后台默认列表跳过；这样不会破坏用户已有徽章展示引用。
		if _, err := tx.ExecContext(ctx, `
			UPDATE achievement_definitions
			SET status = 'archived', version = ?, updated_by_admin_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND achievement_id = ?`,
			version, operatorAdminID, nowMS, appcode.FromContext(ctx), achievementID,
		); err != nil {
			return domain.Definition{}, err
		}
		// 条件只服务旧事件推进；归档时清掉当前条件，避免恢复脚本或历史事件误匹配已删除配置。
		if _, err := tx.ExecContext(ctx, `
			DELETE FROM achievement_conditions
			WHERE app_code = ? AND achievement_id = ?`,
			appcode.FromContext(ctx), achievementID,
		); err != nil {
			return domain.Definition{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.Definition{}, err
	}
	return r.getAchievementDefinition(ctx, r.db, achievementID)
}

func (r *Repository) ClaimPendingAchievementRewardJobs(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, achievementRewardSelectSQL()+`
		WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_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, batchSize,
	)
	if err != nil {
		return nil, err
	}
	jobs, err := scanAchievementRewards(rows)
	_ = rows.Close()
	if err != nil {
		return nil, err
	}
	lockUntil := nowMS + lockTTL.Milliseconds()
	for _, job := range jobs {
		if _, err := tx.ExecContext(ctx, `
			UPDATE achievement_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) MarkAchievementRewardGranted(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")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE achievement_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 = ?`,
		walletGrantID, nowMS, appcode.FromContext(ctx), rewardJobID,
	)
	return err
}

func (r *Repository) MarkAchievementRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE achievement_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 <> 'granted'`,
		nextRetryAtMS, truncateAchievementFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID,
	)
	return err
}

func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID int64, definition domain.Definition) (domain.UserAchievement, error) {
	cycleKey := achievementCycleKey(definition)
	progresses, err := r.listAchievementProgress(ctx, r.db, userID, definition.AchievementID, cycleKey)
	if err != nil {
		return domain.UserAchievement{}, err
	}
	targetTotal := int64(0)
	for _, condition := range definition.Conditions {
		targetTotal += condition.TargetValue
	}
	if targetTotal == 0 && definition.PrimaryBadgeResourceID > 0 {
		targetTotal = 1
	}
	progressTotal := int64(0)
	completed := len(definition.Conditions) > 0
	for _, condition := range definition.Conditions {
		value := progresses[condition.ConditionID]
		progressTotal += value
		if value < condition.TargetValue {
			completed = false
		}
	}
	unlockedAt, unlocked, err := r.getAchievementUnlock(ctx, r.db, userID, definition.AchievementID, cycleKey)
	if err != nil {
		return domain.UserAchievement{}, err
	}
	if !unlocked && definition.PrimaryBadgeResourceID > 0 {
		// 新版成就以徽章资源配置为核心，不再强依赖事件条件；用户已经拥有该徽章展示资源时，H5 直接视为已激活。
		hasBadge, err := r.userHasBadgeDisplayResource(ctx, r.db, userID, definition.PrimaryBadgeResourceID)
		if err != nil {
			return domain.UserAchievement{}, err
		}
		if hasBadge {
			unlocked = true
			progressTotal = targetTotal
		}
	}
	userStatus := domain.ProgressStatusInProgress
	if completed {
		userStatus = domain.ProgressStatusCompleted
	}
	if unlocked {
		userStatus = domain.UnlockStatusUnlocked
	}
	rewardStatus, err := r.getAchievementRewardStatus(ctx, r.db, userID, definition.AchievementID, cycleKey)
	if err != nil {
		return domain.UserAchievement{}, err
	}
	if definition.RewardResourceGroupID == 0 && rewardStatus == "" {
		rewardStatus = "none"
	}
	return domain.UserAchievement{
		Definition:    definition,
		CycleKey:      cycleKey,
		ProgressValue: progressTotal,
		TargetValue:   targetTotal,
		UserStatus:    userStatus,
		UnlockedAtMS:  unlockedAt,
		RewardStatus:  rewardStatus,
	}, nil
}

func (r *Repository) insertAchievementEventConsumption(ctx context.Context, tx *sql.Tx, event domain.Event, nowMS int64) (bool, string, error) {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO achievement_event_consumption (
			app_code, event_id, event_type, source_service, user_id, metric_type,
			status, created_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
		appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, event.MetricType, nowMS,
	)
	if err == nil {
		return true, "", nil
	}
	if !isMySQLDuplicate(err) {
		return false, "", err
	}
	var status string
	if err := tx.QueryRowContext(ctx, `
		SELECT status FROM achievement_event_consumption
		WHERE app_code = ? AND event_id = ?`,
		appcode.FromContext(ctx), event.EventID,
	).Scan(&status); err != nil {
		return false, "", err
	}
	return false, status, nil
}

func (r *Repository) updateAchievementEventConsumption(ctx context.Context, tx *sql.Tx, eventID string, status string, skipReason string, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE achievement_event_consumption
		SET status = ?, skip_reason = ?, consumed_at_ms = ?
		WHERE app_code = ? AND event_id = ?`,
		status, truncateAchievementFailure(skipReason), nowMS, appcode.FromContext(ctx), eventID,
	)
	return err
}

func parseWalletBadgeGrantPayload(value string) (walletBadgeGrantPayload, error) {
	var payload walletBadgeGrantPayload
	if err := json.Unmarshal([]byte(value), &payload); err != nil {
		return walletBadgeGrantPayload{}, err
	}
	payload.GrantID = strings.TrimSpace(payload.GrantID)
	payload.GrantSource = strings.ToLower(strings.TrimSpace(payload.GrantSource))
	if payload.GrantID == "" {
		return walletBadgeGrantPayload{}, xerr.New(xerr.InvalidArgument, "grant_id is required")
	}
	return payload, nil
}

func walletBadgeSlotAndForm(item walletBadgeGrantItem) (string, string) {
	metadata := walletBadgeMetadata{}
	metadataJSON := strings.TrimSpace(item.MetadataJSON)
	if metadataJSON == "" || metadataJSON == "{}" {
		metadataJSON = metadataJSONFromResourceSnapshot(item.ResourceSnapshotJSON)
	}
	_ = json.Unmarshal([]byte(metadataJSON), &metadata)
	metadata.BadgeForm = strings.ToLower(strings.TrimSpace(metadata.BadgeForm))
	metadata.DefaultSlot = strings.ToLower(strings.TrimSpace(metadata.DefaultSlot))
	if metadata.BadgeForm == domain.BadgeFormStrip || metadata.DefaultSlot == domain.BadgeSlotProfileStrip {
		return domain.BadgeSlotProfileStrip, domain.BadgeFormStrip
	}
	return domain.BadgeSlotHonorWall, domain.BadgeFormTile
}

func metadataJSONFromResourceSnapshot(value string) string {
	var snapshot map[string]json.RawMessage
	if err := json.Unmarshal([]byte(value), &snapshot); err != nil {
		return "{}"
	}
	for _, key := range []string{"MetadataJSON", "metadata_json"} {
		raw, ok := snapshot[key]
		if !ok {
			continue
		}
		var text string
		if err := json.Unmarshal(raw, &text); err == nil {
			if strings.TrimSpace(text) != "" {
				return text
			}
			continue
		}
		if len(raw) > 0 {
			return string(raw)
		}
	}
	return "{}"
}

func shouldSkipWalletBadgeGrantSource(source string) bool {
	switch strings.ToLower(strings.TrimSpace(source)) {
	case domain.GrantSourceAchievement, "growth_level", "vip_purchase":
		return true
	default:
		return false
	}
}

func walletBadgeDisplaySourceType(source string) string {
	switch strings.ToLower(strings.TrimSpace(source)) {
	case "vip_paid", "vip_trial", "vip_purchase":
		// VIP 长徽章由 wallet 当前有效会员/体验卡权益决定，显式来源让所有客户端无需猜资源名。
		return domain.SourceVIP
	default:
		return domain.SourceSystemGrant
	}
}

func (r *Repository) listAchievementDefinitionsForEvent(ctx context.Context, q queryer, metricType string, occurredAtMS int64) ([]domain.Definition, error) {
	rows, err := q.QueryContext(ctx, achievementDefinitionSelectSQL()+`
		WHERE d.app_code = ? AND d.status = 'active'
		  AND (d.effective_from_ms = 0 OR d.effective_from_ms <= ?)
		  AND (d.effective_to_ms = 0 OR d.effective_to_ms > ?)
		  AND EXISTS (
		    SELECT 1 FROM achievement_conditions c
		    WHERE c.app_code = d.app_code AND c.achievement_id = d.achievement_id
		      AND c.version = d.version AND c.metric_type = ?
		  )
		ORDER BY d.sort_order DESC, d.achievement_id ASC`,
		appcode.FromContext(ctx), occurredAtMS, occurredAtMS, metricType,
	)
	if err != nil {
		return nil, err
	}
	definitions, err := scanAchievementDefinitions(rows)
	_ = rows.Close()
	if err != nil {
		return nil, err
	}
	for index := range definitions {
		conditions, err := r.listAchievementConditions(ctx, q, definitions[index].AchievementID, definitions[index].Version)
		if err != nil {
			return nil, err
		}
		definitions[index].Conditions = conditions
	}
	return definitions, nil
}

func (r *Repository) applyAchievementProgressDelta(ctx context.Context, tx *sql.Tx, userID int64, definition domain.Definition, condition domain.Condition, cycleKey string, delta int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_achievement_progress (
			app_code, user_id, achievement_id, condition_id, cycle_key,
			progress_value, target_value, status, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 0, ?, 'in_progress', ?)`,
		appcode.FromContext(ctx), userID, definition.AchievementID, condition.ConditionID, cycleKey, condition.TargetValue, nowMS,
	)
	if err != nil {
		return err
	}
	var current int64
	if err := tx.QueryRowContext(ctx, `
		SELECT progress_value
		FROM user_achievement_progress
		WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND condition_id = ? AND cycle_key = ?
		FOR UPDATE`,
		appcode.FromContext(ctx), userID, definition.AchievementID, condition.ConditionID, cycleKey,
	).Scan(&current); err != nil {
		return err
	}
	next := current + delta
	status := domain.ProgressStatusInProgress
	if next >= condition.TargetValue {
		status = domain.ProgressStatusCompleted
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE user_achievement_progress
		SET progress_value = ?, target_value = ?, status = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND condition_id = ? AND cycle_key = ?`,
		next, condition.TargetValue, status, nowMS, appcode.FromContext(ctx), userID, definition.AchievementID, condition.ConditionID, cycleKey,
	)
	return err
}

func (r *Repository) unlockAchievementIfComplete(ctx context.Context, tx *sql.Tx, userID int64, definition domain.Definition, cycleKey string, nowMS int64) (bool, bool, error) {
	progresses, err := r.listAchievementProgress(ctx, tx, userID, definition.AchievementID, cycleKey)
	if err != nil {
		return false, false, err
	}
	for _, condition := range definition.Conditions {
		if progresses[condition.ConditionID] < condition.TargetValue {
			return false, false, nil
		}
	}
	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_achievement_unlocks (
			app_code, user_id, achievement_id, cycle_key, version, status,
			unlocked_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'unlocked', ?, ?, ?)`,
		appcode.FromContext(ctx), userID, definition.AchievementID, cycleKey, definition.Version, nowMS, nowMS, nowMS,
	)
	if err != nil {
		return false, false, err
	}
	affected, err := result.RowsAffected()
	if err != nil || affected == 0 {
		return false, false, err
	}
	rewardCreated := false
	rewardJobID := ""
	if definition.RewardResourceGroupID > 0 {
		rewardJobID = idgen.New("achreward")
		changed, err := r.insertAchievementRewardJob(ctx, tx, rewardJobID, userID, definition.AchievementID, cycleKey, definition.RewardResourceGroupID, nowMS)
		if err != nil {
			return false, false, err
		}
		rewardCreated = changed
		if changed {
			if _, err := tx.ExecContext(ctx, `
				UPDATE user_achievement_unlocks
				SET reward_job_id = ?, updated_at_ms = ?
				WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`,
				rewardJobID, nowMS, appcode.FromContext(ctx), userID, definition.AchievementID, cycleKey,
			); err != nil {
				return false, false, err
			}
		}
	}
	if definition.PrimaryBadgeResourceID > 0 {
		if err := r.autoPinAchievementBadge(ctx, tx, userID, definition, nowMS); err != nil {
			return false, false, err
		}
	}
	return true, rewardCreated, nil
}

func (r *Repository) insertAchievementRewardJob(ctx context.Context, tx *sql.Tx, rewardJobID string, userID int64, achievementID string, cycleKey string, resourceGroupID int64, nowMS int64) (bool, error) {
	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO achievement_reward_jobs (
			app_code, reward_job_id, user_id, achievement_id, cycle_key, resource_group_id,
			wallet_command_id, status, next_retry_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
		appcode.FromContext(ctx), rewardJobID, userID, achievementID, cycleKey, resourceGroupID,
		"achievement_reward:"+rewardJobID, nowMS, nowMS,
	)
	if err != nil {
		return false, err
	}
	affected, err := result.RowsAffected()
	return affected > 0, err
}

func (r *Repository) autoPinAchievementBadge(ctx context.Context, tx *sql.Tx, userID int64, definition domain.Definition, nowMS int64) error {
	sourceID := definition.AchievementID + ":" + achievementCycleKey(definition)
	if definition.HonorVisibility {
		position, err := r.nextBadgeDisplayPosition(ctx, tx, userID, domain.BadgeSlotHonorWall)
		if err != nil {
			return err
		}
		if err := r.insertBadgeDisplaySlot(ctx, tx, userID, domain.BadgeSlotHonorWall, position, domain.BadgeFormTile, definition.PrimaryBadgeResourceID, "", domain.SourceAchievement, sourceID, "auto", nowMS); err != nil {
			return err
		}
	}
	switch definition.AutoPinPolicy {
	case domain.AutoPinIfEmpty:
		count, err := r.badgeDisplaySlotCount(ctx, tx, userID, domain.BadgeSlotProfileTile)
		if err != nil {
			return err
		}
		if count == 0 {
			return r.insertBadgeDisplaySlot(ctx, tx, userID, domain.BadgeSlotProfileTile, 1, domain.BadgeFormTile, definition.PrimaryBadgeResourceID, "", domain.SourceAchievement, sourceID, "auto", nowMS)
		}
	case domain.AutoPinAlways:
		count, err := r.badgeDisplaySlotCount(ctx, tx, userID, domain.BadgeSlotProfileTile)
		if err != nil {
			return err
		}
		if count < 6 {
			return r.insertBadgeDisplaySlot(ctx, tx, userID, domain.BadgeSlotProfileTile, int32(count+1), domain.BadgeFormTile, definition.PrimaryBadgeResourceID, "", domain.SourceAchievement, sourceID, "auto", nowMS)
		}
	}
	return nil
}

func (r *Repository) insertBadgeDisplaySlot(ctx context.Context, tx *sql.Tx, userID int64, slot string, position int32, badgeForm string, resourceID int64, entitlementID string, sourceType string, sourceID string, pinMode string, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_badge_display_slots (
			app_code, user_id, slot, position, badge_form, resource_id,
			entitlement_id, source_type, source_id, pin_mode, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), userID, slot, position, badgeForm, resourceID, entitlementID, sourceType, sourceID, pinMode, nowMS,
	)
	return err
}

func (r *Repository) badgeDisplaySlotCount(ctx context.Context, q queryer, userID int64, slot string) (int64, error) {
	var count int64
	err := q.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM user_badge_display_slots
		WHERE app_code = ? AND user_id = ? AND slot = ?`,
		appcode.FromContext(ctx), userID, slot,
	).Scan(&count)
	return count, err
}

func (r *Repository) nextBadgeDisplayPosition(ctx context.Context, q queryer, userID int64, slot string) (int32, error) {
	var position int32
	err := q.QueryRowContext(ctx, `
		SELECT COALESCE(MAX(position), 0) + 1
		FROM user_badge_display_slots
		WHERE app_code = ? AND user_id = ? AND slot = ?`,
		appcode.FromContext(ctx), userID, slot,
	).Scan(&position)
	return position, err
}

func (r *Repository) userHasBadgeDisplayResource(ctx context.Context, q queryer, userID int64, resourceID int64) (bool, error) {
	var count int64
	err := q.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM user_badge_display_slots
		WHERE app_code = ? AND user_id = ? AND resource_id = ?
		  AND slot IN ('honor_wall', 'profile_tile')`,
		appcode.FromContext(ctx), userID, resourceID,
	).Scan(&count)
	return count > 0, err
}

func (r *Repository) listBadgeDisplayItems(ctx context.Context, q queryer, userID int64, slot string) ([]domain.BadgeDisplayItem, error) {
	where := []string{"app_code = ?", "user_id = ?"}
	args := []any{appcode.FromContext(ctx), userID}
	if slot != "" {
		where = append(where, "slot = ?")
		args = append(args, slot)
	}
	rows, err := q.QueryContext(ctx, `
		SELECT user_id, slot, position, badge_form, resource_id, entitlement_id,
		       source_type, source_id, pin_mode, updated_at_ms
		FROM user_badge_display_slots
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY slot ASC, position ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]domain.BadgeDisplayItem, 0)
	for rows.Next() {
		item, err := scanBadgeDisplayItem(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) listAchievementProgress(ctx context.Context, q queryer, userID int64, achievementID string, cycleKey string) (map[string]int64, error) {
	rows, err := q.QueryContext(ctx, `
		SELECT condition_id, progress_value
		FROM user_achievement_progress
		WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`,
		appcode.FromContext(ctx), userID, achievementID, cycleKey,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	result := map[string]int64{}
	for rows.Next() {
		var conditionID string
		var progressValue int64
		if err := rows.Scan(&conditionID, &progressValue); err != nil {
			return nil, err
		}
		result[conditionID] = progressValue
	}
	return result, rows.Err()
}

func (r *Repository) getAchievementUnlock(ctx context.Context, q queryer, userID int64, achievementID string, cycleKey string) (int64, bool, error) {
	var unlockedAt int64
	err := q.QueryRowContext(ctx, `
		SELECT unlocked_at_ms
		FROM user_achievement_unlocks
		WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`,
		appcode.FromContext(ctx), userID, achievementID, cycleKey,
	).Scan(&unlockedAt)
	if errors.Is(err, sql.ErrNoRows) {
		return 0, false, nil
	}
	return unlockedAt, err == nil, err
}

func (r *Repository) getAchievementRewardStatus(ctx context.Context, q queryer, userID int64, achievementID string, cycleKey string) (string, error) {
	var status string
	err := q.QueryRowContext(ctx, `
		SELECT status
		FROM achievement_reward_jobs
		WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`,
		appcode.FromContext(ctx), userID, achievementID, cycleKey,
	).Scan(&status)
	if errors.Is(err, sql.ErrNoRows) {
		return "", nil
	}
	return status, err
}

func (r *Repository) getAchievementDefinitionForUpdate(ctx context.Context, tx *sql.Tx, achievementID string) (domain.Definition, error) {
	row := tx.QueryRowContext(ctx, achievementDefinitionSelectSQL()+`
		WHERE d.app_code = ? AND d.achievement_id = ?
		FOR UPDATE`,
		appcode.FromContext(ctx), achievementID,
	)
	item, err := scanAchievementDefinition(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Definition{}, xerr.New(xerr.NotFound, "achievement definition not found")
	}
	return item, err
}

func (r *Repository) getAchievementDefinition(ctx context.Context, q queryer, achievementID string) (domain.Definition, error) {
	row := q.QueryRowContext(ctx, achievementDefinitionSelectSQL()+`
		WHERE d.app_code = ? AND d.achievement_id = ?`,
		appcode.FromContext(ctx), achievementID,
	)
	item, err := scanAchievementDefinition(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Definition{}, xerr.New(xerr.NotFound, "achievement definition not found")
	}
	if err != nil {
		return domain.Definition{}, err
	}
	conditions, err := r.listAchievementConditions(ctx, q, item.AchievementID, item.Version)
	if err != nil {
		return domain.Definition{}, err
	}
	item.Conditions = conditions
	return item, nil
}

func (r *Repository) listAchievementConditions(ctx context.Context, q queryer, achievementID string, version int64) ([]domain.Condition, error) {
	rows, err := q.QueryContext(ctx, `
		SELECT app_code, condition_id, achievement_id, version, metric_type,
		       target_value, target_unit, COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), created_at_ms
		FROM achievement_conditions
		WHERE app_code = ? AND achievement_id = ? AND version = ?
		ORDER BY condition_id ASC`,
		appcode.FromContext(ctx), achievementID, version,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]domain.Condition, 0)
	for rows.Next() {
		var item domain.Condition
		if err := rows.Scan(&item.AppCode, &item.ConditionID, &item.AchievementID, &item.Version, &item.MetricType, &item.TargetValue, &item.TargetUnit, &item.DimensionFilterJSON, &item.CreatedAtMS); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func achievementDefinitionWhere(ctx context.Context, query domain.ListQuery, nowMS int64) (string, []any) {
	conditions := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if query.Status != "" {
		conditions = append(conditions, "status = ?")
		args = append(args, query.Status)
	} else {
		conditions = append(conditions, "status = 'active'")
		conditions = append(conditions, "(effective_from_ms = 0 OR effective_from_ms <= ?)")
		args = append(args, nowMS)
		conditions = append(conditions, "(effective_to_ms = 0 OR effective_to_ms > ?)")
		args = append(args, nowMS)
	}
	if query.CollectionType != "" {
		conditions = append(conditions, "collection_type = ?")
		args = append(args, query.CollectionType)
	}
	if query.CollectionID != "" {
		conditions = append(conditions, "collection_id = ?")
		args = append(args, query.CollectionID)
	}
	return "WHERE " + strings.Join(conditions, " AND "), args
}

func achievementDefinitionAdminWhere(ctx context.Context, query domain.ListQuery) (string, []any) {
	conditions := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if query.Status != "" {
		conditions = append(conditions, "status = ?")
		args = append(args, query.Status)
	} else {
		conditions = append(conditions, "status <> 'archived'")
	}
	if query.CollectionType != "" {
		conditions = append(conditions, "collection_type = ?")
		args = append(args, query.CollectionType)
	}
	if query.CollectionID != "" {
		conditions = append(conditions, "collection_id = ?")
		args = append(args, query.CollectionID)
	}
	return "WHERE " + strings.Join(conditions, " AND "), args
}

func achievementCycleKey(definition domain.Definition) string {
	if definition.AchievementType == domain.TypeActivityLimited {
		if definition.CollectionID != "" {
			return "activity:" + definition.CollectionID
		}
		return "activity:" + definition.AchievementID
	}
	return domain.CycleLifetime
}

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

func scanAchievementDefinition(row rowScanner) (domain.Definition, error) {
	var item domain.Definition
	err := row.Scan(
		&item.AppCode, &item.AchievementID, &item.CollectionID, &item.CollectionType,
		&item.AchievementType, &item.Title, &item.Description, &item.Status, &item.Version,
		&item.PrimaryBadgeResourceID, &item.RewardResourceGroupID, &item.AutoPinPolicy,
		&item.HonorVisibility, &item.EffectiveFromMS, &item.EffectiveToMS, &item.SortOrder,
		&item.DisplayConfigJSON, &item.CreatedByAdminID, &item.UpdatedByAdminID,
		&item.CreatedAtMS, &item.UpdatedAtMS,
	)
	return item, err
}

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

func scanAchievementReward(row rowScanner) (domain.RewardJob, error) {
	var item domain.RewardJob
	err := row.Scan(
		&item.AppCode, &item.RewardJobID, &item.UserID, &item.AchievementID, &item.CycleKey,
		&item.ResourceGroupID, &item.WalletCommandID, &item.WalletGrantID, &item.Status,
		&item.AttemptCount, &item.FailureReason, &item.CreatedAtMS, &item.UpdatedAtMS,
	)
	return item, err
}

func scanBadgeDisplayItem(row rowScanner) (domain.BadgeDisplayItem, error) {
	var item domain.BadgeDisplayItem
	err := row.Scan(
		&item.UserID, &item.Slot, &item.Position, &item.BadgeForm, &item.ResourceID,
		&item.EntitlementID, &item.SourceType, &item.SourceID, &item.PinMode, &item.UpdatedAtMS,
	)
	return item, err
}

func achievementDefinitionSelectSQL() string {
	return `SELECT d.app_code, d.achievement_id, d.collection_id, d.collection_type,
	               d.achievement_type, d.title, d.description, d.status, d.version,
	               d.primary_badge_resource_id, d.reward_resource_group_id, d.auto_pin_policy,
	               d.honor_visibility, d.effective_from_ms, d.effective_to_ms, d.sort_order,
	               COALESCE(CAST(d.display_config_json AS CHAR), '{}'), d.created_by_admin_id,
	               d.updated_by_admin_id, d.created_at_ms, d.updated_at_ms
	        FROM achievement_definitions d `
}

func achievementRewardSelectSQL() string {
	return `SELECT app_code, reward_job_id, user_id, achievement_id, cycle_key,
	               resource_group_id, wallet_command_id, wallet_grant_id, status,
	               attempt_count, failure_reason, created_at_ms, updated_at_ms
	        FROM achievement_reward_jobs `
}

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