package mysql

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"math/big"
	"sort"
	"strings"
	"time"

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

const (
	bizTypeRedPacketCreate = "red_packet_create"
	bizTypeRedPacketClaim  = "red_packet_claim"
	bizTypeRedPacketRefund = "red_packet_refund"

	eventTypeRedPacketCreated  = "WalletRedPacketCreated"
	eventTypeRedPacketClaimed  = "WalletRedPacketClaimed"
	eventTypeRedPacketRefunded = "WalletRedPacketRefunded"

	redPacketAllocationUnclaimed = "unclaimed"
	redPacketAllocationClaimed   = "claimed"
	redPacketAllocationExpired   = "expired"
)

type redPacketMetadata struct {
	AppCode           string `json:"app_code"`
	PacketID          string `json:"packet_id"`
	CommandID         string `json:"command_id"`
	SenderUserID      int64  `json:"sender_user_id"`
	RoomID            string `json:"room_id"`
	RegionID          int64  `json:"region_id"`
	PacketType        string `json:"packet_type"`
	TotalAmount       int64  `json:"total_amount"`
	PacketCount       int32  `json:"packet_count"`
	RemainingAmount   int64  `json:"remaining_amount"`
	RemainingCount    int32  `json:"remaining_count"`
	Status            string `json:"status"`
	OpenAtMS          int64  `json:"open_at_ms"`
	ExpiresAtMS       int64  `json:"expires_at_ms"`
	BalanceAfter      int64  `json:"balance_after"`
	WalletTransaction string `json:"wallet_transaction_id"`
	CreatedAtMS       int64  `json:"created_at_ms"`
}

type redPacketClaimMetadata struct {
	AppCode           string `json:"app_code"`
	ClaimID           string `json:"claim_id"`
	CommandID         string `json:"command_id"`
	PacketID          string `json:"packet_id"`
	UserID            int64  `json:"user_id"`
	Amount            int64  `json:"amount"`
	RoomID            string `json:"room_id"`
	RegionID          int64  `json:"region_id"`
	PacketType        string `json:"packet_type"`
	RemainingAmount   int64  `json:"remaining_amount"`
	RemainingCount    int32  `json:"remaining_count"`
	PacketStatus      string `json:"packet_status"`
	BalanceAfter      int64  `json:"balance_after"`
	WalletTransaction string `json:"wallet_transaction_id"`
	ClaimedAtMS       int64  `json:"claimed_at_ms"`
}

type redPacketRefundMetadata struct {
	AppCode           string `json:"app_code"`
	RefundID          string `json:"refund_id"`
	CommandID         string `json:"command_id"`
	PacketID          string `json:"packet_id"`
	SenderUserID      int64  `json:"sender_user_id"`
	RoomID            string `json:"room_id"`
	RegionID          int64  `json:"region_id"`
	PacketType        string `json:"packet_type"`
	RefundAmount      int64  `json:"refund_amount"`
	PacketStatus      string `json:"packet_status"`
	BalanceAfter      int64  `json:"balance_after"`
	WalletTransaction string `json:"wallet_transaction_id"`
	RefundedAtMS      int64  `json:"refunded_at_ms"`
}

type redPacketQuerier interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

// GetRedPacketConfig 返回红包配置；未配置时返回关闭状态和空档位，调用方不需要把 NotFound 当错误处理。
func (r *Repository) GetRedPacketConfig(ctx context.Context, app string) (ledger.RedPacketConfig, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacketConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, app)
	config, err := r.getRedPacketConfig(ctx, r.db)
	if err != nil {
		return ledger.RedPacketConfig{}, err
	}
	return config, nil
}

