package mysql

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

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

// ListWeeklyStarCycles returns admin-configured cycles with gifts and rewards attached for editing.
func (r *Repository) ListWeeklyStarCycles(ctx context.Context, query domain.ListQuery) ([]domain.Cycle, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where, args := weeklyStarCycleWhere(ctx, query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM weekly_star_cycles `+where, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	pageSize := normalizePageSize(query.PageSize)
	page := query.Page
	if page <= 0 {
		page = 1
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM weekly_star_cycles `+where+`
		ORDER BY start_ms DESC, region_id ASC, cycle_id DESC
		LIMIT ? OFFSET ?`, append(args, int(pageSize), int((page-1)*pageSize))...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	cycles := make([]domain.Cycle, 0)
	for rows.Next() {
		cycle, scanErr := scanWeeklyStarCycle(rows)
		if scanErr != nil {
			return nil, 0, scanErr
		}
		cycles = append(cycles, cycle)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.attachWeeklyStarChildren(ctx, cycles); err != nil {
		return nil, 0, err
	}
	return cycles, total, nil
}

// GetWeeklyStarCycle returns the authoritative cycle row and its editable child rows.
func (r *Repository) GetWeeklyStarCycle(ctx context.Context, cycleID string) (domain.Cycle, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	cycle, err := r.getWeeklyStarCycle(ctx, r.db, cycleID)
	if err != nil {
		return domain.Cycle{}, err
	}
	cycles := []domain.Cycle{cycle}
	if err := r.attachWeeklyStarChildren(ctx, cycles); err != nil {
		return domain.Cycle{}, err
	}
	return cycles[0], nil
}

// UpsertWeeklyStarCycle replaces a full cycle configuration in one transaction so gifts and rewards stay consistent.
func (r *Repository) UpsertWeeklyStarCycle(ctx context.Context, command domain.CycleCommand, nowMS int64) (domain.Cycle, bool, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Cycle{}, false, err
	}
	defer tx.Rollback()
	cycle := command.Cycle
	cycle.AppCode = appcode.FromContext(ctx)
	cycle.ActivityCode = domain.ActivityCode
	if cycle.Status == "" {
		cycle.Status = domain.StatusDraft
	}
	created := strings.TrimSpace(cycle.CycleID) == ""
	if !created {
		if _, err := r.getWeeklyStarCycle(ctx, tx, cycle.CycleID); err != nil {
			return domain.Cycle{}, false, err
		}
	} else if !command.RequireNewRecord {
		return domain.Cycle{}, false, xerr.New(xerr.InvalidArgument, "cycle_id is required")
	}
	if created {
		cycle.CycleID = idgen.New("wstar")
		cycle.CreatedAtMS = nowMS
		cycle.CreatedByAdminID = command.OperatorAdminID
	}
	cycle.UpdatedAtMS = nowMS
	cycle.UpdatedByAdminID = command.OperatorAdminID
	if err := r.checkWeeklyStarCycleOverlap(ctx, tx, cycle); err != nil {
		return domain.Cycle{}, false, err
	}
	if created {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO weekly_star_cycles (
			  app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
			  created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
			cycle.AppCode, cycle.CycleID, cycle.ActivityCode, cycle.RegionID, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS,
			cycle.CreatedByAdminID, cycle.UpdatedByAdminID, cycle.CreatedAtMS, cycle.UpdatedAtMS,
		); err != nil {
			return domain.Cycle{}, false, err
		}
	} else {
		if _, err := tx.ExecContext(ctx, `
			UPDATE weekly_star_cycles
			SET region_id = ?, title = ?, status = ?, start_ms = ?, end_ms = ?,
			    updated_by_admin_id = ?, updated_at_ms = ?
			WHERE app_code = ? AND cycle_id = ?`,
			cycle.RegionID, cycle.Title, cycle.Status, cycle.StartMS, cycle.EndMS,
			cycle.UpdatedByAdminID, cycle.UpdatedAtMS, cycle.AppCode, cycle.CycleID,
		); err != nil {
			return domain.Cycle{}, false, err
		}
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM weekly_star_cycle_gifts WHERE app_code = ? AND cycle_id = ?`, cycle.AppCode, cycle.CycleID); err != nil {
		return domain.Cycle{}, false, err
	}
	for index, gift := range cycle.Gifts {
		sortOrder := gift.SortOrder
		if sortOrder <= 0 {
			// repository 仍兼容绕过 service 的调用方；数组首项补最高权重，保证读回的 DESC 顺序不反转。
			sortOrder = int32(len(cycle.Gifts) - index)
		}
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO weekly_star_cycle_gifts (app_code, cycle_id, gift_id, sort_order, created_at_ms)
			VALUES (?, ?, ?, ?, ?)`,
			cycle.AppCode, cycle.CycleID, gift.GiftID, sortOrder, nowMS,
		); err != nil {
			return domain.Cycle{}, false, err
		}
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM weekly_star_cycle_rewards WHERE app_code = ? AND cycle_id = ?`, cycle.AppCode, cycle.CycleID); err != nil {
		return domain.Cycle{}, false, err
	}
	for _, reward := range cycle.Rewards {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO weekly_star_cycle_rewards (app_code, cycle_id, rank_no, resource_group_id, created_at_ms, updated_at_ms)
			VALUES (?, ?, ?, ?, ?, ?)`,
			cycle.AppCode, cycle.CycleID, reward.RankNo, reward.ResourceGroupID, nowMS, nowMS,
		); err != nil {
			return domain.Cycle{}, false, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.Cycle{}, false, err
	}
	stored, err := r.GetWeeklyStarCycle(ctx, cycle.CycleID)
	return stored, created, err
}

