package mysql

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

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

const (
	bizTypeWithdrawalApply = "withdrawal_apply"
	bizTypeVIPPurchase     = "vip_purchase"
)

// GetWalletOverview 返回钱包首页摘要；开关放在 wallet-service 内，避免 gateway 硬编码账务能力。
func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) {
	balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin, ledger.AssetDiamond, ledger.AssetUSDBalance})
	if err != nil {
		return ledger.WalletOverview{}, err
	}
	return ledger.WalletOverview{
		Balances: balances,
		FeatureFlags: ledger.WalletFeatureFlags{
			RechargeEnabled:        true,
			DiamondExchangeEnabled: true,
			WithdrawEnabled:        true,
		},
	}, nil
}

// GetWalletValueSummary 返回我的页钱包卡片需要的最小金币余额。
func (r *Repository) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) {
	balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin})
	if err != nil {
		return ledger.WalletValueSummary{}, err
	}
	if len(balances) == 0 {
		return ledger.WalletValueSummary{}, nil
	}
	return ledger.WalletValueSummary{
		CoinAmount:  balances[0].AvailableAmount,
		UpdatedAtMS: balances[0].UpdatedAtMs,
	}, nil
}

// GetDiamondExchangeConfig 返回后台配置的钻石兑换规则，未配置时返回空列表而不是 gateway 默认值。
func (r *Repository) GetDiamondExchangeConfig(ctx context.Context, _ int64) ([]ledger.DiamondExchangeRule, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	rows, err := r.db.QueryContext(ctx, `
		SELECT exchange_type, from_asset_type, to_asset_type, from_amount, to_amount, status
		FROM wallet_diamond_exchange_rules
		WHERE app_code = ? AND status = 'active'
		ORDER BY sort_order ASC, rule_id ASC`,
		appcode.FromContext(ctx),
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	rules := make([]ledger.DiamondExchangeRule, 0)
	for rows.Next() {
		var rule ledger.DiamondExchangeRule
		var status string
		if err := rows.Scan(&rule.ExchangeType, &rule.FromAssetType, &rule.ToAssetType, &rule.FromAmount, &rule.ToAmount, &status); err != nil {
			return nil, err
		}
		rule.Enabled = status == "active"
		rules = append(rules, rule)
	}
	return rules, rows.Err()
}

// ListWalletTransactions 分页读取当前用户钱包分录，并带出交易业务类型。
func (r *Repository) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, 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)
	query.Page, query.PageSize = normalizePage(query.Page, query.PageSize)
	where := `WHERE e.app_code = ? AND e.user_id = ?`
	args := []any{query.AppCode, query.UserID}
	if strings.TrimSpace(query.AssetType) != "" {
		where += ` AND e.asset_type = ?`
		args = append(args, query.AssetType)
	}

	var total int64
	if err := r.db.QueryRowContext(ctx, `
		SELECT COUNT(*)
		FROM wallet_entries e
		`+where,
		args...,
	).Scan(&total); err != nil {
		return nil, 0, err
	}

	rows, err := r.db.QueryContext(ctx, `
		SELECT e.entry_id, e.transaction_id, wt.biz_type, e.asset_type,
		       e.available_delta, e.frozen_delta, e.available_after, e.frozen_after,
		       e.counterparty_user_id, e.room_id, e.created_at_ms
		FROM wallet_entries e
		JOIN wallet_transactions wt ON wt.app_code = e.app_code AND wt.transaction_id = e.transaction_id
		`+where+`
		ORDER BY e.created_at_ms DESC, e.entry_id DESC
		LIMIT ? OFFSET ?`,
		append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	items := make([]ledger.WalletTransaction, 0, query.PageSize)
	for rows.Next() {
		var item ledger.WalletTransaction
		if err := rows.Scan(
			&item.EntryID,
			&item.TransactionID,
			&item.BizType,
			&item.AssetType,
			&item.AvailableDelta,
			&item.FrozenDelta,
			&item.AvailableAfter,
			&item.FrozenAfter,
			&item.CounterpartyUserID,
			&item.RoomID,
			&item.CreatedAtMS,
		); err != nil {
			return nil, 0, err
		}
		items = append(items, item)
	}
	return items, total, rows.Err()
}