// UpdateRedPacketConfig 原子替换红包配置和档位；发送路径只读取 active 档位。
func (r *Repository) UpdateRedPacketConfig(ctx context.Context, config ledger.RedPacketConfig) (ledger.RedPacketConfig, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacketConfig{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, config.AppCode)
	config.AppCode = appcode.FromContext(ctx)
	nowMS := time.Now().UTC().UnixMilli()

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

	if _, err := tx.ExecContext(ctx, `
		INSERT INTO red_packet_configs (
			app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit, rule_url,
			updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			enabled = VALUES(enabled),
			delayed_open_seconds = VALUES(delayed_open_seconds),
			expire_seconds = VALUES(expire_seconds),
			daily_send_limit = VALUES(daily_send_limit),
			rule_url = VALUES(rule_url),
			updated_by_admin_id = VALUES(updated_by_admin_id),
			updated_at_ms = VALUES(updated_at_ms)`,
		config.AppCode,
		config.Enabled,
		config.DelayedOpenSeconds,
		ledger.RedPacketExpireSeconds,
		config.DailySendLimit,
		strings.TrimSpace(config.RuleURL),
		config.UpdatedByAdminID,
		nowMS,
		nowMS,
	); err != nil {
		return ledger.RedPacketConfig{}, err
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM red_packet_count_tiers WHERE app_code = ?`, config.AppCode); err != nil {
		return ledger.RedPacketConfig{}, err
	}
	for index, count := range config.CountTiers {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO red_packet_count_tiers (
				app_code, packet_count, status, sort_order, created_at_ms, updated_at_ms
			) VALUES (?, ?, 'active', ?, ?, ?)`,
			config.AppCode, count, index, nowMS, nowMS,
		); err != nil {
			return ledger.RedPacketConfig{}, err
		}
	}
	if _, err := tx.ExecContext(ctx, `DELETE FROM red_packet_amount_tiers WHERE app_code = ?`, config.AppCode); err != nil {
		return ledger.RedPacketConfig{}, err
	}
	for index, amount := range config.AmountTiers {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO red_packet_amount_tiers (
				app_code, total_amount, status, sort_order, created_at_ms, updated_at_ms
			) VALUES (?, ?, 'active', ?, ?, ?)`,
			config.AppCode, amount, index, nowMS, nowMS,
		); err != nil {
			return ledger.RedPacketConfig{}, err
		}
	}
	if err := tx.Commit(); err != nil {
		return ledger.RedPacketConfig{}, err
	}
	return r.GetRedPacketConfig(ctx, config.AppCode)
}

// CreateRedPacket 在同一事务内校验配置、扣 sender 金币、写随机份额和钱包 outbox。
func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPacketCreateCommand) (ledger.RedPacketCreateReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacketCreateReceipt{}, 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.RedPacketCreateReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := redPacketCreateRequestHash(command)
	if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeRedPacketCreate, xerr.RequestConflict); err != nil || exists {
		if err != nil || !exists {
			return ledger.RedPacketCreateReceipt{}, err
		}
		return r.redPacketCreateReceiptForTransaction(ctx, tx, txRow.TransactionID)
	}

	nowMS := time.Now().UTC().UnixMilli()
	config, err := r.getRedPacketConfig(ctx, tx)
	if err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if !config.Enabled {
		return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketDisabled, "red packet is disabled")
	}
	if !int64InSet(command.TotalAmount, config.AmountTiers) {
		return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketInvalidAmountTier, "red packet amount tier is invalid")
	}
	if !int32InSet(command.PacketCount, config.CountTiers) {
		return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketInvalidCountTier, "red packet count tier is invalid")
	}
	if command.TotalAmount < int64(command.PacketCount) {
		return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small")
	}
	if ledger.RedPacketExpireSeconds <= 0 || (command.PacketType == ledger.RedPacketTypeDelayed && ledger.RedPacketExpireSeconds <= config.DelayedOpenSeconds) {
		return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InvalidArgument, "red packet config time range is invalid")
	}
	if err := r.incrementRedPacketDailyCounter(ctx, tx, command.SenderUserID, config.DailySendLimit, nowMS); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}

	sender, err := r.lockAccount(ctx, tx, command.SenderUserID, ledger.AssetCoin, false, nowMS)
	if err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if sender.AvailableAmount < command.TotalAmount {
		return ledger.RedPacketCreateReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	allocations, err := splitRedPacketAmounts(command.TotalAmount, command.PacketCount)
	if err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}

	packetID := redPacketID(command.AppCode, command.CommandID)
	transactionID := transactionID(command.AppCode, command.CommandID)
	openAtMS := nowMS
	status := ledger.RedPacketStatusActive
	if command.PacketType == ledger.RedPacketTypeDelayed {
		openAtMS = nowMS + int64(config.DelayedOpenSeconds)*1000
		status = ledger.RedPacketStatusWaitingOpen
	}
	expiresAtMS := nowMS + int64(ledger.RedPacketExpireSeconds)*1000
	balanceAfter := sender.AvailableAmount - command.TotalAmount
	metadata := redPacketMetadata{
		AppCode:           command.AppCode,
		PacketID:          packetID,
		CommandID:         command.CommandID,
		SenderUserID:      command.SenderUserID,
		RoomID:            command.RoomID,
		RegionID:          command.RegionID,
		PacketType:        command.PacketType,
		TotalAmount:       command.TotalAmount,
		PacketCount:       command.PacketCount,
		RemainingAmount:   command.TotalAmount,
		RemainingCount:    command.PacketCount,
		Status:            status,
		OpenAtMS:          openAtMS,
		ExpiresAtMS:       expiresAtMS,
		BalanceAfter:      balanceAfter,
		WalletTransaction: transactionID,
		CreatedAtMS:       nowMS,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRedPacketCreate, requestHash, packetID, metadata, nowMS); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, sender, -command.TotalAmount, 0, nowMS); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.SenderUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: -command.TotalAmount,
		FrozenDelta:    0,
		AvailableAfter: balanceAfter,
		FrozenAfter:    sender.FrozenAmount,
		RoomID:         command.RoomID,
		CreatedAtMS:    nowMS,
	}); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if err := r.insertRedPacket(ctx, tx, metadata); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if err := r.insertRedPacketAllocations(ctx, tx, packetID, allocations, nowMS); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -command.TotalAmount, 0, balanceAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMS),
		redPacketCreatedEvent(transactionID, command.CommandID, metadata, nowMS),
	}); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	return ledger.RedPacketCreateReceipt{Packet: redPacketFromMetadata(metadata), BalanceAfter: balanceAfter}, nil
}

// ClaimRedPacket 领取一个未分配份额，并把金额原子入账到领取者 COIN 余额。
func (r *Repository) ClaimRedPacket(ctx context.Context, command ledger.RedPacketClaimCommand) (ledger.RedPacketClaimReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacketClaimReceipt{}, 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.RedPacketClaimReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := redPacketClaimRequestHash(command)
	if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeRedPacketClaim, xerr.RequestConflict); err != nil || exists {
		if err != nil || !exists {
			return ledger.RedPacketClaimReceipt{}, err
		}
		return r.redPacketClaimReceiptForTransaction(ctx, tx, txRow.TransactionID)
	}
	nowMS := time.Now().UTC().UnixMilli()
	packet, err := r.getRedPacketForUpdate(ctx, tx, command.PacketID)
	if err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if packet.Status == ledger.RedPacketStatusFinished || packet.RemainingCount <= 0 || packet.RemainingAmount <= 0 {
		return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketSoldOut, "red packet is sold out")
	}
	if packet.Status == ledger.RedPacketStatusRefunded || nowMS >= packet.ExpiresAtMS {
		return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is expired")
	}
	if nowMS < packet.OpenAtMS {
		return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.RedPacketNotOpen, "red packet is not open")
	}
	if existingClaim, claimed, err := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID); err != nil || claimed {
		if err != nil {
			return ledger.RedPacketClaimReceipt{}, err
		}
		return ledger.RedPacketClaimReceipt{Claim: existingClaim, Packet: packet}, nil
	}
	allocationNo, amount, err := r.lockOneRedPacketAllocation(ctx, tx, command.PacketID)
	if err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if amount <= 0 || amount > packet.RemainingAmount {
		return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet allocation is invalid")
	}

	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMS)
	if err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	claimID := redPacketClaimID(command.AppCode, command.CommandID)
	balanceAfter := account.AvailableAmount + amount
	metadata := redPacketClaimMetadata{
		AppCode:           command.AppCode,
		ClaimID:           claimID,
		CommandID:         command.CommandID,
		PacketID:          command.PacketID,
		UserID:            command.UserID,
		Amount:            amount,
		RoomID:            packet.RoomID,
		RegionID:          packet.RegionID,
		PacketType:        packet.PacketType,
		RemainingAmount:   packet.RemainingAmount - amount,
		RemainingCount:    packet.RemainingCount - 1,
		BalanceAfter:      balanceAfter,
		WalletTransaction: transactionID,
		ClaimedAtMS:       nowMS,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRedPacketClaim, requestHash, command.PacketID, metadata, nowMS); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, amount, 0, nowMS); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.UserID,
		AssetType:          ledger.AssetCoin,
		AvailableDelta:     amount,
		FrozenDelta:        0,
		AvailableAfter:     balanceAfter,
		FrozenAfter:        account.FrozenAmount,
		CounterpartyUserID: packet.SenderUserID,
		RoomID:             packet.RoomID,
		CreatedAtMS:        nowMS,
	}); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if err := r.insertRedPacketClaim(ctx, tx, metadata); err != nil {
		if isMySQLDuplicateError(err) {
			existingClaim, claimed, lookupErr := r.getUserRedPacketClaim(ctx, tx, command.PacketID, command.UserID)
			if lookupErr != nil {
				return ledger.RedPacketClaimReceipt{}, lookupErr
			}
			if claimed {
				return r.redPacketClaimReceiptForTransaction(ctx, tx, existingClaim.WalletTransactionID)
			}
			return ledger.RedPacketClaimReceipt{}, xerr.New(xerr.LedgerConflict, "red packet claim already exists")
		}
		return ledger.RedPacketClaimReceipt{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE red_packet_allocations
		SET status = ?, claimed_by_user_id = ?, claim_id = ?, claimed_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND packet_id = ? AND allocation_no = ? AND status = ?`,
		redPacketAllocationClaimed, command.UserID, claimID, nowMS, nowMS,
		appcode.FromContext(ctx), command.PacketID, allocationNo, redPacketAllocationUnclaimed,
	); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	newRemainingAmount := packet.RemainingAmount - amount
	newRemainingCount := packet.RemainingCount - 1
	newStatus := ledger.RedPacketStatusActive
	if newRemainingCount == 0 {
		newStatus = ledger.RedPacketStatusFinished
	}
	metadata.PacketStatus = newStatus
	if _, err := tx.ExecContext(ctx, `
		UPDATE red_packets
		SET remaining_amount = ?, remaining_count = ?, status = ?, updated_at_ms = ?
		WHERE app_code = ? AND packet_id = ?`,
		newRemainingAmount, newRemainingCount, newStatus, nowMS, appcode.FromContext(ctx), command.PacketID,
	); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS),
		redPacketClaimedEvent(transactionID, command.CommandID, metadata, nowMS),
	}); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	packet.RemainingAmount = newRemainingAmount
	packet.RemainingCount = newRemainingCount
	packet.Status = newStatus
	packet.UpdatedAtMS = nowMS
	return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: balanceAfter}, nil
}