// SetWeeklyStarCycleStatus changes visibility without mutating gifts, rewards, or score facts.
func (r *Repository) SetWeeklyStarCycleStatus(ctx context.Context, cycleID string, status string, operatorAdminID int64, nowMS int64) (domain.Cycle, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Cycle{}, err
	}
	defer tx.Rollback()
	cycle, err := r.getWeeklyStarCycle(ctx, tx, cycleID)
	if err != nil {
		return domain.Cycle{}, err
	}
	next := cycle
	next.Status = status
	if err := r.checkWeeklyStarCycleOverlap(ctx, tx, next); err != nil {
		return domain.Cycle{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE weekly_star_cycles
		SET status = ?, updated_by_admin_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND cycle_id = ?`,
		status, operatorAdminID, nowMS, appcode.FromContext(ctx), cycleID,
	); err != nil {
		return domain.Cycle{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.Cycle{}, err
	}
	return r.GetWeeklyStarCycle(ctx, cycleID)
}

// FindCurrentWeeklyStarCycle resolves the active concrete-region cycle first and falls back to region 0.
func (r *Repository) FindCurrentWeeklyStarCycle(ctx context.Context, regionID int64, nowMS int64) (domain.Cycle, bool, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, `
		SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM weekly_star_cycles
		WHERE app_code = ? AND activity_code = ? AND status = ? AND start_ms <= ? AND end_ms > ?
		  AND region_id IN (?, 0)
		ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, start_ms DESC, updated_at_ms DESC
		LIMIT 1`,
		appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, nowMS, nowMS, regionID, regionID,
	)
	cycle, err := scanWeeklyStarCycle(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Cycle{}, false, nil
		}
		return domain.Cycle{}, false, err
	}
	cycles := []domain.Cycle{cycle}
	if err := r.attachWeeklyStarChildren(ctx, cycles); err != nil {
		return domain.Cycle{}, false, err
	}
	return cycles[0], true, nil
}

// ConsumeWeeklyStarGiftEvent stores source-event idempotency before adding the score delta.
func (r *Repository) ConsumeWeeklyStarGiftEvent(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")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.EventResult{}, err
	}
	defer tx.Rollback()
	cycle, ok, err := r.findCurrentWeeklyStarCycleForGift(ctx, tx, event.RegionID, event.GiftID, event.OccurredAtMS)
	if err != nil {
		return domain.EventResult{}, err
	}
	if !ok {
		return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO weekly_star_score_events (
		  app_code, source_event_id, cycle_id, user_id, gift_id, score_delta, occurred_at_ms, created_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), event.EventID, cycle.CycleID, event.UserID, event.GiftID, event.ScoreDelta, event.OccurredAtMS, nowMS,
	); err != nil {
		if isMySQLDuplicate(err) {
			return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusDuplicate, CycleID: cycle.CycleID}, nil
		}
		return domain.EventResult{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO weekly_star_user_scores (
		  app_code, cycle_id, user_id, score, first_scored_at_ms, last_scored_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		  score = score + VALUES(score),
		  first_scored_at_ms = LEAST(first_scored_at_ms, VALUES(first_scored_at_ms)),
		  last_scored_at_ms = GREATEST(last_scored_at_ms, VALUES(last_scored_at_ms)),
		  updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), cycle.CycleID, event.UserID, event.ScoreDelta, event.OccurredAtMS, event.OccurredAtMS, nowMS, nowMS,
	); err != nil {
		return domain.EventResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.EventResult{}, err
	}
	return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusConsumed, CycleID: cycle.CycleID, ScoreDelta: event.ScoreDelta}, nil
}

// ListWeeklyStarLeaderboard returns ranked score rows with stable pagination by offset token.
func (r *Repository) ListWeeklyStarLeaderboard(ctx context.Context, cycleID string, regionID int64, pageSize int32, pageToken string, nowMS int64) (domain.Cycle, []domain.LeaderboardEntry, string, int64, error) {
	if r == nil || r.db == nil {
		return domain.Cycle{}, nil, "", 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var cycle domain.Cycle
	var err error
	if strings.TrimSpace(cycleID) == "" {
		var ok bool
		cycle, ok, err = r.FindCurrentWeeklyStarCycle(ctx, regionID, nowMS)
		if err != nil || !ok {
			return domain.Cycle{}, nil, "", 0, err
		}
	} else {
		cycle, err = r.GetWeeklyStarCycle(ctx, cycleID)
		if err != nil {
			return domain.Cycle{}, nil, "", 0, err
		}
	}
	size := int(normalizePageSize(pageSize))
	offset := parseWeeklyStarPageToken(pageToken)
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM weekly_star_user_scores WHERE app_code = ? AND cycle_id = ?`,
		appcode.FromContext(ctx), cycle.CycleID,
	).Scan(&total); err != nil {
		return domain.Cycle{}, nil, "", 0, err
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT user_id, score, first_scored_at_ms, last_scored_at_ms
		FROM weekly_star_user_scores
		WHERE app_code = ? AND cycle_id = ?
		ORDER BY score DESC, first_scored_at_ms ASC, user_id ASC
		LIMIT ? OFFSET ?`,
		appcode.FromContext(ctx), cycle.CycleID, size, offset,
	)
	if err != nil {
		return domain.Cycle{}, nil, "", 0, err
	}
	defer rows.Close()
	entries := make([]domain.LeaderboardEntry, 0, size)
	for rows.Next() {
		var entry domain.LeaderboardEntry
		entry.RankNo = int32(offset + len(entries) + 1)
		if err := rows.Scan(&entry.UserID, &entry.Score, &entry.FirstScoredAtMS, &entry.LastScoredAtMS); err != nil {
			return domain.Cycle{}, nil, "", 0, err
		}
		entries = append(entries, entry)
	}
	if err := rows.Err(); err != nil {
		return domain.Cycle{}, nil, "", 0, err
	}
	nextToken := ""
	if int64(offset+len(entries)) < total {
		nextToken = strconv.Itoa(offset + len(entries))
	}
	return cycle, entries, nextToken, total, nil
}

// GetWeeklyStarUserEntry computes the user's rank using the same score/first-time/user-id ordering as the leaderboard.
func (r *Repository) GetWeeklyStarUserEntry(ctx context.Context, cycleID string, userID int64) (domain.LeaderboardEntry, bool, error) {
	if r == nil || r.db == nil {
		return domain.LeaderboardEntry{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var entry domain.LeaderboardEntry
	if err := r.db.QueryRowContext(ctx, `
		SELECT user_id, score, first_scored_at_ms, last_scored_at_ms
		FROM weekly_star_user_scores
		WHERE app_code = ? AND cycle_id = ? AND user_id = ?`,
		appcode.FromContext(ctx), cycleID, userID,
	).Scan(&entry.UserID, &entry.Score, &entry.FirstScoredAtMS, &entry.LastScoredAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.LeaderboardEntry{}, false, nil
		}
		return domain.LeaderboardEntry{}, false, err
	}
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) + 1
		FROM weekly_star_user_scores
		WHERE app_code = ? AND cycle_id = ?
		  AND (score > ? OR (score = ? AND first_scored_at_ms < ?) OR (score = ? AND first_scored_at_ms = ? AND user_id < ?))`,
		appcode.FromContext(ctx), cycleID, entry.Score, entry.Score, entry.FirstScoredAtMS, entry.Score, entry.FirstScoredAtMS, userID,
	).Scan(&entry.RankNo); err != nil {
		return domain.LeaderboardEntry{}, false, err
	}
	return entry, true, nil
}

