package mysql

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

	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
)

const (
	vipDailyCoinRebateTaskDayLayout  = "2006-01-02"
	vipDailyCoinRebateAvailableEvent = "VipDailyCoinRebateAvailable"
)

type vipDailyCoinRebateCandidate struct {
	UserID      int64
	Level       int32
	VipName     string
	ExpiresAtMS int64
}

type vipDailyCoinRebateMetadata struct {
	RebateID      string `json:"rebate_id"`
	UserID        int64  `json:"user_id"`
	TaskDay       string `json:"task_day"`
	VipLevel      int32  `json:"vip_level"`
	VipName       string `json:"vip_name"`
	CoinAmount    int64  `json:"coin_amount"`
	AvailableAtMS int64  `json:"available_at_ms"`
	ExpiresAtMS   int64  `json:"expires_at_ms"`
	ConfigVersion int64  `json:"config_version"`
	CreatedAtMS   int64  `json:"created_at_ms"`
}

// ProcessVipDailyCoinRebateBatch 在一个事务内串行推进单 App+UTC 日游标。
// run 首次创建时锁住 program 并固化金额矩阵；后续页只读 run 快照，绝不混入后台的新配置版本。
func (r *Repository) ProcessVipDailyCoinRebateBatch(ctx context.Context, command ledger.ProcessVipDailyCoinRebateBatchCommand) (ledger.ProcessVipDailyCoinRebateBatchResult, error) {
	if r == nil || r.db == nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	dayStart, err := time.ParseInLocation(vipDailyCoinRebateTaskDayLayout, command.TaskDay, time.UTC)
	if err != nil || dayStart.Format(vipDailyCoinRebateTaskDayLayout) != command.TaskDay {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, xerr.New(xerr.InvalidArgument, "vip rebate task_day is invalid")
	}
	dayStartMS := dayStart.UnixMilli()
	dayEndMS := dayStart.Add(24 * time.Hour).UnixMilli()
	if command.BatchSize <= 0 {
		command.BatchSize = 100
	}
	if command.BatchSize > 500 {
		command.BatchSize = 500
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
	}
	defer func() { _ = tx.Rollback() }()

	run, exists, err := r.findVipDailyCoinRebateRun(ctx, tx, command.TaskDay, false)
	if err != nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
	}
	if !exists {
		// 缺 run 时先锁 program，再二次检查唯一日 run。并发 worker 会在该锁上排队，
		// 避免两个不存在键的 gap lock 互相等待，并保证金额与 config_version 来自同一快照。
		program, lockErr := r.queryVipProgramConfig(ctx, tx.QueryRowContext(ctx, vipProgramSelectSQL()+` FOR UPDATE`, command.AppCode))
		if errors.Is(lockErr, sql.ErrNoRows) {
			// 租户注册表驱动的 cron 会覆盖所有启用 App；未配置 VIP 的租户没有返现工作，
			// 应按空批次结束，而不是每分钟制造不可重试的 NotFound 告警。
			if err := tx.Commit(); err != nil {
				return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
			}
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, nil
		}
		if lockErr != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, lockErr
		}
		// 等待 program 锁后必须使用 locking current read。普通 RR 快照仍停留在等待前，
		// 看不到另一个 worker 刚提交的 run，随后 INSERT 会错误撞唯一键。
		run, exists, err = r.findVipDailyCoinRebateRun(ctx, tx, command.TaskDay, true)
		if err != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
		}
		if !exists {
			levelAmounts := map[int32]int64{}
			if program.Status == ledger.VipStatusActive {
				levelAmounts, err = r.loadVipDailyCoinRebateLevelAmounts(ctx, tx, program.LevelCount)
				if err != nil {
					return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
				}
			}
			levelAmountsJSON, marshalErr := json.Marshal(levelAmounts)
			if marshalErr != nil {
				return ledger.ProcessVipDailyCoinRebateBatchResult{}, marshalErr
			}
			run = ledger.VipDailyCoinRebateRun{
				RunID:            vipDailyCoinRebateRunID(command.AppCode, command.TaskDay),
				TaskDay:          command.TaskDay,
				DayStartMS:       dayStartMS,
				DayEndMS:         dayEndMS,
				ConfigVersion:    program.ConfigVersion,
				LevelAmounts:     levelAmounts,
				LevelAmountsJSON: string(levelAmountsJSON),
				Status:           ledger.VipDailyCoinRebateRunStatusRunning,
				CreatedAtMS:      command.NowMS,
				UpdatedAtMS:      command.NowMS,
			}
			if _, err := tx.ExecContext(ctx, `
				INSERT INTO vip_daily_coin_rebate_runs (
				   app_code, task_day, run_id, day_start_ms, day_end_ms, config_version,
				   level_amounts_json, status, last_user_id, scanned_count, created_count,
				   created_at_ms, updated_at_ms, completed_at_ms
				 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, 0)`,
				command.AppCode, run.TaskDay, run.RunID, run.DayStartMS, run.DayEndMS,
				run.ConfigVersion, run.LevelAmountsJSON, run.Status, command.NowMS, command.NowMS,
			); err != nil {
				return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
			}
		}
	}
	run, exists, err = r.findVipDailyCoinRebateRun(ctx, tx, command.TaskDay, true)
	if err != nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
	}
	if !exists {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, xerr.New(xerr.Internal, "vip daily coin rebate run creation failed")
	}
	if run.Status == ledger.VipDailyCoinRebateRunStatusCompleted {
		if err := tx.Commit(); err != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
		}
		return ledger.ProcessVipDailyCoinRebateBatchResult{Run: run}, nil
	}

	// 没有任何已配置正数金额时，直接完成 run。扫描会员既不会生成返现，也只会放大日切压力。
	if len(run.LevelAmounts) == 0 {
		run.Status = ledger.VipDailyCoinRebateRunStatusCompleted
		run.CompletedAtMS = command.NowMS
		run.UpdatedAtMS = command.NowMS
		if err := r.updateVipDailyCoinRebateRun(ctx, tx, run); err != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
		}
		if err := tx.Commit(); err != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
		}
		return ledger.ProcessVipDailyCoinRebateBatchResult{Run: run}, nil
	}

	candidates, err := r.listVipDailyCoinRebateCandidates(ctx, tx, run.DayStartMS, run.LastUserID, command.BatchSize)
	if err != nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
	}
	createdCount := 0
	for _, candidate := range candidates {
		if candidate.Level <= 0 || candidate.ExpiresAtMS <= run.DayStartMS {
			continue
		}
		amount, enabled := run.LevelAmounts[candidate.Level]
		if !enabled || amount <= 0 {
			continue
		}
		rebateID := vipDailyCoinRebateID(command.AppCode, run.TaskDay, candidate.UserID)
		result, insertErr := tx.ExecContext(ctx, `
			INSERT IGNORE INTO vip_daily_coin_rebates (
			   app_code, rebate_id, run_id, task_day, user_id, vip_level, vip_name,
			   coin_amount, status, available_at_ms, expires_at_ms, config_version,
			   claimed_at_ms, wallet_transaction_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, '', ?, ?)`,
			command.AppCode, rebateID, run.RunID, run.TaskDay, candidate.UserID, candidate.Level,
			candidate.VipName, amount, ledger.VipDailyCoinRebateStatusClaimable,
			run.DayStartMS, run.DayEndMS, run.ConfigVersion, command.NowMS, command.NowMS,
		)
		if insertErr != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, insertErr
		}
		inserted, rowsErr := result.RowsAffected()
		if rowsErr != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, rowsErr
		}
		if inserted == 0 {
			continue
		}
		createdCount++
		metadata := vipDailyCoinRebateMetadata{
			RebateID: rebateID, UserID: candidate.UserID, TaskDay: run.TaskDay,
			VipLevel: candidate.Level, VipName: candidate.VipName, CoinAmount: amount,
			AvailableAtMS: run.DayStartMS, ExpiresAtMS: run.DayEndMS,
			ConfigVersion: run.ConfigVersion, CreatedAtMS: command.NowMS,
		}
		// available 不是余额变化，因此 transaction_id 使用稳定 rebate_id 占位；事件与返现行
		// 同事务提交，下游可直接用 rebate_id 做通知幂等源 ID。
		if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{{
			EventID:       eventID(rebateID, vipDailyCoinRebateAvailableEvent, candidate.UserID, ledger.AssetCoin),
			EventType:     vipDailyCoinRebateAvailableEvent,
			TransactionID: rebateID,
			CommandID:     vipDailyCoinRebateAvailableCommandID(run.TaskDay, candidate.UserID),
			UserID:        candidate.UserID,
			AssetType:     ledger.AssetCoin,
			Payload:       metadata,
			CreatedAtMS:   command.NowMS,
		}}); err != nil {
			return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
		}
	}

	if len(candidates) > 0 {
		run.LastUserID = candidates[len(candidates)-1].UserID
	}
	run.ScannedCount += int64(len(candidates))
	run.CreatedCount += int64(createdCount)
	run.UpdatedAtMS = command.NowMS
	if len(candidates) < command.BatchSize {
		run.Status = ledger.VipDailyCoinRebateRunStatusCompleted
		run.CompletedAtMS = command.NowMS
	}
	if err := r.updateVipDailyCoinRebateRun(ctx, tx, run); err != nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.ProcessVipDailyCoinRebateBatchResult{}, err
	}
	return ledger.ProcessVipDailyCoinRebateBatchResult{
		Run: run, ScannedCount: len(candidates), CreatedCount: createdCount,
		HasMore: run.Status == ledger.VipDailyCoinRebateRunStatusRunning,
	}, nil
}