func (r *Repository) ListRedPackets(ctx context.Context, query ledger.RedPacketQuery) ([]ledger.RedPacket, int64, error) {
	if r == nil || r.db == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, query.AppCode)
	query.AppCode = appcode.FromContext(ctx)
	if query.Page <= 0 {
		query.Page = 1
	}
	if query.PageSize <= 0 {
		query.PageSize = 20
	}
	if query.PageSize > 100 {
		query.PageSize = 100
	}
	where, args := redPacketWhereSQL(query)
	var total int64
	if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM red_packets `+where, 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, `
		SELECT app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type,
		       total_amount, packet_count, remaining_amount, remaining_count, status,
		       open_at_ms, expires_at_ms, created_at_ms, updated_at_ms
		FROM red_packets `+where+`
		ORDER BY created_at_ms DESC, packet_id DESC
		LIMIT ? OFFSET ?`, args...)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()
	packets := make([]ledger.RedPacket, 0, query.PageSize)
	for rows.Next() {
		packet, err := scanRedPacket(rows)
		if err != nil {
			return nil, 0, err
		}
		packets = append(packets, packet)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, err
	}
	if err := r.attachRefunds(ctx, packets); err != nil {
		return nil, 0, err
	}
	if query.ViewerUserID > 0 {
		if err := r.attachViewerClaims(ctx, packets, query.ViewerUserID); err != nil {
			return nil, 0, err
		}
	}
	return packets, total, nil
}

func (r *Repository) GetRedPacket(ctx context.Context, app string, packetID string, viewerUserID int64, includeClaims bool) (ledger.RedPacket, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacket{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, app)
	packet, err := r.getRedPacket(ctx, r.db, packetID)
	if err != nil {
		return ledger.RedPacket{}, err
	}
	if viewerUserID > 0 {
		packets := []ledger.RedPacket{packet}
		if err := r.attachViewerClaims(ctx, packets, viewerUserID); err != nil {
			return ledger.RedPacket{}, err
		}
		if err := r.attachRefunds(ctx, packets); err != nil {
			return ledger.RedPacket{}, err
		}
		packet = packets[0]
	} else {
		packets := []ledger.RedPacket{packet}
		if err := r.attachRefunds(ctx, packets); err != nil {
			return ledger.RedPacket{}, err
		}
		packet = packets[0]
	}
	if includeClaims {
		claims, err := r.listRedPacketClaims(ctx, packetID)
		if err != nil {
			return ledger.RedPacket{}, err
		}
		packet.Claims = claims
	}
	return packet, nil
}

func (r *Repository) ExpireRedPackets(ctx context.Context, app string, batchSize int32) (ledger.RedPacketExpireResult, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacketExpireResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, app)
	if batchSize <= 0 {
		batchSize = 50
	}
	if batchSize > 200 {
		batchSize = 200
	}
	nowMS := time.Now().UTC().UnixMilli()
	rows, err := r.db.QueryContext(ctx, `
		SELECT packet_id
		FROM red_packets
		WHERE app_code = ? AND status IN (?, ?) AND expires_at_ms <= ?
		ORDER BY expires_at_ms ASC, packet_id ASC
		LIMIT ?`,
		appcode.FromContext(ctx), ledger.RedPacketStatusActive, ledger.RedPacketStatusWaitingOpen, nowMS, batchSize,
	)
	if err != nil {
		return ledger.RedPacketExpireResult{}, err
	}
	packetIDs := make([]string, 0, batchSize)
	for rows.Next() {
		var packetID string
		if err := rows.Scan(&packetID); err != nil {
			_ = rows.Close()
			return ledger.RedPacketExpireResult{}, err
		}
		packetIDs = append(packetIDs, packetID)
	}
	if err := rows.Close(); err != nil {
		return ledger.RedPacketExpireResult{}, err
	}
	var result ledger.RedPacketExpireResult
	for _, packetID := range packetIDs {
		refunded, amount, err := r.expireOneRedPacket(ctx, packetID, nowMS)
		if err != nil {
			return result, err
		}
		if refunded {
			result.ExpiredCount++
			result.RefundedAmount += amount
		}
	}
	return result, nil
}

func (r *Repository) RetryRedPacketRefund(ctx context.Context, app string, packetID string, operatorUserID int64) (ledger.RedPacketRefundReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_ = operatorUserID
	ctx = contextWithCommandApp(ctx, app)
	nowMS := time.Now().UTC().UnixMilli()
	refunded, amount, err := r.expireOneRedPacket(ctx, strings.TrimSpace(packetID), nowMS)
	if err != nil {
		return ledger.RedPacketRefundReceipt{}, err
	}
	packet, err := r.GetRedPacket(ctx, appcode.FromContext(ctx), packetID, 0, false)
	if err != nil {
		return ledger.RedPacketRefundReceipt{}, err
	}
	if !refunded {
		if packet.Status == ledger.RedPacketStatusRefunded {
			return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: packet.RefundAmount}, nil
		}
		return ledger.RedPacketRefundReceipt{}, xerr.New(xerr.RedPacketExpired, "red packet is not expired")
	}
	return ledger.RedPacketRefundReceipt{Packet: packet, RefundedAmount: amount}, nil
}

func (r *Repository) expireOneRedPacket(ctx context.Context, packetID string, nowMS int64) (bool, int64, error) {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return false, 0, err
	}
	defer func() { _ = tx.Rollback() }()

	packet, err := r.getRedPacketForUpdate(ctx, tx, packetID)
	if err != nil {
		return false, 0, err
	}
	if packet.Status != ledger.RedPacketStatusActive && packet.Status != ledger.RedPacketStatusWaitingOpen {
		return false, 0, nil
	}
	if packet.ExpiresAtMS > nowMS {
		return false, 0, nil
	}
	if packet.RemainingAmount <= 0 || packet.RemainingCount <= 0 {
		if _, err := tx.ExecContext(ctx, `
			UPDATE red_packets
			SET status = ?, remaining_amount = 0, remaining_count = 0, updated_at_ms = ?
			WHERE app_code = ? AND packet_id = ?`,
			ledger.RedPacketStatusFinished, nowMS, appcode.FromContext(ctx), packetID,
		); err != nil {
			return false, 0, err
		}
		return true, 0, tx.Commit()
	}
	refundAmount := packet.RemainingAmount
	commandID := "red_packet_refund:" + packetID
	requestHash := stableHash(fmt.Sprintf("red_packet_refund|%s|%s|%d|%d", appcode.FromContext(ctx), packetID, packet.SenderUserID, refundAmount))
	if _, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizTypeRedPacketRefund, xerr.RequestConflict); err != nil || exists {
		return false, 0, err
	}
	account, err := r.lockAccount(ctx, tx, packet.SenderUserID, ledger.AssetCoin, true, nowMS)
	if err != nil {
		return false, 0, err
	}
	transactionID := transactionID(appcode.FromContext(ctx), commandID)
	refundID := redPacketRefundID(appcode.FromContext(ctx), packetID)
	balanceAfter := account.AvailableAmount + refundAmount
	metadata := redPacketRefundMetadata{
		AppCode:           appcode.FromContext(ctx),
		RefundID:          refundID,
		CommandID:         commandID,
		PacketID:          packetID,
		SenderUserID:      packet.SenderUserID,
		RoomID:            packet.RoomID,
		RegionID:          packet.RegionID,
		PacketType:        packet.PacketType,
		RefundAmount:      refundAmount,
		PacketStatus:      ledger.RedPacketStatusRefunded,
		BalanceAfter:      balanceAfter,
		WalletTransaction: transactionID,
		RefundedAtMS:      nowMS,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, commandID, bizTypeRedPacketRefund, requestHash, packetID, metadata, nowMS); err != nil {
		return false, 0, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, refundAmount, 0, nowMS); err != nil {
		return false, 0, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         packet.SenderUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: refundAmount,
		FrozenDelta:    0,
		AvailableAfter: balanceAfter,
		FrozenAfter:    account.FrozenAmount,
		RoomID:         packet.RoomID,
		CreatedAtMS:    nowMS,
	}); err != nil {
		return false, 0, err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO red_packet_refunds (
			app_code, refund_id, command_id, packet_id, sender_user_id, refund_amount,
			wallet_transaction_id, status, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, 'refunded', ?, ?)`,
		appcode.FromContext(ctx), refundID, commandID, packetID, packet.SenderUserID, refundAmount, transactionID, nowMS, nowMS,
	); err != nil {
		return false, 0, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE red_packet_allocations
		SET status = ?, updated_at_ms = ?
		WHERE app_code = ? AND packet_id = ? AND status = ?`,
		redPacketAllocationExpired, nowMS, appcode.FromContext(ctx), packetID, redPacketAllocationUnclaimed,
	); err != nil {
		return false, 0, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE red_packets
		SET status = ?, remaining_amount = 0, remaining_count = 0, updated_at_ms = ?
		WHERE app_code = ? AND packet_id = ?`,
		ledger.RedPacketStatusRefunded, nowMS, appcode.FromContext(ctx), packetID,
	); err != nil {
		return false, 0, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, commandID, packet.SenderUserID, ledger.AssetCoin, refundAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMS),
		redPacketRefundedEvent(transactionID, commandID, metadata, nowMS),
	}); err != nil {
		return false, 0, err
	}
	return true, refundAmount, tx.Commit()
}