// ListWeeklyStarHistory returns completed cycles, prioritizing a concrete region over default when both exist.
func (r *Repository) ListWeeklyStarHistory(ctx context.Context, regionID int64, limit int32, nowMS int64) ([]domain.HistoryCycle, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if limit <= 0 {
		limit = 10
	}
	if limit > 50 {
		limit = 50
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM weekly_star_cycles
		WHERE app_code = ? AND activity_code = ? AND status = ? AND region_id IN (?, 0)
		ORDER BY end_ms DESC, CASE WHEN region_id = ? THEN 0 ELSE 1 END, cycle_id DESC
		LIMIT ?`,
		appcode.FromContext(ctx), domain.ActivityCode, domain.StatusSettled, regionID, regionID, int(limit),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	history := make([]domain.HistoryCycle, 0)
	for rows.Next() {
		cycle, scanErr := scanWeeklyStarCycle(rows)
		if scanErr != nil {
			return nil, scanErr
		}
		_, entries, _, _, listErr := r.ListWeeklyStarLeaderboard(ctx, cycle.CycleID, regionID, 3, "", nowMS)
		if listErr != nil {
			return nil, listErr
		}
		history = append(history, domain.HistoryCycle{Cycle: cycle, TopEntries: entries})
	}
	return history, rows.Err()
}

// ListWeeklyStarSettlements returns the wallet grant jobs created for a cycle settlement.
func (r *Repository) ListWeeklyStarSettlements(ctx context.Context, cycleID string) ([]domain.Settlement, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, settlement_id, cycle_id, rank_no, user_id, score, resource_group_id,
		       wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
		FROM weekly_star_settlements
		WHERE app_code = ? AND cycle_id = ?
		ORDER BY rank_no ASC`,
		appcode.FromContext(ctx), cycleID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	settlements := make([]domain.Settlement, 0)
	for rows.Next() {
		settlement, scanErr := scanWeeklyStarSettlement(rows)
		if scanErr != nil {
			return nil, scanErr
		}
		settlements = append(settlements, settlement)
	}
	return settlements, rows.Err()
}

// ClaimDueWeeklyStarCycles locks ended cycles so only one cron worker prepares their Top rewards.
// 这里允许认领 active 到期周期，也允许认领锁过期的 settling 周期；后者用于 worker 崩溃后的自动恢复。
// FOR UPDATE SKIP LOCKED 保证多 cron worker 并发时互不等待，每个周期最多被一个事务改成 settling。
func (r *Repository) ClaimDueWeeklyStarCycles(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.Cycle, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if batchSize <= 0 {
		batchSize = 50
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	rows, err := tx.QueryContext(ctx, `
		SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM weekly_star_cycles
		WHERE app_code = ? AND activity_code = ? AND end_ms <= ?
		  AND ((status = ?) OR (status = ? AND lock_until_ms <= ?))
		ORDER BY end_ms ASC, cycle_id ASC
		LIMIT ? FOR UPDATE SKIP LOCKED`,
		appcode.FromContext(ctx), domain.ActivityCode, nowMS, domain.StatusActive, domain.StatusSettling, nowMS, int(batchSize),
	)
	if err != nil {
		return nil, err
	}
	// go-sql-driver/mysql 不支持在同一个连接还持有未关闭 rows 时继续执行下一条语句。
	// 所以先把待认领周期完整读入内存并显式 Close，再执行后续 UPDATE，避免出现 driver: bad connection。
	cycles := make([]domain.Cycle, 0)
	for rows.Next() {
		cycle, scanErr := scanWeeklyStarCycle(rows)
		if scanErr != nil {
			return nil, scanErr
		}
		cycles = append(cycles, cycle)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	lockUntil := nowMS + lockTTL.Milliseconds()
	for _, cycle := range cycles {
		// 只更新本事务已经 FOR UPDATE 锁住的行；lock_until_ms 是恢复窗口，不是业务结束时间。
		if _, err := tx.ExecContext(ctx, `
			UPDATE weekly_star_cycles
			SET status = ?, locked_by = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND cycle_id = ?`,
			domain.StatusSettling, workerID, lockUntil, nowMS, appcode.FromContext(ctx), cycle.CycleID,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return cycles, nil
}

// PrepareWeeklyStarSettlements materializes Top1/Top2/Top3 exactly once before wallet grants are attempted.
// 第一次进入时把当前榜单固化为 weekly_star_settlements，之后重试只读取 pending/failed 记录。
// 这保证周期结束后的 Top3 快照不会因为迟到事件、重跑 cron 或后台刷新而变化。
func (r *Repository) PrepareWeeklyStarSettlements(ctx context.Context, cycleID string, nowMS int64) ([]domain.Settlement, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	var existing int
	if err := tx.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM weekly_star_settlements WHERE app_code = ? AND cycle_id = ?`,
		appcode.FromContext(ctx), cycleID,
	).Scan(&existing); err != nil {
		return nil, err
	}
	if existing == 0 {
		// settlement 表以 cycle_id+rank_no 做唯一约束；即使 cron 重入，也只会固化一次 Top 排名。
		if err := r.insertWeeklyStarSettlements(ctx, tx, cycleID, nowMS); err != nil {
			return nil, err
		}
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT app_code, settlement_id, cycle_id, rank_no, user_id, score, resource_group_id,
		       wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
		FROM weekly_star_settlements
		WHERE app_code = ? AND cycle_id = ? AND status IN (?, ?)
		ORDER BY rank_no ASC FOR UPDATE`,
		appcode.FromContext(ctx), cycleID, domain.SettlementStatusPending, domain.SettlementStatusFailed,
	)
	if err != nil {
		return nil, err
	}
	// 这里同样必须先读完并关闭 rows，再把选中的 settlement 更新为 running。
	// 否则同一事务中的 UPDATE 会复用被 rows 占用的 MySQL 连接，导致 driver: bad connection。
	settlements := make([]domain.Settlement, 0)
	for rows.Next() {
		settlement, scanErr := scanWeeklyStarSettlement(rows)
		if scanErr != nil {
			return nil, scanErr
		}
		settlements = append(settlements, settlement)
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return nil, err
	}
	if err := rows.Close(); err != nil {
		return nil, err
	}
	for _, settlement := range settlements {
		// running 只表示本轮 cron 正在尝试发放；失败会回到 failed，成功会变成 granted。
		// attempt_count 在状态切换时递增，方便后台排查反复失败的奖励。
		if _, err := tx.ExecContext(ctx, `
			UPDATE weekly_star_settlements
			SET status = ?, attempt_count = attempt_count + 1, updated_at_ms = ?
			WHERE app_code = ? AND settlement_id = ?`,
			domain.SettlementStatusRunning, nowMS, appcode.FromContext(ctx), settlement.SettlementID,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	return settlements, nil
}

// MarkWeeklyStarSettlementGranted records the wallet grant id after wallet-service idempotency succeeds.
// wallet_grant_id 是后续审计和客服排障的跨服务凭证，不能只依赖 activity 侧 settlement_id。
func (r *Repository) MarkWeeklyStarSettlementGranted(ctx context.Context, settlementID 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 weekly_star_settlements
		SET status = ?, wallet_grant_id = ?, failure_reason = '', updated_at_ms = ?
		WHERE app_code = ? AND settlement_id = ?`,
		domain.SettlementStatusGranted, walletGrantID, nowMS, appcode.FromContext(ctx), settlementID,
	)
	return err
}

// MarkWeeklyStarSettlementFailed unlocks one grant job for a later cron retry.
// failed 不是终态；下一轮 PrepareWeeklyStarSettlements 会重新锁定它，并用同一 wallet_command_id 继续重试。
func (r *Repository) MarkWeeklyStarSettlementFailed(ctx context.Context, settlementID string, reason 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 weekly_star_settlements
		SET status = ?, failure_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND settlement_id = ?`,
		domain.SettlementStatusFailed, truncateWeeklyStarFailure(reason), nowMS, appcode.FromContext(ctx), settlementID,
	)
	return err
}

// FinishWeeklyStarCycleIfComplete marks a cycle settled only after every materialized grant succeeded.
// 只要存在非 granted 的 settlement，就释放 cycle 锁但保持 settling，等待下一轮 cron 补偿。
// 全部发放成功后才写 settled_at_ms，H5 历史榜和后台结算记录才会把这个周期视为已完结。
func (r *Repository) FinishWeeklyStarCycleIfComplete(ctx context.Context, cycleID string, nowMS int64) (bool, error) {
	if r == nil || r.db == nil {
		return false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var pending int
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM weekly_star_settlements
		WHERE app_code = ? AND cycle_id = ? AND status <> ?`,
		appcode.FromContext(ctx), cycleID, domain.SettlementStatusGranted,
	).Scan(&pending); err != nil {
		return false, err
	}
	if pending > 0 {
		_, err := r.db.ExecContext(ctx, `
			UPDATE weekly_star_cycles
			SET locked_by = '', lock_until_ms = 0, updated_at_ms = ?
			WHERE app_code = ? AND cycle_id = ?`,
			nowMS, appcode.FromContext(ctx), cycleID,
		)
		return false, err
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE weekly_star_cycles
		SET status = ?, settled_at_ms = ?, locked_by = '', lock_until_ms = 0, updated_at_ms = ?
		WHERE app_code = ? AND cycle_id = ?`,
		domain.StatusSettled, nowMS, nowMS, appcode.FromContext(ctx), cycleID,
	)
	return true, err
}

func (r *Repository) checkWeeklyStarCycleOverlap(ctx context.Context, tx *sql.Tx, cycle domain.Cycle) error {
	if cycle.Status != domain.StatusActive {
		return nil
	}
	// 同一 app/activity/region 下 active 周期不能时间重叠；region_id=0 的默认周期可以和具体区域周期并存。
	// 具体区域优先级由读取 current 时的 ORDER BY 决定，这里只约束同一区域自身不重叠。
	var overlapID string
	err := tx.QueryRowContext(ctx, `
		SELECT cycle_id
		FROM weekly_star_cycles
		WHERE app_code = ? AND activity_code = ? AND region_id = ? AND status = ? AND cycle_id <> ?
		  AND start_ms < ? AND end_ms > ?
		LIMIT 1`,
		cycle.AppCode, domain.ActivityCode, cycle.RegionID, domain.StatusActive, cycle.CycleID, cycle.EndMS, cycle.StartMS,
	).Scan(&overlapID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil
	}
	if err != nil {
		return err
	}
	return xerr.New(xerr.Conflict, "weekly star active cycle overlaps existing cycle")
}

func (r *Repository) findCurrentWeeklyStarCycleForGift(ctx context.Context, tx *sql.Tx, regionID int64, giftID string, occurredAtMS int64) (domain.Cycle, bool, error) {
	// 送礼事件按 occurred_at_ms 命中周期，而不是按消费时的系统时间命中。
	// 这样 MQ 延迟或手动补偿不会把旧周期的礼物误计到当前周期；具体区域优先于 region_id=0 默认配置。
	row := tx.QueryRowContext(ctx, `
		SELECT c.app_code, c.cycle_id, c.activity_code, c.region_id, c.title, c.status, c.start_ms, c.end_ms,
		       c.created_by_admin_id, c.updated_by_admin_id, c.settled_at_ms, c.created_at_ms, c.updated_at_ms
		FROM weekly_star_cycles c
		JOIN weekly_star_cycle_gifts g ON g.app_code = c.app_code AND g.cycle_id = c.cycle_id
		WHERE c.app_code = ? AND c.activity_code = ? AND c.status = ? AND c.start_ms <= ? AND c.end_ms > ?
		  AND c.region_id IN (?, 0) AND g.gift_id = ?
		ORDER BY CASE WHEN c.region_id = ? THEN 0 ELSE 1 END, c.start_ms DESC, c.updated_at_ms DESC
		LIMIT 1`,
		appcode.FromContext(ctx), domain.ActivityCode, domain.StatusActive, occurredAtMS, occurredAtMS, regionID, giftID, regionID,
	)
	cycle, err := scanWeeklyStarCycle(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Cycle{}, false, nil
	}
	return cycle, err == nil, err
}

func (r *Repository) insertWeeklyStarSettlements(ctx context.Context, tx *sql.Tx, cycleID string, nowMS int64) error {
	// 排名规则必须和榜单查询一致：score 高优先，首次得分早优先，user_id 小优先。
	// 子查询先取 Top3，再用同一排序条件反算 rank_no，确保 Top1/2/3 对应正确资源组。
	rows, err := tx.QueryContext(ctx, `
		SELECT s.user_id, s.score, s.first_scored_at_ms, r.rank_no, r.resource_group_id
		FROM (
		  SELECT user_id, score, first_scored_at_ms
		  FROM weekly_star_user_scores
		  WHERE app_code = ? AND cycle_id = ?
		  ORDER BY score DESC, first_scored_at_ms ASC, user_id ASC
		  LIMIT 3
		) s
		JOIN weekly_star_cycle_rewards r ON r.app_code = ? AND r.cycle_id = ? AND r.rank_no =
		  (SELECT COUNT(*) + 1 FROM weekly_star_user_scores x
		   WHERE x.app_code = ? AND x.cycle_id = ?
		     AND (x.score > s.score OR (x.score = s.score AND x.first_scored_at_ms < s.first_scored_at_ms) OR (x.score = s.score AND x.first_scored_at_ms = s.first_scored_at_ms AND x.user_id < s.user_id)))
		ORDER BY r.rank_no ASC`,
		appcode.FromContext(ctx), cycleID, appcode.FromContext(ctx), cycleID, appcode.FromContext(ctx), cycleID,
	)
	if err != nil {
		return err
	}
	type topRow struct {
		userID          int64
		score           int64
		rankNo          int32
		resourceGroupID int64
	}
	// 先缓存 Top3 再插入 settlement，原因同上：同一 MySQL 事务里不能边遍历 rows 边继续写表。
	topRows := make([]topRow, 0, 3)
	for rows.Next() {
		var firstScoredAtMS int64
		var item topRow
		if err := rows.Scan(&item.userID, &item.score, &firstScoredAtMS, &item.rankNo, &item.resourceGroupID); err != nil {
			_ = rows.Close()
			return err
		}
		topRows = append(topRows, item)
		_ = firstScoredAtMS
	}
	if err := rows.Err(); err != nil {
		_ = rows.Close()
		return err
	}
	if err := rows.Close(); err != nil {
		return err
	}
	for _, item := range topRows {
		settlementID := idgen.New("wstars")
		// wallet_command_id 必须稳定包含 cycle/rank/user；结算重跑或 wallet 超时重试时依赖它防重发。
		commandID := fmt.Sprintf("weekly_star:%s:%d:%d", cycleID, item.rankNo, item.userID)
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO weekly_star_settlements (
			  app_code, settlement_id, cycle_id, rank_no, user_id, score, resource_group_id,
			  wallet_command_id, wallet_grant_id, status, failure_reason, attempt_count, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, '', 0, ?, ?)`,
			appcode.FromContext(ctx), settlementID, cycleID, item.rankNo, item.userID, item.score, item.resourceGroupID,
			commandID, domain.SettlementStatusPending, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) getWeeklyStarCycle(ctx context.Context, q interface {
	QueryRowContext(context.Context, string, ...any) *sql.Row
}, cycleID string) (domain.Cycle, error) {
	row := q.QueryRowContext(ctx, `
		SELECT app_code, cycle_id, activity_code, region_id, title, status, start_ms, end_ms,
		       created_by_admin_id, updated_by_admin_id, settled_at_ms, created_at_ms, updated_at_ms
		FROM weekly_star_cycles
		WHERE app_code = ? AND cycle_id = ?`,
		appcode.FromContext(ctx), strings.TrimSpace(cycleID),
	)
	cycle, err := scanWeeklyStarCycle(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Cycle{}, xerr.New(xerr.NotFound, "weekly star cycle not found")
	}
	return cycle, err
}

func (r *Repository) attachWeeklyStarChildren(ctx context.Context, cycles []domain.Cycle) error {
	if len(cycles) == 0 {
		return nil
	}
	for index := range cycles {
		gifts, err := r.listWeeklyStarGifts(ctx, cycles[index].CycleID)
		if err != nil {
			return err
		}
		rewards, err := r.listWeeklyStarRewards(ctx, cycles[index].CycleID)
		if err != nil {
			return err
		}
		cycles[index].Gifts = gifts
		cycles[index].Rewards = rewards
	}
	return nil
}

func (r *Repository) listWeeklyStarGifts(ctx context.Context, cycleID string) ([]domain.Gift, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, cycle_id, gift_id, sort_order
		FROM weekly_star_cycle_gifts
		WHERE app_code = ? AND cycle_id = ?
		ORDER BY sort_order DESC, gift_id ASC`,
		appcode.FromContext(ctx), cycleID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	gifts := make([]domain.Gift, 0)
	for rows.Next() {
		var gift domain.Gift
		if err := rows.Scan(&gift.AppCode, &gift.CycleID, &gift.GiftID, &gift.SortOrder); err != nil {
			return nil, err
		}
		gifts = append(gifts, gift)
	}
	return gifts, rows.Err()
}

func (r *Repository) listWeeklyStarRewards(ctx context.Context, cycleID string) ([]domain.Reward, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, cycle_id, rank_no, resource_group_id
		FROM weekly_star_cycle_rewards
		WHERE app_code = ? AND cycle_id = ?
		ORDER BY rank_no ASC`,
		appcode.FromContext(ctx), cycleID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	rewards := make([]domain.Reward, 0)
	for rows.Next() {
		var reward domain.Reward
		if err := rows.Scan(&reward.AppCode, &reward.CycleID, &reward.RankNo, &reward.ResourceGroupID); err != nil {
			return nil, err
		}
		rewards = append(rewards, reward)
	}
	return rewards, rows.Err()
}

type weeklyStarCycleScanner interface {
	Scan(dest ...any) error
}

func scanWeeklyStarCycle(row weeklyStarCycleScanner) (domain.Cycle, error) {
	var cycle domain.Cycle
	err := row.Scan(&cycle.AppCode, &cycle.CycleID, &cycle.ActivityCode, &cycle.RegionID, &cycle.Title, &cycle.Status,
		&cycle.StartMS, &cycle.EndMS, &cycle.CreatedByAdminID, &cycle.UpdatedByAdminID, &cycle.SettledAtMS, &cycle.CreatedAtMS, &cycle.UpdatedAtMS)
	return cycle, err
}

func scanWeeklyStarSettlement(row weeklyStarCycleScanner) (domain.Settlement, error) {
	var settlement domain.Settlement
	err := row.Scan(&settlement.AppCode, &settlement.SettlementID, &settlement.CycleID, &settlement.RankNo, &settlement.UserID,
		&settlement.Score, &settlement.ResourceGroupID, &settlement.WalletCommandID, &settlement.WalletGrantID, &settlement.Status,
		&settlement.FailureReason, &settlement.AttemptCount, &settlement.CreatedAtMS, &settlement.UpdatedAtMS)
	return settlement, err
}

func weeklyStarCycleWhere(ctx context.Context, query domain.ListQuery) (string, []any) {
	conditions := []string{"app_code = ?", "activity_code = ?"}
	args := []any{appcode.FromContext(ctx), domain.ActivityCode}
	if query.RegionID != nil {
		conditions = append(conditions, "region_id = ?")
		args = append(args, *query.RegionID)
	}
	if query.Status != "" {
		conditions = append(conditions, "status = ?")
		args = append(args, query.Status)
	}
	if query.StartMS > 0 {
		conditions = append(conditions, "end_ms > ?")
		args = append(args, query.StartMS)
	}
	if query.EndMS > 0 {
		conditions = append(conditions, "start_ms < ?")
		args = append(args, query.EndMS)
	}
	return "WHERE " + strings.Join(conditions, " AND "), args
}

func parseWeeklyStarPageToken(value string) int {
	offset, err := strconv.Atoi(strings.TrimSpace(value))
	if err != nil || offset < 0 {
		return 0
	}
	return offset
}

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