package mysql

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

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

func (r *Repository) GetInviteActivityRewardConfig(ctx context.Context) (domain.Config, bool, error) {
	if r == nil || r.db == nil {
		return domain.Config{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	config, exists, err := r.getInviteActivityRewardConfig(ctx, nil)
	if err != nil || !exists {
		return config, exists, err
	}
	tiers, err := r.listInviteActivityRewardTiers(ctx, nil, false)
	if err != nil {
		return domain.Config{}, false, err
	}
	config.Tiers = tiers
	return config, true, nil
}

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

	if _, err := tx.ExecContext(ctx, `
		INSERT INTO invite_activity_reward_configs (
		  app_code, enabled, per_invite_inviter_reward_coin_amount, per_invite_invitee_reward_coin_amount,
		  updated_by_admin_id, created_at_ms, updated_at_ms
		)
		VALUES (?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
		  enabled = VALUES(enabled),
		  per_invite_inviter_reward_coin_amount = VALUES(per_invite_inviter_reward_coin_amount),
		  per_invite_invitee_reward_coin_amount = VALUES(per_invite_invitee_reward_coin_amount),
		  updated_by_admin_id = VALUES(updated_by_admin_id),
		  updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), config.Enabled, config.PerInviteInviterRewardCoinAmount, config.PerInviteInviteeRewardCoinAmount,
		config.UpdatedByAdminID, nowMS, nowMS,
	); err != nil {
		return domain.Config{}, err
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM invite_activity_reward_tiers WHERE app_code = ?`, appcode.FromContext(ctx)); err != nil {
		return domain.Config{}, err
	}
	for i := range config.Tiers {
		tier := config.Tiers[i]
		tier.CreatedAtMS = nowMS
		tier.UpdatedAtMS = nowMS
		if tier.TierID > 0 {
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO invite_activity_reward_tiers (
				  tier_id, app_code, reward_type, tier_code, tier_name, threshold_coin_amount,
				  threshold_valid_invite_count, reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
				) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
				tier.TierID, appcode.FromContext(ctx), tier.RewardType, tier.TierCode, tier.TierName, tier.ThresholdCoinAmount,
				tier.ThresholdValidInviteCount, tier.RewardCoinAmount, tier.Status, tier.SortOrder, nowMS, nowMS,
			); err != nil {
				return domain.Config{}, err
			}
			continue
		}
		result, err := tx.ExecContext(ctx, `
			INSERT INTO invite_activity_reward_tiers (
			  app_code, reward_type, tier_code, tier_name, threshold_coin_amount,
			  threshold_valid_invite_count, reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), tier.RewardType, tier.TierCode, tier.TierName, tier.ThresholdCoinAmount,
			tier.ThresholdValidInviteCount, tier.RewardCoinAmount, tier.Status, tier.SortOrder, nowMS, nowMS,
		)
		if err != nil {
			return domain.Config{}, err
		}
		id, err := result.LastInsertId()
		if err != nil {
			return domain.Config{}, err
		}
		config.Tiers[i].TierID = id
	}
	if err := tx.Commit(); err != nil {
		return domain.Config{}, err
	}
	updated, exists, err := r.GetInviteActivityRewardConfig(ctx)
	if err != nil {
		return domain.Config{}, err
	}
	if !exists {
		return domain.Config{}, xerr.New(xerr.NotFound, "invite activity reward config not found")
	}
	return updated, nil
}

func (r *Repository) GetInviteActivityRewardProgress(ctx context.Context, cycleKey string, userID int64) (domain.Progress, bool, error) {
	if r == nil || r.db == nil {
		return domain.Progress{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	row := r.db.QueryRowContext(ctx, inviteActivityRewardProgressSelectSQL()+`
		WHERE app_code = ? AND cycle_key = ? AND user_id = ?`,
		appcode.FromContext(ctx), cycleKey, userID,
	)
	progress, err := scanInviteActivityRewardProgress(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Progress{AppCode: appcode.FromContext(ctx), CycleKey: cycleKey, UserID: userID}, false, nil
	}
	return progress, err == nil, err
}

func (r *Repository) ListInviteActivityRewardClaimsByUserCycle(ctx context.Context, cycleKey string, userID int64) ([]domain.Claim, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rows, err := r.db.QueryContext(ctx, inviteActivityRewardClaimSelectSQL()+`
		WHERE app_code = ? AND cycle_key = ? AND user_id = ?
		ORDER BY reward_type ASC, created_at_ms ASC, claim_id ASC`,
		appcode.FromContext(ctx), cycleKey, userID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	claims, _, err := scanInviteActivityRewardClaims(rows, 0)
	return claims, err
}

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

	inserted, err := insertInviteActivityRewardEvent(ctx, tx, inviteActivityRewardEventRow{
		AppCode:            appcode.FromContext(ctx),
		EventID:            event.EventID,
		EventType:          event.EventType,
		CycleKey:           cycle.Key,
		InvitedUserID:      event.InvitedUserID,
		InviterUserID:      event.InviterUserID,
		TransactionID:      event.TransactionID,
		CommandID:          event.CommandID,
		RechargeType:       event.RechargeType,
		RechargeCoinAmount: event.RechargeCoinAmount,
		Status:             "consumed",
		PayloadJSON:        event.PayloadJSON,
		OccurredAtMS:       event.OccurredAtMS,
		CreatedAtMS:        nowMS,
	})
	if err != nil {
		return domain.ConsumeResult{}, err
	}
	if !inserted {
		return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonAlreadyConsumed}, tx.Commit()
	}
	if err := upsertInviteActivityRechargeProgress(ctx, tx, event.InviterUserID, cycle.Key, event.RechargeCoinAmount, nowMS); err != nil {
		return domain.ConsumeResult{}, err
	}
	return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, tx.Commit()
}

func (r *Repository) RecordInviteActivityInviteEvent(ctx context.Context, event domain.InviteEvent, cycle domain.Cycle, status string, skipReason string, nowMS int64) (domain.ConsumeResult, error) {
	if r == nil || r.db == nil {
		return domain.ConsumeResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.ConsumeResult{}, err
	}
	defer func() { _ = tx.Rollback() }()
	inserted, err := insertInviteActivityRewardEvent(ctx, tx, inviteActivityRewardEventRow{
		AppCode:       appcode.FromContext(ctx),
		EventID:       event.EventID,
		EventType:     event.EventType,
		CycleKey:      cycle.Key,
		InvitedUserID: event.InvitedUserID,
		InviterUserID: event.InviterUserID,
		CommandID:     event.Source,
		RechargeType:  event.InviteCode,
		Status:        strings.TrimSpace(status),
		SkipReason:    strings.TrimSpace(skipReason),
		PayloadJSON:   event.PayloadJSON,
		OccurredAtMS:  event.OccurredAtMS,
		CreatedAtMS:   nowMS,
	})
	if err != nil {
		return domain.ConsumeResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return domain.ConsumeResult{}, err
	}
	if !inserted {
		return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonAlreadyConsumed}, nil
	}
	if strings.TrimSpace(status) == "skipped" {
		return domain.ConsumeResult{Consumed: false, Reason: strings.TrimSpace(skipReason)}, nil
	}
	return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, nil
}

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

	inserted, err := insertInviteActivityRewardEvent(ctx, tx, inviteActivityRewardEventRow{
		AppCode:       appcode.FromContext(ctx),
		EventID:       event.EventID,
		EventType:     event.EventType,
		CycleKey:      cycle.Key,
		InvitedUserID: event.InvitedUserID,
		InviterUserID: event.InviterUserID,
		Status:        "consumed",
		PayloadJSON:   event.PayloadJSON,
		OccurredAtMS:  event.OccurredAtMS,
		CreatedAtMS:   nowMS,
	})
	if err != nil {
		return domain.ConsumeResult{}, err
	}
	if !inserted {
		return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonAlreadyConsumed}, tx.Commit()
	}
	if err := upsertInviteActivityValidProgress(ctx, tx, event.InviterUserID, cycle.Key, nowMS); err != nil {
		return domain.ConsumeResult{}, err
	}
	return domain.ConsumeResult{Consumed: true, Reason: domain.ReasonEligible}, tx.Commit()
}

func (r *Repository) CreateInviteActivityRewardClaim(ctx context.Context, claim domain.Claim, nowMS int64) (domain.Claim, error) {
	if r == nil || r.db == nil {
		return domain.Claim{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if claim.ClaimID == "" {
		claim.ClaimID = idgen.New("IAR")
	}
	claim.AppCode = appcode.FromContext(ctx)
	claim.Status = domain.ClaimStatusPending
	claim.WalletCommandID = claim.CommandID
	claim.CreatedAtMS = nowMS
	claim.UpdatedAtMS = nowMS
	_, err := r.db.ExecContext(ctx, `
		INSERT INTO invite_activity_reward_claims (
		  app_code, claim_id, cycle_key, user_id, reward_type, command_id, tier_id, tier_code, tier_name,
		  threshold_coin_amount, threshold_valid_invite_count, reached_value, reward_coin_amount,
		  status, wallet_command_id, wallet_transaction_id, failure_reason, granted_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', 0, ?, ?)`,
		claim.AppCode, claim.ClaimID, claim.CycleKey, claim.UserID, claim.RewardType, claim.CommandID, claim.TierID, claim.TierCode, claim.TierName,
		claim.ThresholdCoinAmount, claim.ThresholdValidInviteCount, claim.ReachedValue, claim.RewardCoinAmount,
		claim.Status, claim.WalletCommandID, nowMS, nowMS,
	)
	if err != nil {
		if isMySQLDuplicate(err) {
			existing, exists, lookupErr := r.getInviteActivityRewardClaimByUserTier(ctx, claim.CycleKey, claim.UserID, claim.RewardType, claim.TierID)
			if lookupErr != nil {
				return domain.Claim{}, lookupErr
			}
			if exists {
				return existing, nil
			}
		}
		return domain.Claim{}, err
	}
	return r.getInviteActivityRewardClaimByID(ctx, claim.ClaimID)
}

func (r *Repository) MarkInviteActivityRewardClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (domain.Claim, error) {
	if r == nil || r.db == nil {
		return domain.Claim{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return domain.Claim{}, err
	}
	defer func() { _ = tx.Rollback() }()
	claim, exists, err := getInviteActivityRewardClaimByIDForUpdate(ctx, tx, appcode.FromContext(ctx), claimID)
	if err != nil {
		return domain.Claim{}, err
	}
	if !exists {
		return domain.Claim{}, xerr.New(xerr.NotFound, "invite activity reward claim not found")
	}
	if claim.Status != domain.ClaimStatusGranted {
		if _, err := tx.ExecContext(ctx, `
			UPDATE invite_activity_reward_claims
			SET status = 'granted', wallet_transaction_id = ?, failure_reason = '', granted_at_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND claim_id = ?`,
			walletTransactionID, grantedAtMS, grantedAtMS, appcode.FromContext(ctx), claimID,
		); err != nil {
			return domain.Claim{}, err
		}
		if err := upsertInviteActivityClaimedProgress(ctx, tx, claim.UserID, claim.CycleKey, claim.RewardCoinAmount, grantedAtMS); err != nil {
			return domain.Claim{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return domain.Claim{}, err
	}
	return r.getInviteActivityRewardClaimByID(ctx, claimID)
}

func (r *Repository) MarkInviteActivityRewardClaimFailed(ctx context.Context, claimID string, failureReason 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 invite_activity_reward_claims
		SET status = 'failed', failure_reason = ?, updated_at_ms = ?
		WHERE app_code = ? AND claim_id = ? AND status <> 'granted'`,
		strings.TrimSpace(failureReason), nowMS, appcode.FromContext(ctx), claimID,
	)
	return err
}

func (r *Repository) ListInviteActivityRewardClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	where := []string{"app_code = ?"}
	args := []any{appcode.FromContext(ctx)}
	if query.Status != "" {
		where = append(where, "status = ?")
		args = append(args, query.Status)
	}
	if query.RewardType != "" {
		where = append(where, "reward_type = ?")
		args = append(args, query.RewardType)
	}
	if query.UserID > 0 {
		where = append(where, "user_id = ?")
		args = append(args, query.UserID)
	}
	if query.CycleKey != "" {
		where = append(where, "cycle_key = ?")
		args = append(args, query.CycleKey)
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	countSQL := `SELECT COUNT(1) FROM invite_activity_reward_claims WHERE ` + strings.Join(where, " AND ")
	var total int64
	if err := r.db.QueryRowContext(ctx, countSQL, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	args = append(args, int(query.PageSize), int((query.Page-1)*query.PageSize))
	rows, err := r.db.QueryContext(ctx, inviteActivityRewardClaimSelectSQL()+`
		WHERE `+strings.Join(where, " AND ")+`
		ORDER BY created_at_ms DESC, claim_id DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	claims, _, err := scanInviteActivityRewardClaims(rows, 0)
	return claims, total, err
}

func (r *Repository) ListInviteActivityRewardSummaries(ctx context.Context, query domain.SummaryQuery) ([]domain.Summary, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}

	groupedSQL, groupedArgs := inviteActivitySummaryGroupedSQL(appcode.FromContext(ctx), query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM (`+groupedSQL+`) grouped`, groupedArgs...).Scan(&total); err != nil {
		return nil, 0, err
	}
	if total == 0 {
		return []domain.Summary{}, 0, nil
	}

	inviteAggSQL, inviteAggArgs := inviteActivitySummaryInviteAggSQL(appcode.FromContext(ctx), query)
	args := append([]any{}, groupedArgs...)
	args = append(args, inviteAggArgs...)
	args = append(args, appcode.FromContext(ctx), int(query.PageSize), int((query.Page-1)*query.PageSize))
	rows, err := r.db.QueryContext(ctx, `
		SELECT
		  grouped.app_code,
		  grouped.cycle_key,
		  grouped.user_id,
		  COALESCE(invites.invite_count, 0),
		  COALESCE(progress.valid_invite_count, 0),
		  COALESCE(progress.total_recharge_coin_amount, 0),
		  COALESCE(progress.claimed_reward_coin_amount, 0),
		  COALESCE(progress.updated_at_ms, invites.last_invited_at_ms, 0)
		FROM (`+groupedSQL+`) grouped
		LEFT JOIN (`+inviteAggSQL+`) invites
		  ON invites.app_code = grouped.app_code
		 AND invites.cycle_key = grouped.cycle_key
		 AND invites.user_id = grouped.user_id
		LEFT JOIN invite_activity_reward_progress progress
		  ON progress.app_code = ?
		 AND progress.cycle_key = grouped.cycle_key
		 AND progress.user_id = grouped.user_id
		ORDER BY grouped.cycle_key DESC, COALESCE(progress.updated_at_ms, invites.last_invited_at_ms, 0) DESC, grouped.user_id DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	summaries := make([]domain.Summary, 0, query.PageSize)
	for rows.Next() {
		var summary domain.Summary
		if err := rows.Scan(
			&summary.AppCode,
			&summary.CycleKey,
			&summary.UserID,
			&summary.InviteCount,
			&summary.ValidInviteCount,
			&summary.TotalRechargeCoinAmount,
			&summary.TotalRewardCoinAmount,
			&summary.UpdatedAtMS,
		); err != nil {
			return nil, 0, err
		}
		summaries = append(summaries, summary)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.fillInviteActivitySummaryInvitees(ctx, summaries); err != nil {
		return nil, 0, err
	}
	return summaries, total, nil
}

func (r *Repository) ListInviteActivityRewardInvitees(ctx context.Context, query domain.InviteeQuery) ([]domain.InviteeSummary, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	if query.UserID <= 0 || strings.TrimSpace(query.CycleKey) == "" {
		return nil, 0, xerr.New(xerr.InvalidArgument, "user_id and cycle_key are required")
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 50
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	args := []any{
		appcode.FromContext(ctx),
		query.CycleKey,
		query.UserID,
		domain.EventTypeUserInvited,
		domain.EventTypeWalletRechargeRecorded,
	}
	// app_code + cycle_key + inviter_user_id 命中 idx_invite_activity_reward_event_inviter；
	// 查询只聚合当前用户一个 UTC 月的事实，不扫描其他邀请人或历史月份。
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(DISTINCT invited_user_id)
		FROM invite_activity_reward_events
		WHERE app_code = ?
		  AND cycle_key = ?
		  AND inviter_user_id = ?
		  AND invited_user_id > 0
		  AND event_type IN (?, ?)`, args...).Scan(&total); err != nil {
		return nil, 0, err
	}
	if total == 0 {
		return []domain.InviteeSummary{}, 0, nil
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT
		  invited_user_id,
		  COALESCE(SUM(CASE WHEN event_type = ? AND status = 'consumed' THEN recharge_coin_amount ELSE 0 END), 0) AS recharge_coin_amount,
		  COALESCE(MIN(CASE WHEN event_type = ? THEN occurred_at_ms ELSE NULL END), 0) AS invited_at_ms
		FROM invite_activity_reward_events
		WHERE app_code = ?
		  AND cycle_key = ?
		  AND inviter_user_id = ?
		  AND invited_user_id > 0
		  AND event_type IN (?, ?)
		GROUP BY invited_user_id
		ORDER BY invited_at_ms DESC, invited_user_id DESC
		LIMIT ? OFFSET ?`,
		domain.EventTypeWalletRechargeRecorded,
		domain.EventTypeUserInvited,
		appcode.FromContext(ctx),
		query.CycleKey,
		query.UserID,
		domain.EventTypeUserInvited,
		domain.EventTypeWalletRechargeRecorded,
		int(query.PageSize),
		int((query.Page-1)*query.PageSize),
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	invitees := make([]domain.InviteeSummary, 0, query.PageSize)
	for rows.Next() {
		var invitee domain.InviteeSummary
		if err := rows.Scan(&invitee.InvitedUserID, &invitee.RechargeCoinAmount, &invitee.InvitedAtMS); err != nil {
			return nil, 0, err
		}
		invitees = append(invitees, invitee)
	}
	return invitees, total, rows.Err()
}

func (r *Repository) ListInviteActivityLeaderboard(ctx context.Context, query domain.LeaderboardQuery) ([]domain.LeaderboardEntry, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(1)
		FROM invite_activity_reward_progress
		WHERE app_code = ? AND cycle_key = ? AND valid_invite_count > 0`,
		appcode.FromContext(ctx), query.CycleKey,
	).Scan(&total); err != nil {
		return nil, 0, err
	}
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 50
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	// ROW_NUMBER 在完整月榜上先生成名次，再分页截取；这样第 2 页仍然返回真实全局 rank_no。
	rows, err := r.db.QueryContext(ctx, `
		SELECT rank_no, user_id, valid_invite_count, total_recharge_coin_amount
		FROM (
		  SELECT
		    ROW_NUMBER() OVER (
		      ORDER BY valid_invite_count DESC, total_recharge_coin_amount DESC, updated_at_ms ASC, user_id ASC
		    ) AS rank_no,
		    user_id,
		    valid_invite_count,
		    total_recharge_coin_amount
		  FROM invite_activity_reward_progress
		  WHERE app_code = ? AND cycle_key = ? AND valid_invite_count > 0
		) ranked
		ORDER BY rank_no ASC
		LIMIT ? OFFSET ?`,
		appcode.FromContext(ctx), query.CycleKey, int(query.PageSize), int((query.Page-1)*query.PageSize),
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	entries := make([]domain.LeaderboardEntry, 0, query.PageSize)
	for rows.Next() {
		var entry domain.LeaderboardEntry
		if err := rows.Scan(&entry.RankNo, &entry.UserID, &entry.ValidInviteCount, &entry.TotalRechargeCoinAmount); err != nil {
			return nil, 0, err
		}
		entries = append(entries, entry)
	}
	return entries, total, rows.Err()
}

type inviteActivitySummaryKey struct {
	CycleKey string
	UserID   int64
}

func inviteActivitySummaryGroupedSQL(appCode string, query domain.SummaryQuery) (string, []any) {
	progressWhere := []string{"progress.app_code = ?"}
	progressArgs := []any{appCode}
	eventWhere := []string{"app_code = ?", "event_type = ?", "inviter_user_id > 0", "invited_user_id > 0"}
	eventArgs := []any{appCode, domain.EventTypeUserInvited}
	if query.UserID > 0 {
		progressWhere = append(progressWhere, "progress.user_id = ?")
		progressArgs = append(progressArgs, query.UserID)
		eventWhere = append(eventWhere, "inviter_user_id = ?")
		eventArgs = append(eventArgs, query.UserID)
	}
	if query.CycleKey != "" {
		progressWhere = append(progressWhere, "progress.cycle_key = ?")
		progressArgs = append(progressArgs, query.CycleKey)
		eventWhere = append(eventWhere, "cycle_key = ?")
		eventArgs = append(eventArgs, query.CycleKey)
	}
	// progress 覆盖已产生充值、有效邀请或奖励的邀请人；EXISTS 会排除“被邀请人即时奖励”写出的 invitee-only progress。
	// UserInvited 事件覆盖仅邀请但尚未充值的邀请人，保证后台主行始终代表邀请人而不是奖励领取人。
	sqlText := `
		SELECT progress.app_code, progress.cycle_key, progress.user_id
		FROM invite_activity_reward_progress progress
		WHERE ` + strings.Join(progressWhere, " AND ") + `
		  AND EXISTS (
		    SELECT 1
		    FROM invite_activity_reward_events event
		    WHERE event.app_code = progress.app_code
		      AND event.cycle_key = progress.cycle_key
		      AND event.inviter_user_id = progress.user_id
		      AND event.event_type IN (?, ?, ?)
		    LIMIT 1
		  )
		UNION
		SELECT app_code, cycle_key, inviter_user_id AS user_id
		FROM invite_activity_reward_events
		WHERE ` + strings.Join(eventWhere, " AND ")
	args := append([]any{}, progressArgs...)
	args = append(args, domain.EventTypeUserInvited, domain.EventTypeWalletRechargeRecorded, domain.EventTypeUserInviteBecameValid)
	args = append(args, eventArgs...)
	return sqlText, args
}

func inviteActivitySummaryInviteAggSQL(appCode string, query domain.SummaryQuery) (string, []any) {
	where := []string{"app_code = ?", "event_type = ?", "inviter_user_id > 0", "invited_user_id > 0"}
	args := []any{appCode, domain.EventTypeUserInvited}
	if query.UserID > 0 {
		where = append(where, "inviter_user_id = ?")
		args = append(args, query.UserID)
	}
	if query.CycleKey != "" {
		where = append(where, "cycle_key = ?")
		args = append(args, query.CycleKey)
	}
	return `
		SELECT
		  app_code,
		  cycle_key,
		  inviter_user_id AS user_id,
		  COUNT(DISTINCT invited_user_id) AS invite_count,
		  MAX(occurred_at_ms) AS last_invited_at_ms
		FROM invite_activity_reward_events
		WHERE ` + strings.Join(where, " AND ") + `
		GROUP BY app_code, cycle_key, inviter_user_id`, args
}

func (r *Repository) fillInviteActivitySummaryInvitees(ctx context.Context, summaries []domain.Summary) error {
	if len(summaries) == 0 {
		return nil
	}
	selectors := make([]string, 0, len(summaries))
	args := []any{domain.EventTypeWalletRechargeRecorded, domain.EventTypeUserInvited, appcode.FromContext(ctx)}
	summaryIndex := make(map[inviteActivitySummaryKey]int, len(summaries))
	for index, summary := range summaries {
		key := inviteActivitySummaryKey{CycleKey: summary.CycleKey, UserID: summary.UserID}
		summaryIndex[key] = index
		selectors = append(selectors, "(cycle_key = ? AND inviter_user_id = ?)")
		args = append(args, summary.CycleKey, summary.UserID)
	}
	args = append(args, domain.EventTypeUserInvited, domain.EventTypeWalletRechargeRecorded)

	// 子列表按当前页批量读取；邀请时间只取 UserInvited 首次发生时间，充值只累加已消费的钱包充值事实。
	rows, err := r.db.QueryContext(ctx, `
		SELECT
		  cycle_key,
		  inviter_user_id,
		  invited_user_id,
		  COALESCE(SUM(CASE WHEN event_type = ? AND status = 'consumed' THEN recharge_coin_amount ELSE 0 END), 0) AS recharge_coin_amount,
		  COALESCE(MIN(CASE WHEN event_type = ? THEN occurred_at_ms ELSE NULL END), 0) AS invited_at_ms
		FROM invite_activity_reward_events
		WHERE app_code = ?
		  AND invited_user_id > 0
		  AND (`+strings.Join(selectors, " OR ")+`)
		  AND event_type IN (?, ?)
		GROUP BY cycle_key, inviter_user_id, invited_user_id
		ORDER BY cycle_key DESC, inviter_user_id DESC, invited_at_ms DESC, invited_user_id DESC`, args...)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var (
			cycleKey      string
			inviterUserID int64
			invitee       domain.InviteeSummary
		)
		if err := rows.Scan(&cycleKey, &inviterUserID, &invitee.InvitedUserID, &invitee.RechargeCoinAmount, &invitee.InvitedAtMS); err != nil {
			return err
		}
		index, ok := summaryIndex[inviteActivitySummaryKey{CycleKey: cycleKey, UserID: inviterUserID}]
		if !ok {
			continue
		}
		summaries[index].Invitees = append(summaries[index].Invitees, invitee)
	}
	return rows.Err()
}

type inviteActivityRewardEventRow struct {
	AppCode            string
	EventID            string
	EventType          string
	CycleKey           string
	InvitedUserID      int64
	InviterUserID      int64
	TransactionID      string
	CommandID          string
	RechargeType       string
	RechargeCoinAmount int64
	Status             string
	SkipReason         string
	PayloadJSON        string
	OccurredAtMS       int64
	CreatedAtMS        int64
}

func insertInviteActivityRewardEvent(ctx context.Context, tx *sql.Tx, event inviteActivityRewardEventRow) (bool, error) {
	result, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO invite_activity_reward_events (
		  app_code, event_id, event_type, cycle_key, invited_user_id, inviter_user_id, transaction_id, command_id,
		  recharge_type, recharge_coin_amount, status, skip_reason, payload_json, occurred_at_ms, created_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?, ?)`,
		event.AppCode, event.EventID, event.EventType, event.CycleKey, event.InvitedUserID, event.InviterUserID, event.TransactionID, event.CommandID,
		event.RechargeType, event.RechargeCoinAmount, event.Status, event.SkipReason, event.PayloadJSON, event.OccurredAtMS, event.CreatedAtMS,
	)
	if err != nil {
		return false, err
	}
	affected, err := result.RowsAffected()
	return affected > 0, err
}

func upsertInviteActivityRechargeProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, coinAmount int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO invite_activity_reward_progress (
		  app_code, cycle_key, user_id, total_recharge_coin_amount, valid_invite_count, claimed_reward_coin_amount, updated_at_ms
		) VALUES (?, ?, ?, ?, 0, 0, ?)
		ON DUPLICATE KEY UPDATE
		  total_recharge_coin_amount = total_recharge_coin_amount + VALUES(total_recharge_coin_amount),
		  updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), cycleKey, userID, coinAmount, nowMS,
	)
	return err
}

func upsertInviteActivityValidProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO invite_activity_reward_progress (
		  app_code, cycle_key, user_id, total_recharge_coin_amount, valid_invite_count, claimed_reward_coin_amount, updated_at_ms
		) VALUES (?, ?, ?, 0, 1, 0, ?)
		ON DUPLICATE KEY UPDATE
		  valid_invite_count = valid_invite_count + 1,
		  updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), cycleKey, userID, nowMS,
	)
	return err
}