// GetMyCurrentVipDailyCoinRebate 只读取当前 UTC task_day；未生成记录不视为错误。
func (r *Repository) GetMyCurrentVipDailyCoinRebate(ctx context.Context, userID int64, nowMS int64) (ledger.VipDailyCoinRebate, bool, error) {
	if r == nil || r.db == nil {
		return ledger.VipDailyCoinRebate{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	taskDay := time.UnixMilli(nowMS).UTC().Format(vipDailyCoinRebateTaskDayLayout)
	rebate, err := scanVipDailyCoinRebate(r.db.QueryRowContext(ctx, vipDailyCoinRebateSelectSQL()+`
		WHERE app_code = ? AND user_id = ? AND task_day = ?`, appcode.FromContext(ctx), userID, taskDay))
	if errors.Is(err, sql.ErrNoRows) {
		return ledger.VipDailyCoinRebate{}, false, nil
	}
	if err != nil {
		return ledger.VipDailyCoinRebate{}, false, err
	}
	return projectVipDailyCoinRebateStatus(rebate, nowMS), true, nil
}

// ListMyVipDailyCoinRebateStatuses 按 UTC 日倒序分页，禁止为状态展示扫描整张返现表。
func (r *Repository) ListMyVipDailyCoinRebateStatuses(ctx context.Context, query ledger.ListVipDailyCoinRebateStatusesQuery) ([]ledger.VipDailyCoinRebate, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	appCode := appcode.FromContext(ctx)
	if len(query.RebateIDs) > 0 {
		args := []any{appCode, query.UserID}
		args = append(args, walletOutboxEventArgs(query.RebateIDs)...)
		rows, err := r.db.QueryContext(ctx, vipDailyCoinRebateSelectSQL()+`
			WHERE app_code = ? AND user_id = ? AND rebate_id IN (`+walletOutboxPlaceholders(len(query.RebateIDs))+`)
			ORDER BY task_day DESC, rebate_id DESC`, args...)
		if err != nil {
			return nil, 0, err
		}
		defer rows.Close()
		items := make([]ledger.VipDailyCoinRebate, 0, len(query.RebateIDs))
		for rows.Next() {
			item, scanErr := scanVipDailyCoinRebate(rows)
			if scanErr != nil {
				return nil, 0, scanErr
			}
			items = append(items, projectVipDailyCoinRebateStatus(item, query.NowMS))
		}
		return items, int64(len(items)), rows.Err()
	}
	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM vip_daily_coin_rebates WHERE app_code = ? AND user_id = ?`,
		appCode, query.UserID,
	).Scan(&total); err != nil {
		return nil, 0, err
	}
	offset := (query.Page - 1) * query.PageSize
	rows, err := r.db.QueryContext(ctx, vipDailyCoinRebateSelectSQL()+`
		WHERE app_code = ? AND user_id = ?
		ORDER BY task_day DESC, rebate_id DESC LIMIT ? OFFSET ?`,
		appCode, query.UserID, query.PageSize, offset,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	items := make([]ledger.VipDailyCoinRebate, 0, query.PageSize)
	for rows.Next() {
		item, scanErr := scanVipDailyCoinRebate(rows)
		if scanErr != nil {
			return nil, 0, scanErr
		}
		items = append(items, projectVipDailyCoinRebateStatus(item, query.NowMS))
	}
	return items, total, rows.Err()
}

// ClaimVipDailyCoinRebate 在返现行锁、COIN 账户锁、交易、分录和 outbox 的单一事务中完成领取。
func (r *Repository) ClaimVipDailyCoinRebate(ctx context.Context, command ledger.ClaimVipDailyCoinRebateCommand) (ledger.ClaimVipDailyCoinRebateReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := stableHash(fmt.Sprintf("vip_daily_coin_rebate_claim|%s|%d|%s", command.AppCode, command.UserID, command.RebateID))
	// 所有 VIP 写命令共用 command guard。不同 command 重复点击已领取返现虽然不再造交易，
	// 也会持久化 guard，保证该 command 后续改投另一 rebate 时稳定返回 IDEMPOTENCY_CONFLICT。
	if err := r.lockVipCommand(ctx, tx, command.CommandID, bizTypeVipDailyCoinRebate, requestHash, command.NowMS, xerr.IdempotencyConflict); err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	rebate, err := scanVipDailyCoinRebate(tx.QueryRowContext(ctx, vipDailyCoinRebateSelectSQL()+`
		WHERE app_code = ? AND rebate_id = ? AND user_id = ? FOR UPDATE`,
		command.AppCode, command.RebateID, command.UserID,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.VIPCoinRebateNotFound, "vip daily coin rebate not found")
	}
	if err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	if txRow, exists, lookupErr := r.lookupTransactionConsistent(ctx, tx, command.CommandID, requestHash, bizTypeVipDailyCoinRebate, xerr.IdempotencyConflict); lookupErr != nil || exists {
		if lookupErr != nil || !exists {
			return ledger.ClaimVipDailyCoinRebateReceipt{}, lookupErr
		}
		if rebate.WalletTransactionID != "" && rebate.WalletTransactionID != txRow.TransactionID {
			return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.LedgerConflict, "vip daily coin rebate transaction mismatch")
		}
		balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin)
		if balanceErr != nil {
			return ledger.ClaimVipDailyCoinRebateReceipt{}, balanceErr
		}
		rebate.Status = ledger.VipDailyCoinRebateStatusClaimed
		rebate.WalletTransactionID = txRow.TransactionID
		if err := tx.Commit(); err != nil {
			return ledger.ClaimVipDailyCoinRebateReceipt{}, err
		}
		return ledger.ClaimVipDailyCoinRebateReceipt{Rebate: rebate, TransactionID: txRow.TransactionID, CoinBalance: balance}, nil
	}
	if rebate.Status == ledger.VipDailyCoinRebateStatusClaimed || rebate.WalletTransactionID != "" {
		// 不同 command 的重复点击也返回原成功回执；返现本身才是唯一业务操作，
		// 不为第二个 command 伪造一笔钱包交易。若 command 已用于其他 payload，上面的幂等查询仍会拒绝。
		balance, balanceErr := r.balanceAfterTransaction(ctx, tx, rebate.WalletTransactionID, command.UserID, ledger.AssetCoin)
		if balanceErr != nil {
			return ledger.ClaimVipDailyCoinRebateReceipt{}, balanceErr
		}
		if err := tx.Commit(); err != nil {
			return ledger.ClaimVipDailyCoinRebateReceipt{}, err
		}
		return ledger.ClaimVipDailyCoinRebateReceipt{
			Rebate: rebate, TransactionID: rebate.WalletTransactionID, CoinBalance: balance,
		}, nil
	}
	// 领取窗严格为 [available_at_ms, expires_at_ms)：日切毫秒可领取，次日日切毫秒不可领取。
	if command.NowMS < rebate.AvailableAtMS {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.Conflict, "vip daily coin rebate is not available yet")
	}
	if command.NowMS >= rebate.ExpiresAtMS {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.VIPCoinRebateExpired, "vip daily coin rebate expired")
	}
	if rebate.CoinAmount <= 0 {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.LedgerConflict, "vip daily coin rebate amount is invalid")
	}

	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, command.NowMS)
	if err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	metadata := vipDailyCoinRebateMetadata{
		RebateID: rebate.RebateID, UserID: rebate.UserID, TaskDay: rebate.TaskDay,
		VipLevel: rebate.VipLevel, VipName: rebate.VipName, CoinAmount: rebate.CoinAmount,
		AvailableAtMS: rebate.AvailableAtMS, ExpiresAtMS: rebate.ExpiresAtMS,
		ConfigVersion: rebate.ConfigVersion, CreatedAtMS: command.NowMS,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVipDailyCoinRebate, requestHash, rebate.RebateID, metadata, command.NowMS); err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, rebate.CoinAmount, 0, command.NowMS); err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	balance := ledger.AssetBalance{
		AppCode: command.AppCode, UserID: command.UserID, AssetType: ledger.AssetCoin,
		AvailableAmount: account.AvailableAmount + rebate.CoinAmount,
		FrozenAmount:    account.FrozenAmount, Version: account.Version + 1, UpdatedAtMs: command.NowMS,
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID: transactionID, UserID: command.UserID, AssetType: ledger.AssetCoin,
		AvailableDelta: rebate.CoinAmount, AvailableAfter: balance.AvailableAmount,
		FrozenAfter: balance.FrozenAmount, CreatedAtMS: command.NowMS,
	}); err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	result, err := tx.ExecContext(ctx, `
		UPDATE vip_daily_coin_rebates
		SET status = ?, claimed_at_ms = ?, wallet_transaction_id = ?, updated_at_ms = ?
		WHERE app_code = ? AND rebate_id = ? AND user_id = ? AND status = ?`,
		ledger.VipDailyCoinRebateStatusClaimed, command.NowMS, transactionID, command.NowMS,
		command.AppCode, rebate.RebateID, command.UserID, ledger.VipDailyCoinRebateStatusClaimable,
	)
	if err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	updated, err := result.RowsAffected()
	if err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	if updated != 1 {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, xerr.New(xerr.LedgerConflict, "vip daily coin rebate claim conflict")
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin,
			rebate.CoinAmount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version,
			metadata, command.NowMS, bizTypeVipDailyCoinRebate),
	}); err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.ClaimVipDailyCoinRebateReceipt{}, err
	}
	rebate.Status = ledger.VipDailyCoinRebateStatusClaimed
	rebate.ClaimedAtMS = command.NowMS
	rebate.WalletTransactionID = transactionID
	rebate.UpdatedAtMS = command.NowMS
	return ledger.ClaimVipDailyCoinRebateReceipt{Rebate: rebate, TransactionID: transactionID, CoinBalance: balance}, nil
}

func (r *Repository) loadVipDailyCoinRebateLevelAmounts(ctx context.Context, tx *sql.Tx, levelCount int32) (map[int32]int64, error) {
	rows, err := tx.QueryContext(ctx, `
		SELECT level, COALESCE(CAST(metadata_json AS CHAR), '')
		FROM vip_level_benefits
		WHERE app_code = ? AND level BETWEEN 1 AND ? AND benefit_code = ? AND status = ?
		ORDER BY level ASC`,
		appcode.FromContext(ctx), levelCount, ledger.VipBenefitCodeDailyCoinRebate, ledger.VipStatusActive,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	amounts := make(map[int32]int64)
	for rows.Next() {
		var level int32
		var metadataJSON string
		if err := rows.Scan(&level, &metadataJSON); err != nil {
			return nil, err
		}
		amount, err := parseVipDailyCoinRebateAmount(metadataJSON)
		if err != nil {
			return nil, xerr.New(xerr.Conflict, fmt.Sprintf("vip level %d daily_coin_rebate metadata is invalid", level))
		}
		amounts[level] = amount
	}
	return amounts, rows.Err()
}

// listVipDailyCoinRebateCandidates 先按 membership 主键取固定大小的一页，再只为本页用户还原
// UTC 日切状态。不能在每一页对全 App history 做窗口排序；那会让日切批任务退化成重复全表扫描。
// 迁移前 history 新增的有效期字段为 0，这些行不是可信的日切快照。必须先对全部历史排序，
// 再判断边界最近一条是否完整；否则过滤后再排序会越过较新未知变更，错误复活较旧 VIP。
// 边界行不完整时改用首条后续 history.previous_* 或 current membership 安全兜底。
func (r *Repository) listVipDailyCoinRebateCandidates(ctx context.Context, tx *sql.Tx, dayStartMS int64, lastUserID int64, limit int) ([]vipDailyCoinRebateCandidate, error) {
	rows, err := tx.QueryContext(ctx, `
		WITH member_page AS (
		  SELECT user_id, level, name, started_at_ms, expires_at_ms
		  FROM user_vip_memberships
		  WHERE app_code = ? AND user_id > ?
		  ORDER BY user_id ASC
		  LIMIT ?
		), ranked_history AS (
		  SELECT h.user_id, h.new_level AS vip_level, h.new_expires_at_ms AS expires_at_ms,
		         ROW_NUMBER() OVER (PARTITION BY h.user_id ORDER BY h.created_at_ms DESC, h.history_id DESC) AS row_no
		  FROM user_vip_history h
		  INNER JOIN member_page p ON p.user_id = h.user_id
		  WHERE h.app_code = ? AND h.created_at_ms <= ?
		), ranked_after_history AS (
		  SELECT h.user_id, h.previous_level AS vip_level, h.previous_expires_at_ms AS expires_at_ms,
		         ROW_NUMBER() OVER (PARTITION BY h.user_id ORDER BY h.created_at_ms ASC, h.history_id ASC) AS row_no
		  FROM user_vip_history h
		  INNER JOIN member_page p ON p.user_id = h.user_id
		  WHERE h.app_code = ? AND h.created_at_ms > ?
		), boundary_memberships AS (
		  SELECT p.user_id,
		         COALESCE(h.vip_level,
		           CASE WHEN future.vip_level > 0 AND future.expires_at_ms > ? THEN future.vip_level
		                WHEN p.started_at_ms <= ? THEN p.level ELSE 0 END) AS vip_level,
		         COALESCE(h.expires_at_ms,
		           CASE WHEN future.vip_level > 0 AND future.expires_at_ms > ? THEN future.expires_at_ms
		                WHEN p.started_at_ms <= ? THEN p.expires_at_ms ELSE 0 END) AS expires_at_ms,
		         p.name AS membership_name
		  FROM member_page p
		  LEFT JOIN ranked_history h
		    ON h.user_id = p.user_id AND h.row_no = 1
		   AND h.vip_level > 0 AND h.expires_at_ms > 0
		  LEFT JOIN ranked_after_history future
		    ON future.user_id = p.user_id AND future.row_no = 1
		   AND future.vip_level > 0 AND future.expires_at_ms > 0
		)
		SELECT b.user_id, b.vip_level, COALESCE(v.name, b.membership_name, CONCAT('VIP', b.vip_level)), b.expires_at_ms
		FROM boundary_memberships b
		LEFT JOIN vip_levels v ON v.app_code = ? AND v.level = b.vip_level
		ORDER BY b.user_id ASC`,
		appcode.FromContext(ctx), lastUserID, limit,
		appcode.FromContext(ctx), dayStartMS,
		appcode.FromContext(ctx), dayStartMS,
		dayStartMS, dayStartMS, dayStartMS, dayStartMS,
		appcode.FromContext(ctx),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]vipDailyCoinRebateCandidate, 0, limit)
	for rows.Next() {
		var item vipDailyCoinRebateCandidate
		if err := rows.Scan(&item.UserID, &item.Level, &item.VipName, &item.ExpiresAtMS); err != nil {
			return nil, err
		}
		items = append(items, item)
	}
	return items, rows.Err()
}

func (r *Repository) findVipDailyCoinRebateRun(ctx context.Context, tx *sql.Tx, taskDay string, lock bool) (ledger.VipDailyCoinRebateRun, bool, error) {
	query := `
		SELECT run_id, task_day, day_start_ms, day_end_ms, config_version,
		       CAST(level_amounts_json AS CHAR), status, last_user_id, scanned_count,
		       created_count, created_at_ms, updated_at_ms, completed_at_ms
		FROM vip_daily_coin_rebate_runs
		WHERE app_code = ? AND task_day = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	var run ledger.VipDailyCoinRebateRun
	if err := tx.QueryRowContext(ctx, query, appcode.FromContext(ctx), taskDay).Scan(
		&run.RunID, &run.TaskDay, &run.DayStartMS, &run.DayEndMS, &run.ConfigVersion,
		&run.LevelAmountsJSON, &run.Status, &run.LastUserID, &run.ScannedCount,
		&run.CreatedCount, &run.CreatedAtMS, &run.UpdatedAtMS, &run.CompletedAtMS,
	); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return ledger.VipDailyCoinRebateRun{}, false, nil
		}
		return ledger.VipDailyCoinRebateRun{}, false, err
	}
	if err := json.Unmarshal([]byte(run.LevelAmountsJSON), &run.LevelAmounts); err != nil {
		return ledger.VipDailyCoinRebateRun{}, false, xerr.New(xerr.LedgerConflict, "vip daily coin rebate run snapshot is invalid")
	}
	return run, true, nil
}