func (r *Repository) getRedPacketConfig(ctx context.Context, querier redPacketQuerier) (ledger.RedPacketConfig, error) {
	row := querier.QueryRowContext(ctx, `
		SELECT app_code, enabled, delayed_open_seconds, expire_seconds, daily_send_limit,
		       updated_by_admin_id, COALESCE(rule_url, ''), created_at_ms, updated_at_ms
		FROM red_packet_configs
		WHERE app_code = ?`,
		appcode.FromContext(ctx),
	)
	config := ledger.RedPacketConfig{AppCode: appcode.FromContext(ctx), ExpireSeconds: ledger.RedPacketExpireSeconds}
	if err := row.Scan(
		&config.AppCode,
		&config.Enabled,
		&config.DelayedOpenSeconds,
		&config.ExpireSeconds,
		&config.DailySendLimit,
		&config.UpdatedByAdminID,
		&config.RuleURL,
		&config.CreatedAtMS,
		&config.UpdatedAtMS,
	); err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			return ledger.RedPacketConfig{}, err
		}
		return config, nil
	}
	config.ExpireSeconds = ledger.RedPacketExpireSeconds
	counts, err := r.listRedPacketCountTiers(ctx, querier)
	if err != nil {
		return ledger.RedPacketConfig{}, err
	}
	amounts, err := r.listRedPacketAmountTiers(ctx, querier)
	if err != nil {
		return ledger.RedPacketConfig{}, err
	}
	config.CountTiers = counts
	config.AmountTiers = amounts
	return config, nil
}