// ApplyWithdrawal 冻结 USD_BALANCE 并写待审核提现申请。后台审核和人工转账是后续独立命令。
func (r *Repository) ApplyWithdrawal(ctx context.Context, command ledger.WithdrawalCommand) (ledger.WithdrawalRequest, ledger.AssetBalance, error) {
	if r == nil || r.db == nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, 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.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := stableHash(fmt.Sprintf("withdrawal|%s|%d|%d|%s", command.AppCode, command.UserID, command.Amount, command.PayoutAccount))
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeWithdrawalApply); err != nil || exists {
		if err != nil || !exists {
			return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
		}
		withdrawal, balance, receiptErr := r.withdrawalReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
		return withdrawal, balance, receiptErr
	}

	nowMs := time.Now().UnixMilli()
	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetUSDBalance, false, nowMs)
	if err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	if account.AvailableAmount < command.Amount {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	withdrawalID := "wdr_" + stableHash(command.AppCode+"|"+command.CommandID)
	availableAfter := account.AvailableAmount - command.Amount
	frozenAfter := account.FrozenAmount + command.Amount
	metadata := map[string]any{
		"app_code":       command.AppCode,
		"withdrawal_id":  withdrawalID,
		"user_id":        command.UserID,
		"asset_type":     ledger.AssetUSDBalance,
		"amount":         command.Amount,
		"payout_account": command.PayoutAccount,
		"reason":         command.Reason,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeWithdrawalApply, requestHash, withdrawalID, metadata, nowMs); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, command.Amount, nowMs); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetUSDBalance,
		AvailableDelta: -command.Amount,
		FrozenDelta:    command.Amount,
		AvailableAfter: availableAfter,
		FrozenAfter:    frozenAfter,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	withdrawal := ledger.WithdrawalRequest{
		WithdrawalID:  withdrawalID,
		UserID:        command.UserID,
		AssetType:     ledger.AssetUSDBalance,
		Amount:        command.Amount,
		Status:        ledger.WithdrawalStatusPending,
		PayoutAccount: command.PayoutAccount,
		Reason:        command.Reason,
		CreatedAtMS:   nowMs,
		UpdatedAtMS:   nowMs,
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO wallet_withdrawal_requests (
		   app_code, withdrawal_id, command_id, user_id, asset_type, amount, status,
		   payout_account, reason, wallet_transaction_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode, withdrawal.WithdrawalID, command.CommandID, command.UserID, withdrawal.AssetType,
		withdrawal.Amount, withdrawal.Status, withdrawal.PayoutAccount, withdrawal.Reason, transactionID, nowMs, nowMs,
	); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetUSDBalance, -command.Amount, command.Amount, availableAfter, frozenAfter, metadata, nowMs),
	}); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}

	return withdrawal, ledger.AssetBalance{
		AppCode:         command.AppCode,
		UserID:          command.UserID,
		AssetType:       ledger.AssetUSDBalance,
		AvailableAmount: availableAfter,
		FrozenAmount:    frozenAfter,
		Version:         account.Version + 1,
		UpdatedAtMs:     nowMs,
	}, nil
}

func (r *Repository) withdrawalReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (ledger.WithdrawalRequest, ledger.AssetBalance, error) {
	row := tx.QueryRowContext(ctx, `
		SELECT withdrawal_id, user_id, asset_type, amount, status, payout_account, reason, created_at_ms, updated_at_ms
		FROM wallet_withdrawal_requests
		WHERE app_code = ? AND wallet_transaction_id = ?`,
		appcode.FromContext(ctx), transactionID,
	)
	var withdrawal ledger.WithdrawalRequest
	if err := row.Scan(&withdrawal.WithdrawalID, &withdrawal.UserID, &withdrawal.AssetType, &withdrawal.Amount, &withdrawal.Status, &withdrawal.PayoutAccount, &withdrawal.Reason, &withdrawal.CreatedAtMS, &withdrawal.UpdatedAtMS); err != nil {
		return ledger.WithdrawalRequest{}, ledger.AssetBalance{}, err
	}
	balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetUSDBalance)
	return withdrawal, balance, err
}