func (r *Repository) updateVipDailyCoinRebateRun(ctx context.Context, tx *sql.Tx, run ledger.VipDailyCoinRebateRun) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE vip_daily_coin_rebate_runs
		SET status = ?, last_user_id = ?, scanned_count = ?, created_count = ?,
		    updated_at_ms = ?, completed_at_ms = ?
		WHERE app_code = ? AND task_day = ? AND run_id = ?`,
		run.Status, run.LastUserID, run.ScannedCount, run.CreatedCount,
		run.UpdatedAtMS, run.CompletedAtMS, appcode.FromContext(ctx), run.TaskDay, run.RunID,
	)
	return err
}

func vipDailyCoinRebateSelectSQL() string {
	return `
		SELECT rebate_id, user_id, task_day, vip_level, vip_name, coin_amount, status,
		       available_at_ms, expires_at_ms, config_version, claimed_at_ms,
		       wallet_transaction_id, created_at_ms, updated_at_ms
		FROM vip_daily_coin_rebates `
}

func scanVipDailyCoinRebate(scanner interface{ Scan(...any) error }) (ledger.VipDailyCoinRebate, error) {
	var rebate ledger.VipDailyCoinRebate
	err := scanner.Scan(
		&rebate.RebateID, &rebate.UserID, &rebate.TaskDay, &rebate.VipLevel,
		&rebate.VipName, &rebate.CoinAmount, &rebate.Status, &rebate.AvailableAtMS,
		&rebate.ExpiresAtMS, &rebate.ConfigVersion, &rebate.ClaimedAtMS,
		&rebate.WalletTransactionID, &rebate.CreatedAtMS, &rebate.UpdatedAtMS,
	)
	return rebate, err
}

func projectVipDailyCoinRebateStatus(rebate ledger.VipDailyCoinRebate, nowMS int64) ledger.VipDailyCoinRebate {
	if rebate.Status == ledger.VipDailyCoinRebateStatusClaimable && nowMS >= rebate.ExpiresAtMS {
		rebate.Status = ledger.VipDailyCoinRebateStatusExpired
	}
	return rebate
}

func parseVipDailyCoinRebateAmount(metadataJSON string) (int64, error) {
	decoder := json.NewDecoder(strings.NewReader(strings.TrimSpace(metadataJSON)))
	decoder.UseNumber()
	var metadata map[string]any
	if err := decoder.Decode(&metadata); err != nil || metadata == nil {
		return 0, errors.New("metadata_json must be an object")
	}
	raw, ok := metadata["coin_amount"]
	if !ok {
		return 0, errors.New("coin_amount is required")
	}
	number, ok := raw.(json.Number)
	if !ok {
		return 0, errors.New("coin_amount must be an integer")
	}
	amount, err := number.Int64()
	if err != nil || amount <= 0 {
		return 0, errors.New("coin_amount must be a positive int64")
	}
	return amount, nil
}

func vipDailyCoinRebateRunID(appCode string, taskDay string) string {
	return "vip_rebate_run_" + stableHash(appcode.Normalize(appCode)+"|"+taskDay)
}

func vipDailyCoinRebateID(appCode string, taskDay string, userID int64) string {
	return "vip_rebate_" + stableHash(fmt.Sprintf("%s|%s|%d", appcode.Normalize(appCode), taskDay, userID))
}

func vipDailyCoinRebateAvailableCommandID(taskDay string, userID int64) string {
	return "vip_rebate_available:" + taskDay + ":" + strconv.FormatInt(userID, 10)
}
