package mysql

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

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

const (
	runtimeEventTypeGift = "RoomGiftSent"
	outboxStatusPending  = "pending"
	outboxStatusRunning  = "running"
	outboxStatusSent     = "sent"
	outboxStatusFailed   = "failed"
)

// insertActivityTemplateRuntimeVersion is called inside the publish transaction. It binds all future
// events and settlements to the exact version snapshot that was just persisted.
func insertActivityTemplateRuntimeVersion(ctx context.Context, tx *sql.Tx, item domain.Template, versionNo int64, nowMS int64, settlementLatenessMS int64) error {
	if settlementLatenessMS <= 0 {
		settlementLatenessMS = defaultActivityTemplateSettlementLateness.Milliseconds()
	}
	runtimeFromMS := nowMS
	if item.StartMS > runtimeFromMS {
		runtimeFromMS = item.StartMS
	}
	if runtimeFromMS >= item.EndMS {
		return xerr.New(xerr.Conflict, "activity template runtime period has already ended")
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO activity_template_runtime_versions (
		  app_code, template_id, template_code, version_no, start_ms, end_ms, all_regions,
		  runtime_from_ms, runtime_to_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
		item.AppCode, item.TemplateID, item.TemplateCode, versionNo, item.StartMS, item.EndMS,
		item.AllRegions, runtimeFromMS, nowMS, nowMS); err != nil {
		return err
	}
	for _, regionID := range item.RegionIDs {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO activity_template_runtime_regions (app_code, template_id, version_no, region_id, created_at_ms)
			VALUES (?, ?, ?, ?, ?)`, item.AppCode, item.TemplateID, versionNo, regionID, nowMS); err != nil {
			return err
		}
	}
	// Every period is materialized at publish time. Cron therefore only claims bounded indexed rows and
	// never scans versions or synthesizes historical days differently across workers.
	dayCursor := utcDayStart(runtimeFromMS)
	for dayCursor < item.EndMS {
		dayEnd := dayCursor + int64(24*time.Hour/time.Millisecond)
		periodStart := maxInt64(dayCursor, runtimeFromMS)
		periodEnd := minInt64(dayEnd, item.EndMS)
		if periodStart < periodEnd {
			periodKey := time.UnixMilli(dayCursor).UTC().Format("2006-01-02")
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO activity_template_rank_periods (
				  app_code, template_id, template_code, version_no, board_type, period_key,
				  period_start_ms, period_end_ms, settle_after_ms, leaderboard_size, status, created_at_ms, updated_at_ms
				) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				item.AppCode, item.TemplateID, item.TemplateCode, versionNo, domain.BoardTypeDaily, periodKey,
				periodStart, periodEnd, periodEnd+settlementLatenessMS, item.DailyLeaderboardSize,
				domain.PeriodStatusPending, nowMS, nowMS); err != nil {
				return err
			}
		}
		dayCursor = dayEnd
	}
	_, err := tx.ExecContext(ctx, `
		INSERT INTO activity_template_rank_periods (
		  app_code, template_id, template_code, version_no, board_type, period_key,
		  period_start_ms, period_end_ms, settle_after_ms, leaderboard_size, status, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'total', ?, ?, ?, ?, ?, ?, ?)`,
		item.AppCode, item.TemplateID, item.TemplateCode, versionNo, domain.BoardTypeTotal,
		runtimeFromMS, item.EndMS, item.EndMS+settlementLatenessMS, item.TotalLeaderboardSize,
		domain.PeriodStatusPending, nowMS, nowMS)
	return err
}

func closeActivityTemplateRuntimeVersion(ctx context.Context, tx *sql.Tx, templateID string, versionNo int64, nowMS int64, settlementLatenessMS int64) error {
	if versionNo <= 0 {
		return nil
	}
	if settlementLatenessMS <= 0 {
		settlementLatenessMS = defaultActivityTemplateSettlementLateness.Milliseconds()
	}
	appCode := appcode.FromContext(ctx)
	if _, err := tx.ExecContext(ctx, `
		UPDATE activity_template_runtime_versions
		SET runtime_to_ms = CASE WHEN runtime_to_ms = 0 OR runtime_to_ms > ? THEN ? ELSE runtime_to_ms END,
		    updated_at_ms = ?
		WHERE app_code = ? AND template_id = ? AND version_no = ?`,
		nowMS, nowMS, nowMS, appCode, templateID, versionNo); err != nil {
		return err
	}
	// Lock all unfinished periods before clipping them so settlement cannot freeze a ranking while disable
	// is concurrently changing its event window.
	rows, err := tx.QueryContext(ctx, `
		SELECT board_type, period_key
		FROM activity_template_rank_periods
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND status IN (?, ?)
		FOR UPDATE`, appCode, templateID, versionNo, domain.PeriodStatusPending, domain.PeriodStatusSettling)
	if err != nil {
		return err
	}
	for rows.Next() {
		var boardType, periodKey string
		if err := rows.Scan(&boardType, &periodKey); err != nil {
			rows.Close()
			return err
		}
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE activity_template_rank_periods
		SET status = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND template_id = ? AND version_no = ?
		  AND status IN (?, ?) AND period_start_ms >= ?`,
		domain.PeriodStatusCancelled, nowMS, appCode, templateID, versionNo,
		domain.PeriodStatusPending, domain.PeriodStatusSettling, nowMS); err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE activity_template_rank_periods
		SET period_end_ms = ?, settle_after_ms = ?, status = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND template_id = ? AND version_no = ?
		  AND status IN (?, ?) AND period_start_ms < ? AND period_end_ms > ?`,
		nowMS, nowMS+settlementLatenessMS, domain.PeriodStatusPending, nowMS, appCode, templateID, versionNo,
		domain.PeriodStatusPending, domain.PeriodStatusSettling, nowMS, nowMS)
	return err
}

// FindCurrentActivityTemplateRuntime resolves a concrete region to one immutable active version.
func (r *Repository) FindCurrentActivityTemplateRuntime(ctx context.Context, templateCode string, regionID int64, atMS int64) (domain.Runtime, bool, error) {
	if r == nil || r.db == nil {
		return domain.Runtime{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	return findActivityTemplateRuntime(ctx, r.db, appcode.FromContext(ctx), strings.TrimSpace(templateCode), regionID, atMS)
}

// FindActivityTemplateRuntimeForPeriod resolves the immutable version that owns a materialized period.
// Unlike event/progress creation lookups this deliberately remains readable after runtime_to/end_ms, so
// result pages and idempotent wallet compensation keep a stable version identity after campaign close.
func (r *Repository) FindActivityTemplateRuntimeForPeriod(ctx context.Context, templateCode string, versionNo int64, regionID int64, boardType string, periodKey string, atMS int64) (domain.Runtime, bool, error) {
	if r == nil || r.db == nil {
		return domain.Runtime{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var item domain.Runtime
	var snapshotJSON string
	err := r.db.QueryRowContext(ctx, `
		SELECT rv.runtime_from_ms, rv.runtime_to_ms, CAST(v.snapshot_json AS CHAR), rv.version_no
		FROM activity_template_rank_periods rp
		JOIN activity_template_runtime_versions rv
		  ON rv.app_code = rp.app_code AND rv.template_id = rp.template_id AND rv.version_no = rp.version_no
		JOIN activity_template_versions v
		  ON v.app_code = rv.app_code AND v.template_id = rv.template_id AND v.version_no = rv.version_no
		WHERE rp.app_code = ? AND rp.template_code = ? AND (? = 0 OR rp.version_no = ?)
		  AND rp.board_type = ? AND rp.period_key = ?
		  AND rp.period_start_ms <= ? AND rp.status <> ?
		  AND (rv.all_regions = TRUE OR EXISTS (
		    SELECT 1 FROM activity_template_runtime_regions rr
		    WHERE rr.app_code = rv.app_code AND rr.template_id = rv.template_id
		      AND rr.version_no = rv.version_no AND rr.region_id = ?
		  ))
		ORDER BY CASE WHEN rp.period_start_ms <= ? AND rp.period_end_ms > ? THEN 0 ELSE 1 END,
		         rv.runtime_from_ms DESC, rv.version_no DESC
		LIMIT 1`, appcode.FromContext(ctx), strings.TrimSpace(templateCode), versionNo, versionNo,
		strings.TrimSpace(boardType), strings.TrimSpace(periodKey), atMS, domain.PeriodStatusCancelled, regionID, atMS, atMS).
		Scan(&item.RuntimeFromMS, &item.RuntimeToMS, &snapshotJSON, &item.VersionNo)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Runtime{}, false, nil
	}
	if err != nil {
		return domain.Runtime{}, false, err
	}
	if err := json.Unmarshal([]byte(snapshotJSON), &item.Template); err != nil {
		return domain.Runtime{}, false, fmt.Errorf("decode activity template leaderboard runtime version: %w", err)
	}
	return item, true, nil
}

// FindActivityTemplateRuntimeForUserTask authorizes historical task access from the immutable progress fact,
// not from the user's current profile region. This survives region migration and activity disable/re-publish.
func (r *Repository) FindActivityTemplateRuntimeForUserTask(ctx context.Context, templateCode string, versionNo int64, userID int64, taskDay string, taskKey string) (domain.Runtime, bool, error) {
	if r == nil || r.db == nil {
		return domain.Runtime{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var item domain.Runtime
	var snapshotJSON string
	err := r.db.QueryRowContext(ctx, `
		SELECT rv.runtime_from_ms, rv.runtime_to_ms, CAST(v.snapshot_json AS CHAR), rv.version_no
		FROM activity_template_task_progress p
		JOIN activity_template_runtime_versions rv
		  ON rv.app_code = p.app_code AND rv.template_id = p.template_id AND rv.version_no = p.version_no
		JOIN activity_template_versions v
		  ON v.app_code = rv.app_code AND v.template_id = rv.template_id AND v.version_no = rv.version_no
		WHERE p.app_code = ? AND p.template_code = ? AND p.version_no = ? AND p.user_id = ? AND p.task_day = ?
		  AND (? = '' OR p.task_key = ?)
		LIMIT 1`, appcode.FromContext(ctx), strings.TrimSpace(templateCode), versionNo, userID,
		strings.TrimSpace(taskDay), strings.TrimSpace(taskKey), strings.TrimSpace(taskKey)).
		Scan(&item.RuntimeFromMS, &item.RuntimeToMS, &snapshotJSON, &item.VersionNo)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Runtime{}, false, nil
	}
	if err != nil {
		return domain.Runtime{}, false, err
	}
	if err := json.Unmarshal([]byte(snapshotJSON), &item.Template); err != nil {
		return domain.Runtime{}, false, fmt.Errorf("decode activity template task runtime version: %w", err)
	}
	return item, true, nil
}

// FindActivityTemplateRuntimeForExactLeaderboard resolves the public, immutable period identity without
// consulting either current profile region or participant rows. Region targeting controls campaign delivery;
// it is not a privacy boundary for a versioned leaderboard share/notification deep link.
func (r *Repository) FindActivityTemplateRuntimeForExactLeaderboard(ctx context.Context, templateCode string, versionNo int64, boardType string, periodKey string, atMS int64) (domain.Runtime, bool, error) {
	if r == nil || r.db == nil {
		return domain.Runtime{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var item domain.Runtime
	var snapshotJSON string
	err := r.db.QueryRowContext(ctx, `
		SELECT rv.runtime_from_ms, rv.runtime_to_ms, CAST(v.snapshot_json AS CHAR), rv.version_no
		FROM activity_template_rank_periods rp
		JOIN activity_template_runtime_versions rv
		  ON rv.app_code = rp.app_code AND rv.template_id = rp.template_id AND rv.version_no = rp.version_no
		JOIN activity_template_versions v
		  ON v.app_code = rv.app_code AND v.template_id = rv.template_id AND v.version_no = rv.version_no
		WHERE rp.app_code = ? AND rp.template_code = ? AND rp.version_no = ?
		  AND rp.board_type = ? AND rp.period_key = ? AND rp.period_start_ms <= ? AND rp.status <> ?
		LIMIT 1`, appcode.FromContext(ctx), strings.TrimSpace(templateCode), versionNo,
		strings.TrimSpace(boardType), strings.TrimSpace(periodKey), atMS, domain.PeriodStatusCancelled).
		Scan(&item.RuntimeFromMS, &item.RuntimeToMS, &snapshotJSON, &item.VersionNo)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Runtime{}, false, nil
	}
	if err != nil {
		return domain.Runtime{}, false, err
	}
	if err := json.Unmarshal([]byte(snapshotJSON), &item.Template); err != nil {
		return domain.Runtime{}, false, fmt.Errorf("decode exact activity template leaderboard runtime: %w", err)
	}
	return item, true, nil
}

// ConsumeActivityTemplateGiftEvent performs event idempotency, score updates, task progress and statistics
// outbox writes in one transaction. MQ publication is deliberately outside this transaction and retried by the relay.
func (r *Repository) ConsumeActivityTemplateGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error) {
	if r == nil || r.db == nil {
		return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	if existing, found, err := r.getActivityTemplateEventResult(ctx, appCode, event.EventID); err != nil || found {
		return existing, err
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.EventResult{}, err
	}
	defer tx.Rollback()
	runtime, found, err := findActivityTemplateRuntime(ctx, tx, appCode, "", event.RegionID, event.OccurredAtMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	result := domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}
	if !found {
		result.SkipReason = "no_active_runtime"
		if err := insertActivityTemplateEventConsumption(ctx, tx, appCode, event, result, nowMS); err != nil {
			if isMySQLDuplicate(err) {
				tx.Rollback()
				return r.mustGetActivityTemplateEventResult(ctx, appCode, event.EventID)
			}
			return domain.EventResult{}, err
		}
		if err := tx.Commit(); err != nil {
			return domain.EventResult{}, err
		}
		return result, nil
	}
	allowedGift := false
	for _, gift := range runtime.Template.Gifts {
		if gift.GiftID == event.GiftID {
			allowedGift = true
			break
		}
	}
	result.TemplateID = runtime.Template.TemplateID
	result.TemplateCode = runtime.Template.TemplateCode
	result.VersionNo = runtime.VersionNo
	result.TaskDay = utcTaskDay(event.OccurredAtMS)
	if !allowedGift {
		result.SkipReason = "gift_not_configured"
		if err := insertActivityTemplateEventConsumption(ctx, tx, appCode, event, result, nowMS); err != nil {
			if isMySQLDuplicate(err) {
				tx.Rollback()
				return r.mustGetActivityTemplateEventResult(ctx, appCode, event.EventID)
			}
			return domain.EventResult{}, err
		}
		if err := tx.Commit(); err != nil {
			return domain.EventResult{}, err
		}
		return result, nil
	}
	// Each leaderboard owns an independent watermark. Around UTC midnight a daily board may already be
	// frozen while the activity total board is still open; rejecting the whole gift would permanently corrupt
	// the final total ranking. We still lock daily then total in one fixed order to avoid settlement deadlocks.
	acceptance, err := lockActivityTemplateScorePeriods(ctx, tx, runtime, result.TaskDay, nowMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	if !acceptance.Daily && !acceptance.Total {
		result.SkipReason = acceptance.auditReason()
		if err := insertActivityTemplateEventConsumption(ctx, tx, appCode, event, result, nowMS); err != nil {
			if isMySQLDuplicate(err) {
				tx.Rollback()
				return r.mustGetActivityTemplateEventResult(ctx, appCode, event.EventID)
			}
			return domain.EventResult{}, err
		}
		if err := tx.Commit(); err != nil {
			return domain.EventResult{}, err
		}
		return result, nil
	}
	result.Status = domain.EventStatusConsumed
	result.SkipReason = acceptance.auditReason()
	if err := insertActivityTemplateEventConsumption(ctx, tx, appCode, event, result, nowMS); err != nil {
		if isMySQLDuplicate(err) {
			tx.Rollback()
			return r.mustGetActivityTemplateEventResult(ctx, appCode, event.EventID)
		}
		return domain.EventResult{}, err
	}
	dayStart, dayEnd := clippedUTCDay(runtime, event.OccurredAtMS)
	if acceptance.Daily {
		if _, err := tx.ExecContext(ctx, `
		INSERT INTO activity_template_user_day_scores (
		  app_code, template_id, template_code, version_no, task_day, day_start_ms, day_end_ms,
		  user_id, score, gift_count, coin_amount, first_scored_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE score = score + VALUES(score), gift_count = gift_count + VALUES(gift_count),
		  coin_amount = coin_amount + VALUES(coin_amount),
		  first_scored_at_ms = LEAST(first_scored_at_ms, VALUES(first_scored_at_ms)),
		  updated_at_ms = VALUES(updated_at_ms)`,
			appCode, runtime.Template.TemplateID, runtime.Template.TemplateCode, runtime.VersionNo, result.TaskDay,
			dayStart, dayEnd, event.UserID, event.CoinAmount, event.GiftCount, event.CoinAmount, event.OccurredAtMS, nowMS); err != nil {
			return domain.EventResult{}, err
		}
	}
	if acceptance.Total {
		if _, err := tx.ExecContext(ctx, `
		INSERT INTO activity_template_user_total_scores (
		  app_code, template_id, template_code, version_no, user_id, score, gift_count, coin_amount, first_scored_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE score = score + VALUES(score), gift_count = gift_count + VALUES(gift_count),
		  coin_amount = coin_amount + VALUES(coin_amount),
		  first_scored_at_ms = LEAST(first_scored_at_ms, VALUES(first_scored_at_ms)),
		  updated_at_ms = VALUES(updated_at_ms)`,
			appCode, runtime.Template.TemplateID, runtime.Template.TemplateCode, runtime.VersionNo,
			event.UserID, event.CoinAmount, event.GiftCount, event.CoinAmount, event.OccurredAtMS, nowMS); err != nil {
			return domain.EventResult{}, err
		}
	}
	if err := insertActivityTemplateStatsFact(ctx, tx, statsFactInput{
		SourceKey: event.EventID + ":gift", EventType: activitymq.EventTypeGiftScored, Runtime: runtime,
		TaskDay: result.TaskDay, RegionID: event.RegionID, UserID: event.UserID, GiftID: event.GiftID,
		GiftCountDelta: event.GiftCount, CoinAmountDelta: event.CoinAmount, OccurredAtMS: event.OccurredAtMS,
	}, nowMS); err != nil {
		return domain.EventResult{}, err
	}
	if acceptance.Daily {
		for _, task := range runtime.Template.Tasks {
			var delta int64
			switch task.TaskType {
			case domain.TaskTypeGiftCount:
				delta = event.GiftCount
			case domain.TaskTypeGiftCoinAmount:
				delta = event.CoinAmount
			default:
				continue
			}
			progress, appliedDelta, completedDelta, err := applyActivityTemplateTaskProgress(ctx, tx, runtime, result.TaskDay, event.UserID, task, delta, event.RegionID, event.OccurredAtMS, nowMS)
			if err != nil {
				return domain.EventResult{}, err
			}
			if appliedDelta == 0 && completedDelta == 0 {
				continue
			}
			if err := insertActivityTemplateStatsFact(ctx, tx, statsFactInput{
				SourceKey: event.EventID + ":task:" + task.TaskKey, EventType: activitymq.EventTypeTaskProgressed,
				Runtime: runtime, TaskDay: result.TaskDay, RegionID: event.RegionID, UserID: event.UserID,
				TaskKey: task.TaskKey, TaskType: task.TaskType, ProgressDelta: appliedDelta,
				CompletedDelta: completedDelta, OccurredAtMS: event.OccurredAtMS,
			}, nowMS); err != nil {
				return domain.EventResult{}, err
			}
			_ = progress
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.EventResult{}, err
	}
	return result, nil
}

type scorePeriodAcceptance struct {
	Daily       bool
	Total       bool
	DailyReason string
	TotalReason string
}

func (item scorePeriodAcceptance) auditReason() string {
	reasons := make([]string, 0, 2)
	if !item.Daily {
		reasons = append(reasons, "daily:"+item.DailyReason)
	}
	if !item.Total {
		reasons = append(reasons, "total:"+item.TotalReason)
	}
	return strings.Join(reasons, ";")
}

func lockActivityTemplateScorePeriods(ctx context.Context, tx *sql.Tx, runtime domain.Runtime, taskDay string, processingMS int64) (scorePeriodAcceptance, error) {
	acceptance := scorePeriodAcceptance{}
	periods := []struct {
		boardType string
		periodKey string
	}{
		{boardType: domain.BoardTypeDaily, periodKey: taskDay},
		{boardType: domain.BoardTypeTotal, periodKey: "total"},
	}
	for _, period := range periods {
		var status string
		var settleAfterMS int64
		err := tx.QueryRowContext(ctx, `
			SELECT status, settle_after_ms
			FROM activity_template_rank_periods
			WHERE app_code = ? AND template_id = ? AND version_no = ? AND board_type = ? AND period_key = ?
			FOR UPDATE`, runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo,
			period.boardType, period.periodKey).Scan(&status, &settleAfterMS)
		if errors.Is(err, sql.ErrNoRows) {
			return scorePeriodAcceptance{}, xerr.New(xerr.Internal, "activity template rank period is not materialized")
		}
		if err != nil {
			return scorePeriodAcceptance{}, err
		}
		if settleAfterMS <= 0 {
			return scorePeriodAcceptance{}, xerr.New(xerr.Internal, "activity template rank period settlement watermark is missing")
		}
		accepted, reason := activityTemplateRankPeriodAcceptsEvent(status, settleAfterMS, processingMS)
		if period.boardType == domain.BoardTypeDaily {
			acceptance.Daily, acceptance.DailyReason = accepted, reason
		} else {
			acceptance.Total, acceptance.TotalReason = accepted, reason
		}
	}
	return acceptance, nil
}

func activityTemplateRankPeriodAcceptsEvent(status string, settleAfterMS int64, processingMS int64) (bool, string) {
	// The watermark is exclusive: an event that starts processing exactly at settle_after_ms can race the
	// due-period worker, so it must be rejected even when it occurred within the activity's [start,end) window.
	if settleAfterMS > 0 && processingMS >= settleAfterMS {
		return false, "settlement_window_closed"
	}
	if status != domain.PeriodStatusPending {
		return false, "rank_period_closed"
	}
	return true, ""
}

func (r *Repository) VisitActivityTemplate(ctx context.Context, command domain.VisitCommand, nowMS int64) (domain.VisitResult, error) {
	appCode := appcode.FromContext(ctx)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.VisitResult{}, err
	}
	defer tx.Rollback()
	runtime, found, err := findActivityTemplateRuntime(ctx, tx, appCode, command.TemplateCode, command.RegionID, nowMS)
	if err != nil {
		return domain.VisitResult{}, err
	}
	if !found {
		return domain.VisitResult{}, xerr.New(xerr.NotFound, "active activity template not found")
	}
	if runtime.VersionNo != command.VersionNo {
		return domain.VisitResult{}, xerr.New(xerr.Conflict, "activity template version changed; refresh before visiting")
	}
	taskDay := utcTaskDay(nowMS)
	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO activity_template_daily_visits (
		  app_code, template_id, template_code, version_no, task_day, user_id, region_id, command_id, visited_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode, runtime.Template.TemplateID, runtime.Template.TemplateCode, runtime.VersionNo,
		taskDay, command.UserID, command.RegionID, command.CommandID, nowMS)
	if err != nil {
		return domain.VisitResult{}, err
	}
	affected, _ := result.RowsAffected()
	firstVisit := affected == 1
	if !firstVisit {
		// INSERT IGNORE 同时覆盖自然键和 command_id 唯一键。自然日重复访问是正常幂等；但若同一个
		// command_id 已绑定到别的用户/模版版本，必须报冲突，不能把跨请求键碰撞伪装成“已访问”。
		var existingTemplateID, existingTaskDay string
		var existingVersion, existingUserID, existingRegionID int64
		err := tx.QueryRowContext(ctx, `
			SELECT template_id, version_no, task_day, user_id, region_id FROM activity_template_daily_visits
			WHERE app_code = ? AND command_id = ?`, appCode, command.CommandID).
			Scan(&existingTemplateID, &existingVersion, &existingTaskDay, &existingUserID, &existingRegionID)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return domain.VisitResult{}, err
		}
		if err == nil && (existingTemplateID != runtime.Template.TemplateID || existingVersion != runtime.VersionNo || existingTaskDay != taskDay || existingUserID != command.UserID || existingRegionID != command.RegionID) {
			return domain.VisitResult{}, xerr.New(xerr.Conflict, "activity visit command already belongs to another visit")
		}
	}
	if firstVisit {
		if err := insertActivityTemplateStatsFact(ctx, tx, statsFactInput{
			SourceKey: command.CommandID + ":visited", EventType: activitymq.EventTypeActivityVisited,
			Runtime: runtime, TaskDay: taskDay, RegionID: command.RegionID, UserID: command.UserID,
			VisitCountDelta: 1, OccurredAtMS: nowMS, AttributionAtMS: nowMS,
		}, nowMS); err != nil {
			return domain.VisitResult{}, err
		}
		for _, task := range runtime.Template.Tasks {
			if task.TaskType != domain.TaskTypeDailyVisit {
				continue
			}
			_, appliedDelta, completedDelta, err := applyActivityTemplateTaskProgress(ctx, tx, runtime, taskDay, command.UserID, task, 1, command.RegionID, nowMS, nowMS)
			if err != nil {
				return domain.VisitResult{}, err
			}
			if err := insertActivityTemplateStatsFact(ctx, tx, statsFactInput{
				SourceKey: command.CommandID + ":visit:" + task.TaskKey, EventType: activitymq.EventTypeTaskProgressed,
				Runtime: runtime, TaskDay: taskDay, RegionID: command.RegionID, UserID: command.UserID,
				TaskKey: task.TaskKey, TaskType: task.TaskType, ProgressDelta: appliedDelta,
				CompletedDelta: completedDelta, OccurredAtMS: nowMS,
			}, nowMS); err != nil {
				return domain.VisitResult{}, err
			}
		}
	}
	tasks, err := listActivityTemplateTaskProgress(ctx, tx, runtime, command.UserID, taskDay)
	if err != nil {
		return domain.VisitResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.VisitResult{}, err
	}
	return domain.VisitResult{Runtime: runtime, Tasks: tasks, FirstVisit: firstVisit}, nil
}

func (r *Repository) ListActivityTemplateTaskProgress(ctx context.Context, runtime domain.Runtime, userID int64, taskDay string) ([]domain.TaskProgress, error) {
	return listActivityTemplateTaskProgress(ctx, r.db, runtime, userID, taskDay)
}

func (r *Repository) PrepareActivityTemplateTaskClaim(ctx context.Context, runtime domain.Runtime, command domain.ClaimCommand, nowMS int64) (domain.TaskClaim, error) {
	appCode := appcode.FromContext(ctx)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.TaskClaim{}, err
	}
	defer tx.Rollback()
	var progress domain.TaskProgress
	var progressRewardJSON string
	err = tx.QueryRowContext(ctx, `
		SELECT app_code, template_id, template_code, version_no, task_day, user_id, task_key, task_type,
		       first_region_id, target_value, progress_value, reward_resource_group_id, reward_snapshot_id,
		       COALESCE(CAST(reward_snapshot_json AS CHAR), ''), sort_order, status, completed_at_ms,
		       earning_region_id, attribution_at_ms, claimed_at_ms, updated_at_ms
		FROM activity_template_task_progress
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND task_day = ? AND user_id = ? AND task_key = ?
		FOR UPDATE`, appCode, runtime.Template.TemplateID, runtime.VersionNo, command.TaskDay, command.UserID, command.TaskKey).
		Scan(&progress.AppCode, &progress.TemplateID, &progress.TemplateCode, &progress.VersionNo, &progress.TaskDay,
			&progress.UserID, &progress.TaskKey, &progress.TaskType, &progress.FirstRegionID, &progress.TargetValue, &progress.ProgressValue,
			&progress.RewardResourceGroupID, &progress.RewardSnapshot.SnapshotID, &progressRewardJSON,
			&progress.SortOrder, &progress.Status, &progress.CompletedAtMS, &progress.EarningRegionID, &progress.AttributionAtMS,
			&progress.ClaimedAtMS, &progress.UpdatedAtMS)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.TaskClaim{}, xerr.New(xerr.Conflict, "activity task is not completed")
	}
	if err != nil {
		return domain.TaskClaim{}, err
	}
	if err := decodeActivityTemplateRewardSnapshot(progressRewardJSON, &progress.RewardSnapshot); err != nil {
		return domain.TaskClaim{}, err
	}
	if progress.Status != domain.TaskStatusCompleted && progress.Status != domain.TaskStatusClaimed {
		return domain.TaskClaim{}, xerr.New(xerr.Conflict, "activity task is not completed")
	}
	if existing, found, err := getActivityTemplateTaskClaimByNaturalKey(ctx, tx, appCode, runtime.Template.TemplateID, runtime.VersionNo, command.TaskDay, command.UserID, command.TaskKey, true); err != nil {
		return domain.TaskClaim{}, err
	} else if found {
		if existing.Status != domain.ClaimStatusGranted {
			if _, err := tx.ExecContext(ctx, `
				UPDATE activity_template_task_claims
				SET status = ?, failure_reason = '', attempt_count = attempt_count + 1, updated_at_ms = ?
				WHERE app_code = ? AND claim_id = ?`, domain.ClaimStatusRunning, nowMS, appCode, existing.ClaimID); err != nil {
				return domain.TaskClaim{}, err
			}
			existing.Status = domain.ClaimStatusRunning
			existing.FailureReason = ""
			existing.AttemptCount++
			existing.UpdatedAtMS = nowMS
		}
		if err := tx.Commit(); err != nil {
			return domain.TaskClaim{}, err
		}
		return existing, nil
	}
	claim := domain.TaskClaim{
		AppCode: appCode, ClaimID: idgen.New("atclaim"), CommandID: command.CommandID,
		TemplateID: runtime.Template.TemplateID, TemplateCode: runtime.Template.TemplateCode, VersionNo: runtime.VersionNo,
		TaskDay: command.TaskDay, TaskKey: command.TaskKey, UserID: command.UserID,
		RewardResourceGroupID: progress.RewardResourceGroupID,
		RewardSnapshot:        progress.RewardSnapshot,
		EarningRegionID:       progress.EarningRegionID, AttributionAtMS: progress.AttributionAtMS,
		WalletCommandID: stableRuntimeID("atask", fmt.Sprintf("%s:%s:%d:%s:%d:%s", appCode, runtime.Template.TemplateID, runtime.VersionNo, command.TaskDay, command.UserID, command.TaskKey)),
		Status:          domain.ClaimStatusRunning, AttemptCount: 1, CreatedAtMS: nowMS, UpdatedAtMS: nowMS,
	}
	rewardSnapshotJSON, err := encodeActivityTemplateRewardSnapshot(claim.RewardSnapshot)
	if err != nil {
		return domain.TaskClaim{}, err
	}
	_, err = tx.ExecContext(ctx, `
		INSERT INTO activity_template_task_claims (
		  app_code, claim_id, command_id, template_id, template_code, version_no, task_day, task_key, user_id,
		  earning_region_id, attribution_at_ms, reward_resource_group_id, reward_snapshot_id, reward_snapshot_json, wallet_command_id,
		  status, attempt_count, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?)`,
		claim.AppCode, claim.ClaimID, claim.CommandID, claim.TemplateID, claim.TemplateCode, claim.VersionNo,
		claim.TaskDay, claim.TaskKey, claim.UserID, claim.EarningRegionID, claim.AttributionAtMS,
		claim.RewardResourceGroupID, claim.RewardSnapshot.SnapshotID,
		rewardSnapshotJSON, claim.WalletCommandID,
		claim.Status, claim.AttemptCount, nowMS, nowMS)
	if err != nil {
		if isMySQLDuplicate(err) {
			return domain.TaskClaim{}, xerr.New(xerr.Conflict, "activity task claim command already belongs to another task")
		}
		return domain.TaskClaim{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.TaskClaim{}, err
	}
	return claim, nil
}

func (r *Repository) MarkActivityTemplateTaskClaimGranted(ctx context.Context, claimID string, walletGrantID string, nowMS int64) (domain.TaskClaim, error) {
	appCode := appcode.FromContext(ctx)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.TaskClaim{}, err
	}
	defer tx.Rollback()
	claim, found, err := getActivityTemplateTaskClaimByID(ctx, tx, appCode, claimID, true)
	if err != nil {
		return domain.TaskClaim{}, err
	}
	if !found {
		return domain.TaskClaim{}, xerr.New(xerr.NotFound, "activity task claim not found")
	}
	if claim.Status != domain.ClaimStatusGranted {
		if _, err := tx.ExecContext(ctx, `
			UPDATE activity_template_task_claims
			SET status = ?, wallet_grant_id = ?, failure_reason = '', granted_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND claim_id = ?`,
			domain.ClaimStatusGranted, walletGrantID, nowMS, nowMS, appCode, claimID); err != nil {
			return domain.TaskClaim{}, err
		}
		if _, err := tx.ExecContext(ctx, `
			UPDATE activity_template_task_progress
			SET status = ?, claimed_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND template_id = ? AND version_no = ? AND task_day = ? AND user_id = ? AND task_key = ?`,
			domain.TaskStatusClaimed, nowMS, nowMS, appCode, claim.TemplateID, claim.VersionNo, claim.TaskDay, claim.UserID, claim.TaskKey); err != nil {
			return domain.TaskClaim{}, err
		}
		version, err := getActivityTemplateRuntimeByIdentity(ctx, tx, appCode, claim.TemplateID, claim.VersionNo)
		if err != nil {
			return domain.TaskClaim{}, err
		}
		if err := insertActivityTemplateStatsFact(ctx, tx, statsFactInput{
			SourceKey: claim.ClaimID + ":claimed", EventType: activitymq.EventTypeTaskClaimed, Runtime: version,
			TaskDay: claim.TaskDay, RegionID: claim.EarningRegionID, UserID: claim.UserID, TaskKey: claim.TaskKey,
			TaskType: taskTypeFromRuntime(version, claim.TaskKey), ClaimedDelta: 1, OccurredAtMS: nowMS,
			AttributionAtMS: claim.AttributionAtMS,
		}, nowMS); err != nil {
			return domain.TaskClaim{}, err
		}
		claim.Status = domain.ClaimStatusGranted
		claim.WalletGrantID = walletGrantID
		claim.FailureReason = ""
		claim.GrantedAtMS = nowMS
		claim.UpdatedAtMS = nowMS
	}
	if err := tx.Commit(); err != nil {
		return domain.TaskClaim{}, err
	}
	return claim, nil
}

func (r *Repository) MarkActivityTemplateTaskClaimFailed(ctx context.Context, claimID string, reason string, nowMS int64) error {
	_, err := r.db.ExecContext(ctx, `
		UPDATE activity_template_task_claims SET status = ?, failure_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND claim_id = ? AND status <> ?`,
		domain.ClaimStatusFailed, truncateActivityTemplateRuntimeError(reason), nowMS,
		appcode.FromContext(ctx), claimID, domain.ClaimStatusGranted)
	return err
}

func (r *Repository) ListActivityTemplateTaskClaims(ctx context.Context, query domain.TaskClaimQuery) ([]domain.TaskClaim, int64, error) {
	where := []string{"app_code = ?", "template_id = ?"}
	args := []any{appcode.FromContext(ctx), strings.TrimSpace(query.TemplateID)}
	if query.VersionNo > 0 {
		where, args = append(where, "version_no = ?"), append(args, query.VersionNo)
	}
	if query.TaskDay != "" {
		where, args = append(where, "task_day = ?"), append(args, strings.TrimSpace(query.TaskDay))
	}
	if query.TaskKey != "" {
		where, args = append(where, "task_key = ?"), append(args, strings.TrimSpace(query.TaskKey))
	}
	if query.UserID > 0 {
		where, args = append(where, "user_id = ?"), append(args, query.UserID)
	}
	if query.Status != "" {
		where, args = append(where, "status = ?"), append(args, strings.TrimSpace(query.Status))
	}
	whereSQL := " WHERE " + strings.Join(where, " AND ")
	var total int64
	if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM activity_template_task_claims"+whereSQL, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	page, pageSize := normalizeRuntimePage(query.Page, query.PageSize)
	rows, err := r.db.QueryContext(ctx, activityTemplateTaskClaimSelect+whereSQL+`
		ORDER BY updated_at_ms DESC, claim_id DESC LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]domain.TaskClaim, 0)
	for rows.Next() {
		item, _, err := scanActivityTemplateTaskClaim(rows)
		if err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

func (r *Repository) ClaimActivityTemplateTaskClaimForRetry(ctx context.Context, templateID string, claimID string, nowMS int64) (domain.TaskClaim, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.TaskClaim{}, err
	}
	defer tx.Rollback()
	claim, found, err := getActivityTemplateTaskClaimByID(ctx, tx, appcode.FromContext(ctx), strings.TrimSpace(claimID), true)
	if err != nil {
		return domain.TaskClaim{}, err
	}
	if !found || claim.TemplateID != strings.TrimSpace(templateID) {
		return domain.TaskClaim{}, xerr.New(xerr.NotFound, "activity template task claim not found")
	}
	if claim.Status != domain.ClaimStatusFailed {
		return domain.TaskClaim{}, xerr.New(xerr.Conflict, "only failed activity template task claims can be retried manually")
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE activity_template_task_claims
		SET status = ?, failure_reason = '', attempt_count = attempt_count + 1, updated_at_ms = ?
		WHERE app_code = ? AND claim_id = ? AND status = ?`,
		domain.ClaimStatusRunning, nowMS, appcode.FromContext(ctx), claim.ClaimID, domain.ClaimStatusFailed); err != nil {
		return domain.TaskClaim{}, err
	}
	claim.Status = domain.ClaimStatusRunning
	claim.FailureReason = ""
	claim.AttemptCount++
	claim.UpdatedAtMS = nowMS
	if err := tx.Commit(); err != nil {
		return domain.TaskClaim{}, err
	}
	return claim, nil
}

func findActivityTemplateRuntime(ctx context.Context, q activityTemplateQuerier, appCode string, templateCode string, regionID int64, atMS int64) (domain.Runtime, bool, error) {
	args := []any{appCode, atMS, atMS, atMS, atMS, regionID}
	codeClause := ""
	if templateCode != "" {
		codeClause = " AND rv.template_code = ?"
		args = append(args, templateCode)
	}
	rows, err := q.QueryContext(ctx, `
		SELECT rv.template_id, rv.template_code, rv.version_no, rv.runtime_from_ms, rv.runtime_to_ms,
		       CAST(v.snapshot_json AS CHAR)
		FROM activity_template_runtime_versions rv
		JOIN activity_template_versions v
		  ON v.app_code = rv.app_code AND v.template_id = rv.template_id AND v.version_no = rv.version_no
		WHERE rv.app_code = ? AND rv.start_ms <= ? AND rv.end_ms > ?
		  AND rv.runtime_from_ms <= ? AND (rv.runtime_to_ms = 0 OR rv.runtime_to_ms > ?)
		  AND (rv.all_regions = TRUE OR EXISTS (
		    SELECT 1 FROM activity_template_runtime_regions rr
		    WHERE rr.app_code = rv.app_code AND rr.template_id = rv.template_id
		      AND rr.version_no = rv.version_no AND rr.region_id = ?
		  ))`+codeClause+`
		ORDER BY rv.runtime_from_ms DESC, rv.version_no DESC
		LIMIT 2`, args...)
	if err != nil {
		return domain.Runtime{}, false, err
	}
	defer rows.Close()
	items := make([]domain.Runtime, 0, 2)
	for rows.Next() {
		var item domain.Runtime
		var storedCode, snapshotJSON string
		if err := rows.Scan(&item.Template.TemplateID, &storedCode, &item.VersionNo, &item.RuntimeFromMS, &item.RuntimeToMS, &snapshotJSON); err != nil {
			return domain.Runtime{}, false, err
		}
		if err := json.Unmarshal([]byte(snapshotJSON), &item.Template); err != nil {
			return domain.Runtime{}, false, fmt.Errorf("decode activity template runtime version: %w", err)
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return domain.Runtime{}, false, err
	}
	if len(items) == 0 {
		return domain.Runtime{}, false, nil
	}
	if len(items) > 1 {
		return domain.Runtime{}, false, xerr.New(xerr.Conflict, "multiple activity template runtime versions match the same region and time")
	}
	return items[0], true, nil
}

func getActivityTemplateRuntimeByIdentity(ctx context.Context, q activityTemplateQuerier, appCode string, templateID string, versionNo int64) (domain.Runtime, error) {
	var item domain.Runtime
	var snapshotJSON string
	err := q.QueryRowContext(ctx, `
		SELECT rv.runtime_from_ms, rv.runtime_to_ms, CAST(v.snapshot_json AS CHAR)
		FROM activity_template_runtime_versions rv
		JOIN activity_template_versions v ON v.app_code = rv.app_code AND v.template_id = rv.template_id AND v.version_no = rv.version_no
		WHERE rv.app_code = ? AND rv.template_id = ? AND rv.version_no = ?`, appCode, templateID, versionNo).
		Scan(&item.RuntimeFromMS, &item.RuntimeToMS, &snapshotJSON)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Runtime{}, xerr.New(xerr.NotFound, "activity template runtime version not found")
	}
	if err != nil {
		return domain.Runtime{}, err
	}
	if err := json.Unmarshal([]byte(snapshotJSON), &item.Template); err != nil {
		return domain.Runtime{}, err
	}
	item.VersionNo = versionNo
	return item, nil
}

func insertActivityTemplateEventConsumption(ctx context.Context, tx *sql.Tx, appCode string, event domain.GiftEvent, result domain.EventResult, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO activity_template_event_consumption (
		  app_code, source_event_id, event_type, template_id, template_code, version_no, user_id,
		  region_id, task_day, status, skip_reason, occurred_at_ms, consumed_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appCode, event.EventID, runtimeEventTypeGift, result.TemplateID, result.TemplateCode, result.VersionNo,
		event.UserID, event.RegionID, result.TaskDay, result.Status, result.SkipReason, event.OccurredAtMS, nowMS)
	return err
}

func (r *Repository) getActivityTemplateEventResult(ctx context.Context, appCode string, eventID string) (domain.EventResult, bool, error) {
	var item domain.EventResult
	item.EventID = eventID
	err := r.db.QueryRowContext(ctx, `
		SELECT status, skip_reason, template_id, template_code, version_no, task_day
		FROM activity_template_event_consumption WHERE app_code = ? AND source_event_id = ?`, appCode, eventID).
		Scan(&item.Status, &item.SkipReason, &item.TemplateID, &item.TemplateCode, &item.VersionNo, &item.TaskDay)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.EventResult{}, false, nil
	}
	return item, err == nil, err
}

func (r *Repository) mustGetActivityTemplateEventResult(ctx context.Context, appCode string, eventID string) (domain.EventResult, error) {
	item, found, err := r.getActivityTemplateEventResult(ctx, appCode, eventID)
	if err != nil {
		return domain.EventResult{}, err
	}
	if !found {
		return domain.EventResult{}, xerr.New(xerr.Conflict, "activity template event idempotency race")
	}
	return item, nil
}

func applyActivityTemplateTaskProgress(ctx context.Context, tx *sql.Tx, runtime domain.Runtime, taskDay string, userID int64, task domain.Task, delta int64, regionID int64, occurredAtMS int64, nowMS int64) (domain.TaskProgress, int64, int64, error) {
	appCode := runtime.Template.AppCode
	var item domain.TaskProgress
	var rewardSnapshotJSON string
	err := tx.QueryRowContext(ctx, `
		SELECT app_code, template_id, template_code, version_no, task_day, user_id, task_key, task_type,
		       first_region_id, target_value, progress_value, reward_resource_group_id, reward_snapshot_id,
		       COALESCE(CAST(reward_snapshot_json AS CHAR), ''), sort_order, status, completed_at_ms,
		       earning_region_id, attribution_at_ms, claimed_at_ms, updated_at_ms
		FROM activity_template_task_progress
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND task_day = ? AND user_id = ? AND task_key = ?
		FOR UPDATE`, appCode, runtime.Template.TemplateID, runtime.VersionNo, taskDay, userID, task.TaskKey).
		Scan(&item.AppCode, &item.TemplateID, &item.TemplateCode, &item.VersionNo, &item.TaskDay, &item.UserID,
			&item.TaskKey, &item.TaskType, &item.FirstRegionID, &item.TargetValue, &item.ProgressValue, &item.RewardResourceGroupID,
			&item.RewardSnapshot.SnapshotID, &rewardSnapshotJSON, &item.SortOrder, &item.Status,
			&item.CompletedAtMS, &item.EarningRegionID, &item.AttributionAtMS, &item.ClaimedAtMS, &item.UpdatedAtMS)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return domain.TaskProgress{}, 0, 0, err
	}
	if errors.Is(err, sql.ErrNoRows) {
		item = domain.TaskProgress{
			AppCode: appCode, TemplateID: runtime.Template.TemplateID, TemplateCode: runtime.Template.TemplateCode,
			VersionNo: runtime.VersionNo, TaskDay: taskDay, UserID: userID, TaskKey: task.TaskKey, TaskType: task.TaskType,
			TargetValue: task.TargetValue, RewardResourceGroupID: task.RewardResourceGroupID, SortOrder: task.SortOrder,
			RewardSnapshot: task.RewardSnapshot, Status: domain.TaskStatusInProgress, FirstRegionID: regionID,
		}
	} else if err := decodeActivityTemplateRewardSnapshot(rewardSnapshotJSON, &item.RewardSnapshot); err != nil {
		return domain.TaskProgress{}, 0, 0, err
	}
	if item.Status == domain.TaskStatusClaimed {
		return item, 0, 0, nil
	}
	if item.FirstRegionID <= 0 {
		item.FirstRegionID = regionID
	}
	wasCompleted := item.Status == domain.TaskStatusCompleted
	previousProgress := item.ProgressValue
	item.ProgressValue += delta
	if item.ProgressValue >= item.TargetValue {
		item.ProgressValue = item.TargetValue
		item.Status = domain.TaskStatusCompleted
		if item.CompletedAtMS == 0 {
			item.CompletedAtMS = occurredAtMS
			item.EarningRegionID = regionID
			item.AttributionAtMS = occurredAtMS
		}
	}
	item.UpdatedAtMS = nowMS
	rewardSnapshotJSON, err = encodeActivityTemplateRewardSnapshot(item.RewardSnapshot)
	if err != nil {
		return domain.TaskProgress{}, 0, 0, err
	}
	_, err = tx.ExecContext(ctx, `
		INSERT INTO activity_template_task_progress (
		  app_code, template_id, template_code, version_no, task_day, user_id, first_region_id, task_key, task_type,
		  target_value, progress_value, reward_resource_group_id, reward_snapshot_id, reward_snapshot_json,
		  sort_order, status, completed_at_ms, earning_region_id, attribution_at_ms, claimed_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE progress_value = VALUES(progress_value), status = VALUES(status),
		  first_region_id = IF(first_region_id = 0, VALUES(first_region_id), first_region_id),
		  completed_at_ms = VALUES(completed_at_ms), earning_region_id = VALUES(earning_region_id),
		  attribution_at_ms = VALUES(attribution_at_ms), updated_at_ms = VALUES(updated_at_ms)`,
		item.AppCode, item.TemplateID, item.TemplateCode, item.VersionNo, item.TaskDay, item.UserID,
		item.FirstRegionID, item.TaskKey, item.TaskType, item.TargetValue, item.ProgressValue, item.RewardResourceGroupID,
		item.RewardSnapshot.SnapshotID, rewardSnapshotJSON, item.SortOrder, item.Status,
		item.CompletedAtMS, item.EarningRegionID, item.AttributionAtMS, item.ClaimedAtMS, item.UpdatedAtMS)
	if err != nil {
		return domain.TaskProgress{}, 0, 0, err
	}
	appliedDelta := item.ProgressValue - previousProgress
	completedDelta := int64(0)
	if !wasCompleted && item.Status == domain.TaskStatusCompleted {
		completedDelta = 1
	}
	return item, appliedDelta, completedDelta, nil
}

func listActivityTemplateTaskProgress(ctx context.Context, q activityTemplateQuerier, runtime domain.Runtime, userID int64, taskDay string) ([]domain.TaskProgress, error) {
	rows, err := q.QueryContext(ctx, `
		SELECT app_code, template_id, template_code, version_no, task_day, user_id, task_key, task_type,
		       first_region_id, target_value, progress_value, reward_resource_group_id, reward_snapshot_id,
		       COALESCE(CAST(reward_snapshot_json AS CHAR), ''), sort_order, status, completed_at_ms,
		       earning_region_id, attribution_at_ms, claimed_at_ms, updated_at_ms
		FROM activity_template_task_progress
		WHERE app_code = ? AND template_id = ? AND version_no = ? AND task_day = ? AND user_id = ?`,
		runtime.Template.AppCode, runtime.Template.TemplateID, runtime.VersionNo, taskDay, userID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	byKey := make(map[string]domain.TaskProgress, len(runtime.Template.Tasks))
	for rows.Next() {
		var item domain.TaskProgress
		var rewardSnapshotJSON string
		if err := rows.Scan(&item.AppCode, &item.TemplateID, &item.TemplateCode, &item.VersionNo, &item.TaskDay,
			&item.UserID, &item.TaskKey, &item.TaskType, &item.FirstRegionID, &item.TargetValue, &item.ProgressValue,
			&item.RewardResourceGroupID, &item.RewardSnapshot.SnapshotID, &rewardSnapshotJSON,
			&item.SortOrder, &item.Status, &item.CompletedAtMS, &item.EarningRegionID, &item.AttributionAtMS,
			&item.ClaimedAtMS, &item.UpdatedAtMS); err != nil {
			return nil, err
		}
		if err := decodeActivityTemplateRewardSnapshot(rewardSnapshotJSON, &item.RewardSnapshot); err != nil {
			return nil, err
		}
		byKey[item.TaskKey] = item
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	items := make([]domain.TaskProgress, 0, len(runtime.Template.Tasks))
	for _, task := range runtime.Template.Tasks {
		item, ok := byKey[task.TaskKey]
		if !ok {
			item = domain.TaskProgress{
				AppCode: runtime.Template.AppCode, TemplateID: runtime.Template.TemplateID,
				TemplateCode: runtime.Template.TemplateCode, VersionNo: runtime.VersionNo, TaskDay: taskDay,
				UserID: userID, TaskKey: task.TaskKey, TaskType: task.TaskType, TargetValue: task.TargetValue,
				RewardResourceGroupID: task.RewardResourceGroupID, RewardSnapshot: task.RewardSnapshot,
				SortOrder: task.SortOrder, Status: domain.TaskStatusInProgress,
			}
		} else if item.RewardSnapshot.SnapshotID == "" {
			item.RewardSnapshot = task.RewardSnapshot
		}
		items = append(items, item)
	}
	return items, nil
}

type statsFactInput struct {
	SourceKey       string
	EventType       string
	Runtime         domain.Runtime
	TaskDay         string
	RegionID        int64
	UserID          int64
	GiftID          string
	TaskKey         string
	TaskType        string
	GiftCountDelta  int64
	CoinAmountDelta int64
	ProgressDelta   int64
	CompletedDelta  int64
	ClaimedDelta    int64
	VisitCountDelta int64
	OccurredAtMS    int64
	AttributionAtMS int64
}

func insertActivityTemplateStatsFact(ctx context.Context, tx *sql.Tx, input statsFactInput, nowMS int64) error {
	outboxID := stableRuntimeID("atstat", input.Runtime.Template.AppCode+":"+input.Runtime.Template.TemplateID+":"+fmt.Sprint(input.Runtime.VersionNo)+":"+input.SourceKey)
	payload, err := activitymq.EncodeActivityTemplateFactMessage(activitymq.ActivityTemplateFactMessage{
		AppCode: input.Runtime.Template.AppCode, EventID: outboxID, EventType: input.EventType,
		TemplateID: input.Runtime.Template.TemplateID, TemplateCode: input.Runtime.Template.TemplateCode,
		PublishedVersion: input.Runtime.VersionNo, StatDay: input.TaskDay, RegionID: input.RegionID, UserID: input.UserID,
		GiftID: input.GiftID, TaskKey: input.TaskKey, TaskType: input.TaskType,
		GiftCountDelta: input.GiftCountDelta, CoinAmountDelta: input.CoinAmountDelta,
		ProgressDelta: input.ProgressDelta, CompletedDelta: input.CompletedDelta, ClaimedDelta: input.ClaimedDelta,
		VisitCountDelta: input.VisitCountDelta, OccurredAtMS: input.OccurredAtMS, AttributionAtMS: input.AttributionAtMS,
	})
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		INSERT INTO activity_template_stats_outbox (
		  outbox_id, app_code, event_type, payload, status, next_retry_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE outbox_id = outbox_id`,
		outboxID, input.Runtime.Template.AppCode, input.EventType, payload, outboxStatusPending, nowMS, nowMS, nowMS)
	return err
}

func getActivityTemplateTaskClaimByNaturalKey(ctx context.Context, q activityTemplateQuerier, appCode, templateID string, versionNo int64, taskDay string, userID int64, taskKey string, forUpdate bool) (domain.TaskClaim, bool, error) {
	query := activityTemplateTaskClaimSelect + ` WHERE app_code = ? AND template_id = ? AND version_no = ? AND task_day = ? AND user_id = ? AND task_key = ?`
	if forUpdate {
		query += " FOR UPDATE"
	}
	return scanActivityTemplateTaskClaim(q.QueryRowContext(ctx, query, appCode, templateID, versionNo, taskDay, userID, taskKey))
}

func getActivityTemplateTaskClaimByID(ctx context.Context, q activityTemplateQuerier, appCode, claimID string, forUpdate bool) (domain.TaskClaim, bool, error) {
	query := activityTemplateTaskClaimSelect + ` WHERE app_code = ? AND claim_id = ?`
	if forUpdate {
		query += " FOR UPDATE"
	}
	return scanActivityTemplateTaskClaim(q.QueryRowContext(ctx, query, appCode, claimID))
}

const activityTemplateTaskClaimSelect = `SELECT app_code, claim_id, command_id, template_id, template_code, version_no, task_day, task_key, user_id,
       earning_region_id, attribution_at_ms, reward_resource_group_id, reward_snapshot_id,
       COALESCE(CAST(reward_snapshot_json AS CHAR), ''), wallet_command_id, wallet_grant_id, status, failure_reason,
       attempt_count, created_at_ms, updated_at_ms, granted_at_ms
FROM activity_template_task_claims`

func scanActivityTemplateTaskClaim(row activityTemplateScanner) (domain.TaskClaim, bool, error) {
	var item domain.TaskClaim
	var rewardSnapshotJSON string
	err := row.Scan(&item.AppCode, &item.ClaimID, &item.CommandID, &item.TemplateID, &item.TemplateCode,
		&item.VersionNo, &item.TaskDay, &item.TaskKey, &item.UserID, &item.EarningRegionID, &item.AttributionAtMS, &item.RewardResourceGroupID,
		&item.RewardSnapshot.SnapshotID, &rewardSnapshotJSON, &item.WalletCommandID, &item.WalletGrantID,
		&item.Status, &item.FailureReason, &item.AttemptCount,
		&item.CreatedAtMS, &item.UpdatedAtMS, &item.GrantedAtMS)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.TaskClaim{}, false, nil
	}
	if err == nil {
		err = decodeActivityTemplateRewardSnapshot(rewardSnapshotJSON, &item.RewardSnapshot)
	}
	return item, err == nil, err
}

func encodeActivityTemplateRewardSnapshot(item domain.RewardSnapshot) (string, error) {
	if item.SnapshotID == "" {
		return "null", nil
	}
	value, err := json.Marshal(item)
	return string(value), err
}

func decodeActivityTemplateRewardSnapshot(raw string, item *domain.RewardSnapshot) error {
	if item == nil || strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "null" {
		return nil
	}
	return json.Unmarshal([]byte(raw), item)
}

func taskTypeFromRuntime(runtime domain.Runtime, taskKey string) string {
	for _, task := range runtime.Template.Tasks {
		if task.TaskKey == taskKey {
			return task.TaskType
		}
	}
	return ""
}

func utcTaskDay(ms int64) string { return time.UnixMilli(ms).UTC().Format("2006-01-02") }

func utcDayStart(ms int64) int64 {
	t := time.UnixMilli(ms).UTC()
	return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}

func clippedUTCDay(runtime domain.Runtime, ms int64) (int64, int64) {
	start := maxInt64(utcDayStart(ms), runtime.RuntimeFromMS)
	end := minInt64(utcDayStart(ms)+int64(24*time.Hour/time.Millisecond), runtime.Template.EndMS)
	if runtime.RuntimeToMS > 0 {
		end = minInt64(end, runtime.RuntimeToMS)
	}
	return start, end
}

func stableRuntimeID(prefix, value string) string {
	sum := sha256.Sum256([]byte(value))
	return prefix + "_" + hex.EncodeToString(sum[:])
}

func truncateActivityTemplateRuntimeError(value string) string {
	value = strings.TrimSpace(value)
	if len(value) > 512 {
		return value[:512]
	}
	return value
}

func minInt64(left, right int64) int64 {
	if left < right {
		return left
	}
	return right
}

func maxInt64(left, right int64) int64 {
	if left > right {
		return left
	}
	return right
}