// GetMyVip 返回用户当前 VIP 状态；过期只影响 active 投影，不直接修改历史事实。
func (r *Repository) GetMyVip(ctx context.Context, userID int64) (ledger.UserVip, error) {
	if r == nil || r.db == nil {
		return ledger.UserVip{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	vip, err := r.queryUserVip(ctx, r.db, userID, time.Now().UnixMilli(), false)
	if errors.Is(err, sql.ErrNoRows) {
		return ledger.UserVip{UserID: userID}, nil
	}
	return vip, err
}

// ListVipPackages 返回当前会员和全部 active VIP 包。
func (r *Repository) ListVipPackages(ctx context.Context, userID int64) (ledger.UserVip, []ledger.VipLevel, error) {
	if r == nil || r.db == nil {
		return ledger.UserVip{}, nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	nowMs := time.Now().UnixMilli()
	current, err := r.GetMyVip(ctx, userID)
	if err != nil {
		return ledger.UserVip{}, nil, err
	}
	levels, err := r.listVipLevels(ctx, nowMs, current.Level)
	return current, levels, err
}

// PurchaseVip 在一个事务内完成金币扣费、VIP 状态更新、订单历史和资源组发放。
func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVipCommand) (ledger.PurchaseVipReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.PurchaseVipReceipt{}, 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.PurchaseVipReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := stableHash(fmt.Sprintf("vip_purchase|%s|%d|%d", command.AppCode, command.UserID, command.Level))
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeVIPPurchase); err != nil || exists {
		if err != nil || !exists {
			return ledger.PurchaseVipReceipt{}, err
		}
		return r.vipPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID)
	}

	nowMs := time.Now().UnixMilli()
	level, err := r.getVipLevelForUpdate(ctx, tx, command.Level)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if level.Status != ledger.VipStatusActive {
		return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPLevelDisabled, "vip level is disabled")
	}
	current, err := r.ensureUserVipForUpdate(ctx, tx, command.UserID, nowMs)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	previousLevel := int32(0)
	baseExpiresAt := nowMs
	startedAt := nowMs
	if current.Active {
		previousLevel = current.Level
		baseExpiresAt = current.ExpiresAtMS
		startedAt = current.StartedAtMS
		if current.Level > command.Level {
			return ledger.PurchaseVipReceipt{}, xerr.New(xerr.VIPDowngradeNotAllowed, "vip downgrade is not allowed")
		}
	}
	newExpiresAt := baseExpiresAt + level.DurationMS
	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if account.AvailableAmount < level.PriceCoin {
		return ledger.PurchaseVipReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	orderID := "vip_order_" + stableHash(command.AppCode+"|"+command.CommandID)
	coinBalanceAfter := account.AvailableAmount - level.PriceCoin
	metadata := map[string]any{
		"app_code":        command.AppCode,
		"order_id":        orderID,
		"user_id":         command.UserID,
		"target_level":    command.Level,
		"previous_level":  previousLevel,
		"price_coin":      level.PriceCoin,
		"expires_at_ms":   newExpiresAt,
		"balance_after":   coinBalanceAfter,
		"reward_group_id": level.RewardResourceGroupID,
		"duration_ms":     level.DurationMS,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeVIPPurchase, requestHash, orderID, metadata, nowMs); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, -level.PriceCoin, 0, nowMs); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: -level.PriceCoin,
		FrozenDelta:    0,
		AvailableAfter: coinBalanceAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	rewardItems, grantID, err := r.applyVipRewardGroup(ctx, tx, command, level, newExpiresAt, nowMs)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		UPDATE user_vip_memberships
		SET level = ?, name = ?, status = ?, started_at_ms = ?, expires_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND user_id = ?`,
		level.Level, level.Name, ledger.VipStatusActive, startedAt, newExpiresAt, nowMs, command.AppCode, command.UserID,
	); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO vip_purchase_orders (
		   app_code, order_id, command_id, user_id, target_level, previous_level, price_coin,
		   status, wallet_transaction_id, resource_grant_id, request_hash, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, 'succeeded', ?, ?, ?, ?, ?)`,
		command.AppCode, orderID, command.CommandID, command.UserID, command.Level, previousLevel,
		level.PriceCoin, transactionID, grantID, requestHash, nowMs, nowMs,
	); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	action := "purchase"
	vipEventType := "VipPurchased"
	if previousLevel == command.Level {
		action = "renew"
		vipEventType = "VipRenewed"
	} else if previousLevel > 0 && previousLevel < command.Level {
		action = "upgrade"
		vipEventType = "VipUpgraded"
	}
	if _, err := tx.ExecContext(ctx, `
		INSERT INTO user_vip_history (
		   app_code, user_id, action, previous_level, new_level, order_id, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?)`,
		command.AppCode, command.UserID, action, previousLevel, command.Level, orderID, nowMs,
	); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, metadata, nowMs),
		{
			EventID:        eventID(transactionID, vipEventType, command.UserID, "VIP"),
			EventType:      vipEventType,
			TransactionID:  transactionID,
			CommandID:      command.CommandID,
			UserID:         command.UserID,
			AssetType:      "VIP",
			AvailableDelta: 0,
			FrozenDelta:    0,
			Payload:        metadata,
			CreatedAtMS:    nowMs,
		},
	}); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}

	return ledger.PurchaseVipReceipt{
		OrderID:          orderID,
		TransactionID:    transactionID,
		Vip:              ledger.UserVip{UserID: command.UserID, Level: level.Level, Name: level.Name, Active: true, StartedAtMS: startedAt, ExpiresAtMS: newExpiresAt, UpdatedAtMS: nowMs},
		CoinSpent:        level.PriceCoin,
		CoinBalanceAfter: coinBalanceAfter,
		RewardItems:      rewardItems,
	}, nil
}