func (r *Repository) listRedPacketCountTiers(ctx context.Context, querier redPacketQuerier) ([]int32, error) {
	rows, err := querier.QueryContext(ctx, `
		SELECT packet_count
		FROM red_packet_count_tiers
		WHERE app_code = ? AND status = 'active'
		ORDER BY sort_order ASC, packet_count ASC`,
		appcode.FromContext(ctx),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]int32, 0)
	for rows.Next() {
		var value int32
		if err := rows.Scan(&value); err != nil {
			return nil, err
		}
		items = append(items, value)
	}
	return items, rows.Err()
}

func (r *Repository) listRedPacketAmountTiers(ctx context.Context, querier redPacketQuerier) ([]int64, error) {
	rows, err := querier.QueryContext(ctx, `
		SELECT total_amount
		FROM red_packet_amount_tiers
		WHERE app_code = ? AND status = 'active'
		ORDER BY sort_order ASC, total_amount ASC`,
		appcode.FromContext(ctx),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]int64, 0)
	for rows.Next() {
		var value int64
		if err := rows.Scan(&value); err != nil {
			return nil, err
		}
		items = append(items, value)
	}
	return items, rows.Err()
}

func (r *Repository) incrementRedPacketDailyCounter(ctx context.Context, tx *sql.Tx, userID int64, limit int32, nowMS int64) error {
	if limit <= 0 {
		return xerr.New(xerr.RedPacketDailyLimitReached, "red packet daily limit reached")
	}
	day := time.UnixMilli(nowMS).UTC().Format("2006-01-02")
	var current int32
	err := tx.QueryRowContext(ctx, `
		SELECT sent_count
		FROM red_packet_user_daily_counters
		WHERE app_code = ? AND user_id = ? AND send_day = ?
		FOR UPDATE`,
		appcode.FromContext(ctx), userID, day,
	).Scan(&current)
	if errors.Is(err, sql.ErrNoRows) {
		_, err = tx.ExecContext(ctx, `
			INSERT INTO red_packet_user_daily_counters (
				app_code, user_id, send_day, sent_count, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, 1, ?, ?)`,
			appcode.FromContext(ctx), userID, day, nowMS, nowMS,
		)
		return err
	}
	if err != nil {
		return err
	}
	if current >= limit {
		return xerr.New(xerr.RedPacketDailyLimitReached, "red packet daily limit reached")
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE red_packet_user_daily_counters
		SET sent_count = sent_count + 1, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ? AND send_day = ?`,
		nowMS, appcode.FromContext(ctx), userID, day,
	)
	return err
}

func (r *Repository) insertRedPacket(ctx context.Context, tx *sql.Tx, metadata redPacketMetadata) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO red_packets (
			app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type,
			total_amount, packet_count, remaining_amount, remaining_count, status,
			open_at_ms, expires_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx), metadata.PacketID, metadata.CommandID, metadata.SenderUserID, metadata.RoomID, metadata.RegionID,
		metadata.PacketType, metadata.TotalAmount, metadata.PacketCount, metadata.RemainingAmount, metadata.RemainingCount,
		metadata.Status, metadata.OpenAtMS, metadata.ExpiresAtMS, metadata.CreatedAtMS, metadata.CreatedAtMS,
	)
	return err
}

func (r *Repository) insertRedPacketAllocations(ctx context.Context, tx *sql.Tx, packetID string, allocations []int64, nowMS int64) error {
	for index, amount := range allocations {
		if _, err := tx.ExecContext(ctx, `
			INSERT INTO red_packet_allocations (
				app_code, packet_id, allocation_no, amount, status, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx), packetID, index+1, amount, redPacketAllocationUnclaimed, nowMS, nowMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) insertRedPacketClaim(ctx context.Context, tx *sql.Tx, metadata redPacketClaimMetadata) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO red_packet_claims (
			app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id,
			status, failure_reason, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`,
		appcode.FromContext(ctx), metadata.ClaimID, metadata.CommandID, metadata.PacketID, metadata.UserID,
		metadata.Amount, metadata.WalletTransaction, ledger.RedPacketClaimStatusClaimed, metadata.ClaimedAtMS, metadata.ClaimedAtMS,
	)
	return err
}

func (r *Repository) getRedPacketForUpdate(ctx context.Context, tx *sql.Tx, packetID string) (ledger.RedPacket, error) {
	row := tx.QueryRowContext(ctx, redPacketSelectSQL()+`
		WHERE app_code = ? AND packet_id = ?
		FOR UPDATE`,
		appcode.FromContext(ctx), packetID,
	)
	packet, err := scanRedPacket(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return ledger.RedPacket{}, xerr.New(xerr.NotFound, "red packet not found")
		}
		return ledger.RedPacket{}, err
	}
	return packet, nil
}

func (r *Repository) getRedPacket(ctx context.Context, querier sqlRowQuerier, packetID string) (ledger.RedPacket, error) {
	row := querier.QueryRowContext(ctx, redPacketSelectSQL()+`WHERE app_code = ? AND packet_id = ?`, appcode.FromContext(ctx), packetID)
	packet, err := scanRedPacket(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return ledger.RedPacket{}, xerr.New(xerr.NotFound, "red packet not found")
		}
		return ledger.RedPacket{}, err
	}
	return packet, nil
}

func redPacketSelectSQL() string {
	return `
		SELECT app_code, packet_id, command_id, sender_user_id, room_id, region_id, packet_type,
		       total_amount, packet_count, remaining_amount, remaining_count, status,
		       open_at_ms, expires_at_ms, created_at_ms, updated_at_ms
		FROM red_packets `
}

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

func scanRedPacket(row redPacketScanner) (ledger.RedPacket, error) {
	var packet ledger.RedPacket
	err := row.Scan(
		&packet.AppCode,
		&packet.PacketID,
		&packet.CommandID,
		&packet.SenderUserID,
		&packet.RoomID,
		&packet.RegionID,
		&packet.PacketType,
		&packet.TotalAmount,
		&packet.PacketCount,
		&packet.RemainingAmount,
		&packet.RemainingCount,
		&packet.Status,
		&packet.OpenAtMS,
		&packet.ExpiresAtMS,
		&packet.CreatedAtMS,
		&packet.UpdatedAtMS,
	)
	return packet, err
}

func (r *Repository) getUserRedPacketClaim(ctx context.Context, tx *sql.Tx, packetID string, userID int64) (ledger.RedPacketClaim, bool, error) {
	var claim ledger.RedPacketClaim
	err := tx.QueryRowContext(ctx, `
		SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id,
		       status, failure_reason, created_at_ms, updated_at_ms
		FROM red_packet_claims
		WHERE app_code = ? AND packet_id = ? AND user_id = ?
		LIMIT 1`,
		appcode.FromContext(ctx), packetID, userID,
	).Scan(
		&claim.AppCode,
		&claim.ClaimID,
		&claim.CommandID,
		&claim.PacketID,
		&claim.UserID,
		&claim.Amount,
		&claim.WalletTransactionID,
		&claim.Status,
		&claim.FailureReason,
		&claim.CreatedAtMS,
		&claim.UpdatedAtMS,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return ledger.RedPacketClaim{}, false, nil
	}
	return claim, err == nil, err
}

func (r *Repository) lockOneRedPacketAllocation(ctx context.Context, tx *sql.Tx, packetID string) (int32, int64, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT allocation_no, amount
		FROM red_packet_allocations
		WHERE app_code = ? AND packet_id = ? AND status = ?
		ORDER BY allocation_no ASC
		LIMIT 1
		FOR UPDATE`,
		appcode.FromContext(ctx), packetID, redPacketAllocationUnclaimed,
	)
	var allocationNo int32
	var amount int64
	if err := row.Scan(&allocationNo, &amount); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, 0, xerr.New(xerr.RedPacketSoldOut, "red packet is sold out")
		}
		return 0, 0, err
	}
	return allocationNo, amount, nil
}