func upsertInviteActivityClaimedProgress(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string, rewardCoinAmount int64, nowMS int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO invite_activity_reward_progress (
		  app_code, cycle_key, user_id, total_recharge_coin_amount, valid_invite_count, claimed_reward_coin_amount, updated_at_ms
		) VALUES (?, ?, ?, 0, 0, ?, ?)
		ON DUPLICATE KEY UPDATE
		  claimed_reward_coin_amount = claimed_reward_coin_amount + VALUES(claimed_reward_coin_amount),
		  updated_at_ms = VALUES(updated_at_ms)`,
		appcode.FromContext(ctx), cycleKey, userID, rewardCoinAmount, nowMS,
	)
	return err
}

func (r *Repository) getInviteActivityRewardConfig(ctx context.Context, tx *sql.Tx) (domain.Config, bool, error) {
	queryer := queryerFromRepository(r, tx)
	row := queryer.QueryRowContext(ctx, `
		SELECT app_code, enabled, per_invite_inviter_reward_coin_amount, per_invite_invitee_reward_coin_amount,
		       updated_by_admin_id, created_at_ms, updated_at_ms
		FROM invite_activity_reward_configs
		WHERE app_code = ?`, appcode.FromContext(ctx))
	var config domain.Config
	if err := row.Scan(
		&config.AppCode, &config.Enabled, &config.PerInviteInviterRewardCoinAmount, &config.PerInviteInviteeRewardCoinAmount,
		&config.UpdatedByAdminID, &config.CreatedAtMS, &config.UpdatedAtMS,
	); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Config{}, false, nil
		}
		return domain.Config{}, false, err
	}
	return config, true, nil
}

func (r *Repository) listInviteActivityRewardTiers(ctx context.Context, tx *sql.Tx, activeOnly bool) ([]domain.Tier, error) {
	queryer := queryerFromRepository(r, tx)
	where := `WHERE app_code = ?`
	args := []any{appcode.FromContext(ctx)}
	if activeOnly {
		where += ` AND status = 'active'`
	}
	rows, err := queryer.QueryContext(ctx, inviteActivityRewardTierSelectSQL()+`
		`+where+`
		ORDER BY reward_type ASC, sort_order DESC, tier_id ASC`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	tiers := []domain.Tier{}
	for rows.Next() {
		var tier domain.Tier
		if err := rows.Scan(&tier.TierID, &tier.AppCode, &tier.RewardType, &tier.TierCode, &tier.TierName, &tier.ThresholdCoinAmount, &tier.ThresholdValidInviteCount, &tier.RewardCoinAmount, &tier.Status, &tier.SortOrder, &tier.CreatedAtMS, &tier.UpdatedAtMS); err != nil {
			return nil, err
		}
		tiers = append(tiers, tier)
	}
	return tiers, rows.Err()
}

func inviteActivityRewardTierSelectSQL() string {
	return `SELECT tier_id, app_code, reward_type, tier_code, tier_name, threshold_coin_amount, threshold_valid_invite_count, reward_coin_amount, status, sort_order, created_at_ms, updated_at_ms FROM invite_activity_reward_tiers `
}

func inviteActivityRewardProgressSelectSQL() string {
	return `SELECT app_code, cycle_key, user_id, total_recharge_coin_amount, valid_invite_count, claimed_reward_coin_amount, updated_at_ms FROM invite_activity_reward_progress `
}

func inviteActivityRewardClaimSelectSQL() string {
	return `SELECT claim_id, app_code, cycle_key, user_id, reward_type, command_id, tier_id, tier_code, tier_name, threshold_coin_amount, threshold_valid_invite_count, reached_value, reward_coin_amount, status, wallet_command_id, wallet_transaction_id, failure_reason, granted_at_ms, created_at_ms, updated_at_ms FROM invite_activity_reward_claims `
}

func scanInviteActivityRewardProgress(row interface{ Scan(dest ...any) error }) (domain.Progress, error) {
	var progress domain.Progress
	err := row.Scan(&progress.AppCode, &progress.CycleKey, &progress.UserID, &progress.TotalRechargeCoinAmount, &progress.ValidInviteCount, &progress.ClaimedRewardCoinAmount, &progress.UpdatedAtMS)
	return progress, err
}

func scanInviteActivityRewardClaims(rows *sql.Rows, limit int) ([]domain.Claim, int64, error) {
	claims := []domain.Claim{}
	var count int64
	for rows.Next() {
		var claim domain.Claim
		if err := rows.Scan(&claim.ClaimID, &claim.AppCode, &claim.CycleKey, &claim.UserID, &claim.RewardType, &claim.CommandID, &claim.TierID, &claim.TierCode, &claim.TierName, &claim.ThresholdCoinAmount, &claim.ThresholdValidInviteCount, &claim.ReachedValue, &claim.RewardCoinAmount, &claim.Status, &claim.WalletCommandID, &claim.WalletTransactionID, &claim.FailureReason, &claim.GrantedAtMS, &claim.CreatedAtMS, &claim.UpdatedAtMS); err != nil {
			return nil, 0, err
		}
		count++
		if limit <= 0 || len(claims) < limit {
			claims = append(claims, claim)
		}
	}
	return claims, count, rows.Err()
}

func (r *Repository) getInviteActivityRewardClaimByID(ctx context.Context, claimID string) (domain.Claim, error) {
	row := r.db.QueryRowContext(ctx, inviteActivityRewardClaimSelectSQL()+` WHERE app_code = ? AND claim_id = ?`, appcode.FromContext(ctx), claimID)
	claim, err := scanInviteActivityRewardClaim(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Claim{}, xerr.New(xerr.NotFound, "invite activity reward claim not found")
	}
	return claim, err
}

func (r *Repository) getInviteActivityRewardClaimByUserTier(ctx context.Context, cycleKey string, userID int64, rewardType string, tierID int64) (domain.Claim, bool, error) {
	row := r.db.QueryRowContext(ctx, inviteActivityRewardClaimSelectSQL()+`
		WHERE app_code = ? AND cycle_key = ? AND user_id = ? AND reward_type = ? AND tier_id = ?`,
		appcode.FromContext(ctx), cycleKey, userID, rewardType, tierID,
	)
	claim, err := scanInviteActivityRewardClaim(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Claim{}, false, nil
	}
	return claim, err == nil, err
}

func getInviteActivityRewardClaimByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, claimID string) (domain.Claim, bool, error) {
	row := tx.QueryRowContext(ctx, inviteActivityRewardClaimSelectSQL()+` WHERE app_code = ? AND claim_id = ? FOR UPDATE`, appCode, claimID)
	claim, err := scanInviteActivityRewardClaim(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Claim{}, false, nil
	}
	return claim, err == nil, err
}

func scanInviteActivityRewardClaim(row interface{ Scan(dest ...any) error }) (domain.Claim, error) {
	var claim domain.Claim
	err := row.Scan(&claim.ClaimID, &claim.AppCode, &claim.CycleKey, &claim.UserID, &claim.RewardType, &claim.CommandID, &claim.TierID, &claim.TierCode, &claim.TierName, &claim.ThresholdCoinAmount, &claim.ThresholdValidInviteCount, &claim.ReachedValue, &claim.RewardCoinAmount, &claim.Status, &claim.WalletCommandID, &claim.WalletTransactionID, &claim.FailureReason, &claim.GrantedAtMS, &claim.CreatedAtMS, &claim.UpdatedAtMS)
	return claim, err
}

func queryerFromRepository(r *Repository, tx *sql.Tx) interface {
	QueryRowContext(context.Context, string, ...any) *sql.Row
	QueryContext(context.Context, string, ...any) (*sql.Rows, error)
} {
	if tx != nil {
		return tx
	}
	return r.db
}

func inviteActivityWalletCommandID(commandID string) string {
	return strings.TrimSpace(commandID)
}

func inviteActivityClaimID(userID int64, cycleKey string, rewardType string, tierID int64) string {
	return fmt.Sprintf("IAR_%d_%s_%s_%d", userID, cycleKey, rewardType, tierID)
}