func (r *Repository) getVipLevelForUpdate(ctx context.Context, tx *sql.Tx, level int32) (ledger.VipLevel, error) {
	item, err := r.scanVipLevel(tx.QueryRowContext(ctx, `
		SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, sort_order
		FROM vip_levels
		WHERE app_code = ? AND level = ?
		FOR UPDATE`,
		appcode.FromContext(ctx), level,
	))
	if errors.Is(err, sql.ErrNoRows) {
		return ledger.VipLevel{}, xerr.New(xerr.VIPLevelNotFound, "vip level not found")
	}
	return item, err
}

func (r *Repository) listVipLevels(ctx context.Context, nowMs int64, currentLevel int32) ([]ledger.VipLevel, error) {
	rows, err := r.db.QueryContext(ctx, `
		SELECT level, name, status, price_coin, duration_ms, reward_resource_group_id, sort_order
		FROM vip_levels
		WHERE app_code = ? AND status = ?
		ORDER BY sort_order ASC, level ASC`,
		appcode.FromContext(ctx), ledger.VipStatusActive,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	levels := make([]ledger.VipLevel, 0)
	for rows.Next() {
		level, err := r.scanVipLevel(rows)
		if err != nil {
			return nil, err
		}
		level.CanPurchase = currentLevel == 0 || level.Level >= currentLevel
		level.RewardItems, err = r.vipRewardItems(ctx, level.RewardResourceGroupID, nowMs+level.DurationMS)
		if err != nil {
			return nil, err
		}
		levels = append(levels, level)
	}
	return levels, rows.Err()
}

func (r *Repository) scanVipLevel(scanner scanTarget) (ledger.VipLevel, error) {
	var level ledger.VipLevel
	err := scanner.Scan(&level.Level, &level.Name, &level.Status, &level.PriceCoin, &level.DurationMS, &level.RewardResourceGroupID, &level.SortOrder)
	return level, err
}

func (r *Repository) ensureUserVipForUpdate(ctx context.Context, tx *sql.Tx, userID int64, nowMs int64) (ledger.UserVip, error) {
	if _, err := tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_vip_memberships (
		   app_code, user_id, level, name, status, started_at_ms, expires_at_ms, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, 0, '', 'inactive', 0, 0, ?, ?)`,
		appcode.FromContext(ctx), userID, nowMs, nowMs,
	); err != nil {
		return ledger.UserVip{}, err
	}
	return r.queryUserVip(ctx, tx, userID, nowMs, true)
}

func (r *Repository) queryUserVip(ctx context.Context, querier interface {
	QueryRowContext(context.Context, string, ...any) *sql.Row
}, userID int64, nowMs int64, lock bool) (ledger.UserVip, error) {
	query := `
		SELECT m.user_id, m.level, COALESCE(v.name, m.name), m.started_at_ms, m.expires_at_ms, m.updated_at_ms
		FROM user_vip_memberships m
		LEFT JOIN vip_levels v ON v.app_code = m.app_code AND v.level = m.level
		WHERE m.app_code = ? AND m.user_id = ?`
	if lock {
		query += ` FOR UPDATE`
	}
	var vip ledger.UserVip
	if err := querier.QueryRowContext(ctx, query, appcode.FromContext(ctx), userID).Scan(&vip.UserID, &vip.Level, &vip.Name, &vip.StartedAtMS, &vip.ExpiresAtMS, &vip.UpdatedAtMS); err != nil {
		return ledger.UserVip{}, err
	}
	vip.Active = vip.Level > 0 && vip.ExpiresAtMS > nowMs
	if !vip.Active {
		vip.Level = 0
		vip.Name = ""
	}
	return vip, nil
}

func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, command ledger.PurchaseVipCommand, level ledger.VipLevel, expiresAtMS int64, nowMs int64) ([]ledger.VipRewardItem, string, error) {
	if level.RewardResourceGroupID <= 0 {
		return nil, "", nil
	}
	group, err := r.getResourceGroup(ctx, tx, level.RewardResourceGroupID, true)
	if err != nil {
		return nil, "", err
	}
	if group.Status != resourcedomain.StatusActive {
		return nil, "", xerr.New(xerr.Conflict, "vip reward group is disabled")
	}
	group.Items, err = r.listResourceGroupItemsTx(ctx, tx, level.RewardResourceGroupID, true)
	if err != nil {
		return nil, "", err
	}
	groupSnapshot, err := json.Marshal(group)
	if err != nil {
		return nil, "", err
	}
	rewardCommandID := "vip_reward:" + command.CommandID
	grantID := resourceGrantID(command.AppCode, rewardCommandID)
	if err := r.insertResourceGrant(ctx, tx, grantID, rewardCommandID, command.UserID, "vip_purchase", resourcedomain.GrantSubjectGroup, fmt.Sprintf("%d", level.RewardResourceGroupID), stableHash(string(groupSnapshot)), string(groupSnapshot), "vip purchase reward", command.UserID, nowMs); err != nil {
		return nil, "", err
	}
	durationMS := expiresAtMS - nowMs
	rewards := make([]ledger.VipRewardItem, 0, len(group.Items))
	for _, item := range group.Items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) == resourcedomain.GroupItemTypeWalletAsset {
			inserted, err := r.applyWalletAssetGrantItem(ctx, tx, grantID, rewardCommandID, command.UserID, item, nowMs)
			if err != nil {
				return nil, "", err
			}
			rewards = append(rewards, ledger.VipRewardItem{Quantity: inserted.Quantity, ExpiresAtMS: 0})
			continue
		}
		if err := validateActiveResourceGroupResource(item.Resource); err != nil {
			return nil, "", err
		}
		inserted, err := r.applyGrantItem(ctx, tx, grantID, rewardCommandID, command.UserID, item.Resource, item.Quantity, durationMS, nowMs)
		if err != nil {
			return nil, "", err
		}
		rewards = append(rewards, ledger.VipRewardItem{
			ResourceID:   item.Resource.ResourceID,
			ResourceCode: item.Resource.ResourceCode,
			ResourceType: item.Resource.ResourceType,
			Name:         item.Resource.Name,
			Quantity:     inserted.Quantity,
			ExpiresAtMS:  expiresAtMS,
		})
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		resourceOutboxEvent("ResourceGroupGranted", rewardCommandID, command.UserID, level.RewardResourceGroupID, map[string]any{"grant_id": grantID, "source": "vip_purchase"}, nowMs),
	}); err != nil {
		return nil, "", err
	}
	return rewards, grantID, nil
}

func (r *Repository) vipRewardItems(ctx context.Context, groupID int64, expiresAtMS int64) ([]ledger.VipRewardItem, error) {
	if groupID <= 0 {
		return nil, nil
	}
	items, err := r.listResourceGroupItemsWithQuery(ctx, r.db, groupID)
	if err != nil {
		return nil, err
	}
	rewards := make([]ledger.VipRewardItem, 0, len(items))
	for _, item := range items {
		if resourcedomain.NormalizeGroupItemType(item.ItemType) != resourcedomain.GroupItemTypeResource {
			continue
		}
		rewards = append(rewards, ledger.VipRewardItem{
			ResourceID:   item.Resource.ResourceID,
			ResourceCode: item.Resource.ResourceCode,
			ResourceType: item.Resource.ResourceType,
			Name:         item.Resource.Name,
			Quantity:     item.Quantity,
			ExpiresAtMS:  expiresAtMS,
		})
	}
	return rewards, nil
}

func (r *Repository) vipPurchaseReceiptForTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64) (ledger.PurchaseVipReceipt, error) {
	var orderID string
	var level int32
	var priceCoin int64
	var grantID string
	err := tx.QueryRowContext(ctx, `
		SELECT order_id, target_level, price_coin, resource_grant_id
		FROM vip_purchase_orders
		WHERE app_code = ? AND wallet_transaction_id = ?`,
		appcode.FromContext(ctx), transactionID,
	).Scan(&orderID, &level, &priceCoin, &grantID)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	vip, err := r.queryUserVip(ctx, tx, userID, time.Now().UnixMilli(), false)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	rewards, err := r.rewardItemsByGrant(ctx, tx, grantID, vip.ExpiresAtMS)
	if err != nil {
		return ledger.PurchaseVipReceipt{}, err
	}
	return ledger.PurchaseVipReceipt{
		OrderID:          orderID,
		TransactionID:    transactionID,
		Vip:              vip,
		CoinSpent:        priceCoin,
		CoinBalanceAfter: balance.AvailableAmount,
		RewardItems:      rewards,
	}, nil
}

func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID string, expiresAtMS int64) ([]ledger.VipRewardItem, error) {
	if grantID == "" {
		return nil, nil
	}
	rows, err := tx.QueryContext(ctx, `
		SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity
		FROM resource_grant_items i
		JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id
		WHERE i.app_code = ? AND i.grant_id = ? AND i.result_type = ?
		ORDER BY i.grant_item_id ASC`,
		appcode.FromContext(ctx), grantID, resourcedomain.ResultEntitlement,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	items := make([]ledger.VipRewardItem, 0)
	for rows.Next() {
		var item ledger.VipRewardItem
		if err := rows.Scan(&item.ResourceID, &item.ResourceCode, &item.ResourceType, &item.Name, &item.Quantity); err != nil {
			return nil, err
		}
		item.ExpiresAtMS = expiresAtMS
		items = append(items, item)
	}
	return items, rows.Err()
}