func (r *Repository) attachViewerClaims(ctx context.Context, packets []ledger.RedPacket, viewerUserID int64) error {
	if len(packets) == 0 || viewerUserID <= 0 {
		return nil
	}
	packetIDs := make([]string, 0, len(packets))
	indexByID := make(map[string]int, len(packets))
	for index := range packets {
		packetIDs = append(packetIDs, packets[index].PacketID)
		indexByID[packets[index].PacketID] = index
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",")
	args := []any{appcode.FromContext(ctx), viewerUserID}
	for _, packetID := range packetIDs {
		args = append(args, packetID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT packet_id, amount
		FROM red_packet_claims
		WHERE app_code = ? AND user_id = ? AND packet_id IN (`+placeholders+`)`,
		args...,
	)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var packetID string
		var amount int64
		if err := rows.Scan(&packetID, &amount); err != nil {
			return err
		}
		if index, ok := indexByID[packetID]; ok {
			packets[index].ClaimedByViewer = true
			packets[index].ViewerClaimAmount = amount
		}
	}
	return rows.Err()
}

func (r *Repository) attachRefunds(ctx context.Context, packets []ledger.RedPacket) error {
	if len(packets) == 0 {
		return nil
	}
	packetIDs := make([]string, 0, len(packets))
	indexByID := make(map[string]int, len(packets))
	for index := range packets {
		packetIDs = append(packetIDs, packets[index].PacketID)
		indexByID[packets[index].PacketID] = index
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(packetIDs)), ",")
	args := []any{appcode.FromContext(ctx)}
	for _, packetID := range packetIDs {
		args = append(args, packetID)
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT packet_id, refund_amount, status, updated_at_ms
		FROM red_packet_refunds
		WHERE app_code = ? AND packet_id IN (`+placeholders+`)`,
		args...,
	)
	if err != nil {
		return err
	}
	defer rows.Close()
	for rows.Next() {
		var packetID string
		var amount int64
		var status string
		var updatedAtMS int64
		if err := rows.Scan(&packetID, &amount, &status, &updatedAtMS); err != nil {
			return err
		}
		if index, ok := indexByID[packetID]; ok {
			packets[index].RefundAmount = amount
			packets[index].RefundStatus = status
			if status == ledger.RedPacketStatusRefunded {
				packets[index].RefundedAtMS = updatedAtMS
			}
		}
	}
	return rows.Err()
}

func (r *Repository) listRedPacketClaims(ctx context.Context, packetID string) ([]ledger.RedPacketClaim, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT app_code, claim_id, command_id, packet_id, user_id, amount, wallet_transaction_id,
		       status, failure_reason, created_at_ms, updated_at_ms
		FROM red_packet_claims
		WHERE app_code = ? AND packet_id = ?
		ORDER BY created_at_ms ASC, claim_id ASC`,
		appcode.FromContext(ctx), packetID,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	claims := make([]ledger.RedPacketClaim, 0)
	for rows.Next() {
		var claim ledger.RedPacketClaim
		if err := rows.Scan(
			&claim.AppCode,
			&claim.ClaimID,
			&claim.CommandID,
			&claim.PacketID,
			&claim.UserID,
			&claim.Amount,
			&claim.WalletTransactionID,
			&claim.Status,
			&claim.FailureReason,
			&claim.CreatedAtMS,
			&claim.UpdatedAtMS,
		); err != nil {
			return nil, err
		}
		claims = append(claims, claim)
	}
	return claims, rows.Err()
}

func (r *Repository) redPacketCreateReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RedPacketCreateReceipt, error) {
	var raw string
	if err := tx.QueryRowContext(ctx, `
		SELECT COALESCE(CAST(metadata_json AS CHAR), '{}')
		FROM wallet_transactions
		WHERE app_code = ? AND transaction_id = ?`,
		appcode.FromContext(ctx), transactionID,
	).Scan(&raw); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	var metadata redPacketMetadata
	if err := json.Unmarshal([]byte(raw), &metadata); err != nil {
		return ledger.RedPacketCreateReceipt{}, err
	}
	packet, err := r.getRedPacket(ctx, tx, metadata.PacketID)
	if err != nil {
		packet = redPacketFromMetadata(metadata)
	}
	return ledger.RedPacketCreateReceipt{Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil
}

func (r *Repository) redPacketClaimReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RedPacketClaimReceipt, error) {
	var raw string
	if err := tx.QueryRowContext(ctx, `
		SELECT COALESCE(CAST(metadata_json AS CHAR), '{}')
		FROM wallet_transactions
		WHERE app_code = ? AND transaction_id = ?`,
		appcode.FromContext(ctx), transactionID,
	).Scan(&raw); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	var metadata redPacketClaimMetadata
	if err := json.Unmarshal([]byte(raw), &metadata); err != nil {
		return ledger.RedPacketClaimReceipt{}, err
	}
	packet, err := r.getRedPacket(ctx, tx, metadata.PacketID)
	if err != nil {
		return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), BalanceAfter: metadata.BalanceAfter}, nil
	}
	return ledger.RedPacketClaimReceipt{Claim: redPacketClaimFromMetadata(metadata), Packet: packet, BalanceAfter: metadata.BalanceAfter}, nil
}

func redPacketFromMetadata(metadata redPacketMetadata) ledger.RedPacket {
	return ledger.RedPacket{
		AppCode:         metadata.AppCode,
		PacketID:        metadata.PacketID,
		CommandID:       metadata.CommandID,
		SenderUserID:    metadata.SenderUserID,
		RoomID:          metadata.RoomID,
		RegionID:        metadata.RegionID,
		PacketType:      metadata.PacketType,
		TotalAmount:     metadata.TotalAmount,
		PacketCount:     metadata.PacketCount,
		RemainingAmount: metadata.RemainingAmount,
		RemainingCount:  metadata.RemainingCount,
		Status:          metadata.Status,
		OpenAtMS:        metadata.OpenAtMS,
		ExpiresAtMS:     metadata.ExpiresAtMS,
		CreatedAtMS:     metadata.CreatedAtMS,
		UpdatedAtMS:     metadata.CreatedAtMS,
	}
}

func redPacketClaimFromMetadata(metadata redPacketClaimMetadata) ledger.RedPacketClaim {
	return ledger.RedPacketClaim{
		AppCode:             metadata.AppCode,
		ClaimID:             metadata.ClaimID,
		CommandID:           metadata.CommandID,
		PacketID:            metadata.PacketID,
		UserID:              metadata.UserID,
		Amount:              metadata.Amount,
		WalletTransactionID: metadata.WalletTransaction,
		Status:              ledger.RedPacketClaimStatusClaimed,
		CreatedAtMS:         metadata.ClaimedAtMS,
		UpdatedAtMS:         metadata.ClaimedAtMS,
	}
}

func redPacketWhereSQL(query ledger.RedPacketQuery) (string, []any) {
	where := "WHERE app_code = ?"
	args := []any{appcode.Normalize(query.AppCode)}
	if strings.TrimSpace(query.RoomID) != "" {
		where += " AND room_id = ?"
		args = append(args, strings.TrimSpace(query.RoomID))
	}
	if query.SenderUserID > 0 {
		where += " AND sender_user_id = ?"
		args = append(args, query.SenderUserID)
	}
	if query.RegionID > 0 {
		where += " AND region_id = ?"
		args = append(args, query.RegionID)
	}
	if query.PacketType != "" {
		where += " AND packet_type = ?"
		args = append(args, query.PacketType)
	}
	if query.Status != "" {
		where += " AND status = ?"
		args = append(args, query.Status)
	}
	if query.StartAtMS > 0 {
		where += " AND created_at_ms >= ?"
		args = append(args, query.StartAtMS)
	}
	if query.EndAtMS > 0 {
		where += " AND created_at_ms < ?"
		args = append(args, query.EndAtMS)
	}
	return where, args
}

func redPacketCreatedEvent(transactionID string, commandID string, metadata redPacketMetadata, nowMS int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, eventTypeRedPacketCreated, metadata.SenderUserID, ledger.AssetCoin),
		EventType:      eventTypeRedPacketCreated,
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         metadata.SenderUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: -metadata.TotalAmount,
		FrozenDelta:    0,
		Payload: map[string]any{
			"transaction_id":   transactionID,
			"command_id":       commandID,
			"packet_id":        metadata.PacketID,
			"packet_type":      metadata.PacketType,
			"sender_user_id":   metadata.SenderUserID,
			"room_id":          metadata.RoomID,
			"region_id":        metadata.RegionID,
			"total_amount":     metadata.TotalAmount,
			"packet_count":     metadata.PacketCount,
			"remaining_amount": metadata.RemainingAmount,
			"remaining_count":  metadata.RemainingCount,
			"status":           metadata.Status,
			"open_at_ms":       metadata.OpenAtMS,
			"expires_at_ms":    metadata.ExpiresAtMS,
			"created_at_ms":    metadata.CreatedAtMS,
		},
		CreatedAtMS: nowMS,
	}
}

func redPacketClaimedEvent(transactionID string, commandID string, metadata redPacketClaimMetadata, nowMS int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, eventTypeRedPacketClaimed, metadata.UserID, ledger.AssetCoin),
		EventType:      eventTypeRedPacketClaimed,
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         metadata.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: metadata.Amount,
		FrozenDelta:    0,
		Payload:        metadata,
		CreatedAtMS:    nowMS,
	}
}

func redPacketRefundedEvent(transactionID string, commandID string, metadata redPacketRefundMetadata, nowMS int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, eventTypeRedPacketRefunded, metadata.SenderUserID, ledger.AssetCoin),
		EventType:      eventTypeRedPacketRefunded,
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         metadata.SenderUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: metadata.RefundAmount,
		FrozenDelta:    0,
		Payload:        metadata,
		CreatedAtMS:    nowMS,
	}
}

func redPacketCreateRequestHash(command ledger.RedPacketCreateCommand) string {
	return stableHash(fmt.Sprintf("red_packet_create|%s|%s|%d|%s|%d|%s|%d|%d",
		appcode.Normalize(command.AppCode),
		command.CommandID,
		command.SenderUserID,
		strings.TrimSpace(command.RoomID),
		command.RegionID,
		command.PacketType,
		command.TotalAmount,
		command.PacketCount,
	))
}

func redPacketClaimRequestHash(command ledger.RedPacketClaimCommand) string {
	return stableHash(fmt.Sprintf("red_packet_claim|%s|%s|%d",
		appcode.Normalize(command.AppCode),
		strings.TrimSpace(command.PacketID),
		command.UserID,
	))
}

func redPacketID(app string, commandID string) string {
	return "rp_" + stableHash(appcode.Normalize(app)+"|"+commandID)
}

func redPacketClaimID(app string, commandID string) string {
	return "rpc_" + stableHash(appcode.Normalize(app)+"|"+commandID)
}

func redPacketRefundID(app string, packetID string) string {
	return "rpr_" + stableHash(appcode.Normalize(app)+"|"+packetID)
}

func int64InSet(value int64, values []int64) bool {
	for _, item := range values {
		if item == value {
			return true
		}
	}
	return false
}

func int32InSet(value int32, values []int32) bool {
	for _, item := range values {
		if item == value {
			return true
		}
	}
	return false
}

func splitRedPacketAmounts(total int64, count int32) ([]int64, error) {
	if total < int64(count) || count <= 0 {
		return nil, xerr.New(xerr.RedPacketAmountTooSmall, "red packet amount is too small")
	}
	remainingAmount := total
	remainingCount := int64(count)
	amounts := make([]int64, 0, count)
	for remainingCount > 0 {
		if remainingCount == 1 {
			amounts = append(amounts, remainingAmount)
			break
		}
		maxAmount := remainingAmount - (remainingCount - 1)
		avg := remainingAmount / remainingCount
		drawMax := avg * 2
		if drawMax < 1 {
			drawMax = 1
		}
		if drawMax > maxAmount {
			drawMax = maxAmount
		}
		drawn, err := secureRandomInt64(1, drawMax)
		if err != nil {
			return nil, err
		}
		amounts = append(amounts, drawn)
		remainingAmount -= drawn
		remainingCount--
	}
	if err := shuffleInt64(amounts); err != nil {
		return nil, err
	}
	return amounts, nil
}

func secureRandomInt64(min int64, max int64) (int64, error) {
	if min > max {
		return 0, xerr.New(xerr.InvalidArgument, "random range is invalid")
	}
	span := big.NewInt(max - min + 1)
	value, err := rand.Int(rand.Reader, span)
	if err != nil {
		return 0, err
	}
	return min + value.Int64(), nil
}

func shuffleInt64(values []int64) error {
	for i := len(values) - 1; i > 0; i-- {
		j, err := secureRandomInt64(0, int64(i))
		if err != nil {
			return err
		}
		values[i], values[int(j)] = values[int(j)], values[i]
	}
	return nil
}

func normalizeRedPacketConfig(config ledger.RedPacketConfig) ledger.RedPacketConfig {
	config.AppCode = appcode.Normalize(config.AppCode)
	config.CountTiers = normalizeRedPacketCountTiers(config.CountTiers)
	config.AmountTiers = normalizeRedPacketAmountTiers(config.AmountTiers)
	return config
}

func normalizeRedPacketCountTiers(values []int32) []int32 {
	seen := make(map[int32]bool, len(values))
	out := make([]int32, 0, len(values))
	for _, value := range values {
		if value <= 0 || seen[value] {
			continue
		}
		seen[value] = true
		out = append(out, value)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}

func normalizeRedPacketAmountTiers(values []int64) []int64 {
	seen := make(map[int64]bool, len(values))
	out := make([]int64, 0, len(values))
	for _, value := range values {
		if value <= 0 || seen[value] {
			continue
		}
		seen[value] = true
		out = append(out, value)
	}
	sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
	return out
}
