package mysql

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

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

	mysqlDriver "github.com/go-sql-driver/mysql"
)

const (
	transactionStatusSucceeded        = "succeeded"
	bizTypeGiftDebit                  = "gift_debit"
	bizTypeManualCredit               = "manual_credit"
	bizTypeTaskReward                 = "task_reward"
	bizTypeCoinSellerTransfer         = "coin_seller_transfer"
	bizTypeCoinSellerStockPurchase    = "coin_seller_stock_purchase"
	bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
	bizTypeGameDebit                  = "game_debit"
	bizTypeGameCredit                 = "game_credit"
	bizTypeGameRefund                 = "game_refund"
	bizTypeGameReverse                = "game_reverse"
	outboxStatusPending               = "pending"
)

// Repository 是 wallet-service 的 MySQL v2 账本入口。
type Repository struct {
	// db 是账户、交易、分录和 outbox 的同一事务边界。
	db *sql.DB
}

// Open 创建 MySQL 连接池并做启动 ping。
func Open(ctx context.Context, dsn string) (*Repository, error) {
	if strings.TrimSpace(dsn) == "" {
		return nil, xerr.New(xerr.InvalidArgument, "mysql_dsn is required")
	}

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, err
	}

	if err := db.PingContext(ctx); err != nil {
		_ = db.Close()
		return nil, err
	}

	return &Repository{db: db}, nil
}

// Close 释放 MySQL 连接池。
func (r *Repository) Close() error {
	if r == nil || r.db == nil {
		return nil
	}

	return r.db.Close()
}

// Ping 验证 MySQL 当前可用。
func (r *Repository) Ping(ctx context.Context) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}

	return r.db.PingContext(ctx)
}

// DebitGift 在一个 MySQL 事务内完成 sender COIN 扣减、target GIFT_POINT 入账、分录和 outbox。
func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
	if r == nil || r.db == nil {
		return ledger.Receipt{}, 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.Receipt{}, err
	}
	defer func() {
		// Commit 成功后 Rollback 会返回 sql.ErrTxDone；忽略它可保证所有早退路径释放事务。
		_ = tx.Rollback()
	}()

	requestHash := debitRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeGiftDebit); err != nil || exists {
		if err != nil || !exists {
			return ledger.Receipt{}, err
		}
		receipt, receiptErr := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID)
		return receipt, receiptErr
	}

	nowMs := time.Now().UnixMilli()
	if command.RegionID < 0 {
		return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid")
	}
	giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID)
	if err != nil {
		return ledger.Receipt{}, err
	}
	price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs)
	if err != nil {
		return ledger.Receipt{}, err
	}
	chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
	if err != nil {
		return ledger.Receipt{}, err
	}
	giftPointAdded, err := checkedMul(price.GiftPointAmount, int64(command.GiftCount))
	if err != nil {
		return ledger.Receipt{}, err
	}
	heatValue, err := checkedMul(price.HeatValue, int64(command.GiftCount))
	if err != nil {
		return ledger.Receipt{}, err
	}

	sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, false, nowMs)
	if err != nil {
		return ledger.Receipt{}, err
	}
	if sender.AvailableAmount < chargeAmount {
		return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetGiftPoint, true, nowMs)
	if err != nil {
		return ledger.Receipt{}, err
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	metadata := giftMetadata{
		AppCode:          command.AppCode,
		GiftID:           command.GiftID,
		GiftName:         giftConfig.Name,
		ResourceID:       giftConfig.ResourceID,
		ResourceSnapshot: mustJSON(giftConfig.Resource),
		GiftCount:        command.GiftCount,
		PriceVersion:     price.PriceVersion,
		ChargeAssetType:  price.ChargeAssetType,
		ChargeAmount:     chargeAmount,
		CoinSpent:        chargeAmount,
		GiftPointAdded:   giftPointAdded,
		HeatValue:        heatValue,
		BalanceAfter:     sender.AvailableAmount - chargeAmount,
		BillingReceipt:   billingReceiptID(command.AppCode, command.CommandID),
		SenderUserID:     command.SenderUserID,
		TargetUserID:     command.TargetUserID,
		RoomID:           command.RoomID,
		CoinPrice:        price.CoinPrice,
		GiftPointAmount:  price.GiftPointAmount,
		HeatUnitValue:    price.HeatValue,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
		return ledger.Receipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
		return ledger.Receipt{}, err
	}
	senderAfter := sender.AvailableAmount - chargeAmount
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.SenderUserID,
		AssetType:          price.ChargeAssetType,
		AvailableDelta:     -chargeAmount,
		FrozenDelta:        0,
		AvailableAfter:     senderAfter,
		FrozenAfter:        sender.FrozenAmount,
		CounterpartyUserID: command.TargetUserID,
		RoomID:             command.RoomID,
		CreatedAtMS:        nowMs,
	}); err != nil {
		return ledger.Receipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, target, giftPointAdded, 0, nowMs); err != nil {
		return ledger.Receipt{}, err
	}
	targetAfter := target.AvailableAmount + giftPointAdded
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.TargetUserID,
		AssetType:          ledger.AssetGiftPoint,
		AvailableDelta:     giftPointAdded,
		FrozenDelta:        0,
		AvailableAfter:     targetAfter,
		FrozenAfter:        target.FrozenAmount,
		CounterpartyUserID: command.SenderUserID,
		RoomID:             command.RoomID,
		CreatedAtMS:        nowMs,
	}); err != nil {
		return ledger.Receipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, metadata, nowMs),
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetGiftPoint, giftPointAdded, 0, targetAfter, target.FrozenAmount, metadata, nowMs),
		giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs),
	}); err != nil {
		return ledger.Receipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.Receipt{}, err
	}

	return receiptFromGiftMetadata(transactionID, metadata), nil
}

// GetBalances 读取用户多资产余额；指定资产不存在时返回 0 投影，便于客户端稳定渲染。
func (r *Repository) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	assetTypes = normalizeAssetTypes(assetTypes)
	appCode := appcode.FromContext(ctx)

	query := `SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms
		FROM wallet_accounts WHERE app_code = ? AND user_id = ?`
	args := []any{appCode, userID}
	if len(assetTypes) > 0 {
		placeholders := strings.TrimRight(strings.Repeat("?,", len(assetTypes)), ",")
		query += " AND asset_type IN (" + placeholders + ")"
		for _, assetType := range assetTypes {
			args = append(args, assetType)
		}
	}
	query += " ORDER BY asset_type"

	rows, err := r.db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	byAsset := make(map[string]ledger.AssetBalance)
	for rows.Next() {
		var balance ledger.AssetBalance
		if err := rows.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount, &balance.Version, &balance.UpdatedAtMs); err != nil {
			return nil, err
		}
		byAsset[balance.AssetType] = balance
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	if len(assetTypes) == 0 {
		balances := make([]ledger.AssetBalance, 0, len(byAsset))
		for _, balance := range byAsset {
			balances = append(balances, balance)
		}
		sort.Slice(balances, func(i, j int) bool { return balances[i].AssetType < balances[j].AssetType })
		return balances, nil
	}

	balances := make([]ledger.AssetBalance, 0, len(assetTypes))
	for _, assetType := range assetTypes {
		if balance, ok := byAsset[assetType]; ok {
			balances = append(balances, balance)
			continue
		}
		balances = append(balances, ledger.AssetBalance{AppCode: appCode, UserID: userID, AssetType: assetType})
	}
	return balances, nil
}

// AdminCreditAsset 在一个事务内写入后台入账交易、分录和 outbox。
func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
	if r == nil || r.db == nil {
		return 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.AssetBalance{}, "", err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := adminCreditRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeManualCredit); err != nil || exists {
		if err != nil || !exists {
			return ledger.AssetBalance{}, "", err
		}
		balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.TargetUserID, command.AssetType)
		return balance, txRow.TransactionID, balanceErr
	}

	nowMs := time.Now().UnixMilli()
	account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, true, nowMs)
	if err != nil {
		return ledger.AssetBalance{}, "", err
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	metadata := adminCreditMetadata{
		AppCode:        command.AppCode,
		TargetUserID:   command.TargetUserID,
		AssetType:      command.AssetType,
		Amount:         command.Amount,
		OperatorUserID: command.OperatorUserID,
		Reason:         command.Reason,
		EvidenceRef:    command.EvidenceRef,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeManualCredit, requestHash, command.EvidenceRef, metadata, nowMs); err != nil {
		return ledger.AssetBalance{}, "", err
	}
	if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
		return ledger.AssetBalance{}, "", err
	}
	balance := ledger.AssetBalance{
		AppCode:         command.AppCode,
		UserID:          command.TargetUserID,
		AssetType:       command.AssetType,
		AvailableAmount: account.AvailableAmount + command.Amount,
		FrozenAmount:    account.FrozenAmount,
		Version:         account.Version + 1,
		UpdatedAtMs:     nowMs,
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.TargetUserID,
		AssetType:      command.AssetType,
		AvailableDelta: command.Amount,
		FrozenDelta:    0,
		AvailableAfter: balance.AvailableAmount,
		FrozenAfter:    balance.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.AssetBalance{}, "", err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, metadata, nowMs),
	}); err != nil {
		return ledger.AssetBalance{}, "", err
	}
	if err := tx.Commit(); err != nil {
		return ledger.AssetBalance{}, "", err
	}

	return balance, transactionID, nil
}

// CreditTaskReward 在一个事务内完成任务奖励 COIN 入账、交易分录和 outbox 事件。
func (r *Repository) CreditTaskReward(ctx context.Context, command ledger.TaskRewardCommand) (ledger.TaskRewardReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.TaskRewardReceipt{}, 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.TaskRewardReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := taskRewardRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeTaskReward); err != nil || exists {
		if err != nil || !exists {
			return ledger.TaskRewardReceipt{}, err
		}
		return r.receiptForTaskRewardTransaction(ctx, tx, txRow.TransactionID)
	}

	nowMs := time.Now().UnixMilli()
	account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
	if err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	balanceAfter := account.AvailableAmount + command.Amount
	metadata := taskRewardMetadata{
		AppCode:      command.AppCode,
		TargetUserID: command.TargetUserID,
		AssetType:    ledger.AssetCoin,
		Amount:       command.Amount,
		TaskType:     command.TaskType,
		TaskID:       command.TaskID,
		CycleKey:     command.CycleKey,
		Reason:       command.Reason,
		BalanceAfter: balanceAfter,
		GrantedAtMS:  nowMs,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeTaskReward, requestHash, fmt.Sprintf("%s:%s", command.TaskID, command.CycleKey), metadata, nowMs); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.TargetUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: command.Amount,
		FrozenDelta:    0,
		AvailableAfter: balanceAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, metadata, nowMs),
		taskRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
	}); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}

	return receiptFromTaskRewardMetadata(transactionID, metadata), nil
}

// ApplyGameCoinChange 在同一事务内完成游戏 COIN 改账、分录和 outbox。
func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, command.AppCode)
	command.AppCode = appcode.FromContext(ctx)

	bizType, delta, err := gameBizTypeAndDelta(command.OpType, command.CoinAmount)
	if err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}

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

	if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, command.RequestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
		if err != nil || !exists {
			return ledger.GameCoinChangeReceipt{}, err
		}
		receipt, receiptErr := r.receiptForGameTransaction(ctx, tx, txRow.TransactionID)
		receipt.IdempotentReplay = true
		return receipt, receiptErr
	}

	nowMs := time.Now().UnixMilli()
	createIfMissing := delta > 0
	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, createIfMissing, nowMs)
	if err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	if delta < 0 && account.AvailableAmount < -delta {
		return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	balanceAfter := account.AvailableAmount + delta
	metadata := gameCoinMetadata{
		AppCode:         command.AppCode,
		UserID:          command.UserID,
		PlatformCode:    command.PlatformCode,
		GameID:          command.GameID,
		ProviderOrderID: command.ProviderOrderID,
		ProviderRoundID: command.ProviderRoundID,
		OpType:          command.OpType,
		AssetType:       ledger.AssetCoin,
		CoinAmount:      command.CoinAmount,
		AvailableDelta:  delta,
		RoomID:          command.RoomID,
		BalanceAfter:    balanceAfter,
		AppliedAtMS:     nowMs,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, command.RequestHash, command.ProviderOrderID, metadata, nowMs); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, delta, 0, nowMs); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: delta,
		FrozenDelta:    0,
		AvailableAfter: balanceAfter,
		FrozenAfter:    account.FrozenAmount,
		RoomID:         command.RoomID,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, metadata, nowMs),
	}); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}

	return receiptFromGameMetadata(transactionID, metadata, false), nil
}

// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账，并记录专用进货明细。
func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.CoinSellerStockCreditReceipt{}, 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.CoinSellerStockCreditReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	bizType := coinSellerStockBizType(command.StockType)
	if bizType == "" {
		return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
	}
	requestHash := coinSellerStockRequestHash(command)
	if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
		if err != nil || !exists {
			return ledger.CoinSellerStockCreditReceipt{}, err
		}
		return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID)
	}
	if command.PaymentRef != "" {
		if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated {
			if err != nil {
				return ledger.CoinSellerStockCreditReceipt{}, err
			}
			return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
		}
	}

	nowMs := time.Now().UnixMilli()
	account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
	if err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount)
	if err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase
	metadata := coinSellerStockMetadata{
		AppCode:                command.AppCode,
		SellerUserID:           command.SellerUserID,
		StockType:              command.StockType,
		CoinAmount:             command.CoinAmount,
		PaidCurrencyCode:       command.PaidCurrencyCode,
		PaidAmountMicro:        command.PaidAmountMicro,
		PaymentRef:             command.PaymentRef,
		EvidenceRef:            command.EvidenceRef,
		OperatorUserID:         command.OperatorUserID,
		Reason:                 command.Reason,
		AssetType:              ledger.AssetCoinSellerCoin,
		CountsAsSellerRecharge: countsAsSellerRecharge,
		BalanceAfter:           balanceAfter,
		CreatedAtMS:            nowMs,
	}
	externalRef := command.EvidenceRef
	if command.PaymentRef != "" {
		externalRef = command.PaymentRef
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.SellerUserID,
		AssetType:      ledger.AssetCoinSellerCoin,
		AvailableDelta: command.CoinAmount,
		FrozenDelta:    0,
		AvailableAfter: balanceAfter,
		FrozenAfter:    account.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, metadata, nowMs),
		coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
	}); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}

	return receiptFromCoinSellerStockMetadata(transactionID, metadata), nil
}

// TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账，并记录充值口径。
func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.CoinSellerTransferReceipt{}, 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.CoinSellerTransferReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := coinSellerTransferRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCoinSellerTransfer); err != nil || exists {
		if err != nil || !exists {
			return ledger.CoinSellerTransferReceipt{}, err
		}
		return r.receiptForCoinSellerTransferTransaction(ctx, tx, txRow.TransactionID)
	}

	nowMs := time.Now().UnixMilli()
	seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, false, nowMs)
	if err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if seller.AvailableAmount < command.Amount {
		return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	target, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
	if err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	policy, err := r.resolveRechargePolicy(ctx, tx, command.TargetRegionID, nowMs)
	if err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	rechargeUSDMinor, err := calculateRechargeUSDMinor(command.Amount, policy)
	if err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	sellerAfter := seller.AvailableAmount - command.Amount
	targetAfter := target.AvailableAmount + command.Amount
	metadata := coinSellerTransferMetadata{
		AppCode:                      command.AppCode,
		SellerUserID:                 command.SellerUserID,
		TargetUserID:                 command.TargetUserID,
		SellerRegionID:               command.SellerRegionID,
		TargetRegionID:               command.TargetRegionID,
		Amount:                       command.Amount,
		Reason:                       command.Reason,
		SellerAssetType:              ledger.AssetCoinSellerCoin,
		TargetAssetType:              ledger.AssetCoin,
		SellerBalanceAfter:           sellerAfter,
		TargetBalanceAfter:           targetAfter,
		RechargeUSDMinor:             rechargeUSDMinor,
		RechargeCurrencyCode:         policy.CurrencyCode,
		RechargePolicyID:             policy.PolicyID,
		RechargePolicyVersion:        policy.PolicyVersion,
		RechargePolicyCoinAmount:     policy.CoinAmount,
		RechargePolicyUSDMinorAmount: policy.USDMinorAmount,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCoinSellerTransfer, requestHash, fmt.Sprintf("coin_seller:%d:%d", command.SellerUserID, command.TargetUserID), metadata, nowMs); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, seller, -command.Amount, 0, nowMs); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.SellerUserID,
		AssetType:          ledger.AssetCoinSellerCoin,
		AvailableDelta:     -command.Amount,
		FrozenDelta:        0,
		AvailableAfter:     sellerAfter,
		FrozenAfter:        seller.FrozenAmount,
		CounterpartyUserID: command.TargetUserID,
		CreatedAtMS:        nowMs,
	}); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, target, command.Amount, 0, nowMs); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.TargetUserID,
		AssetType:          ledger.AssetCoin,
		AvailableDelta:     command.Amount,
		FrozenDelta:        0,
		AvailableAfter:     targetAfter,
		FrozenAfter:        target.FrozenAmount,
		CounterpartyUserID: command.SellerUserID,
		CreatedAtMS:        nowMs,
	}); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := r.insertRechargeRecord(ctx, tx, transactionID, metadata, nowMs); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, -command.Amount, 0, sellerAfter, seller.FrozenAmount, metadata, nowMs),
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, metadata, nowMs),
		coinSellerTransferredEvent(transactionID, command.CommandID, command.SellerUserID, -command.Amount, metadata, nowMs),
		rechargeRecordedEvent(transactionID, command.CommandID, command.TargetUserID, command.Amount, metadata, nowMs),
	}); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}

	return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil
}

type transactionRow struct {
	TransactionID string
	MetadataJSON  string
}

func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string) (transactionRow, bool, error) {
	return r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizType, xerr.LedgerConflict)
}

func (r *Repository) lookupTransactionWithConflictCode(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string, conflictCode xerr.Code) (transactionRow, bool, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}')
		 FROM wallet_transactions
		 WHERE app_code = ? AND command_id = ?
		 FOR UPDATE`,
		appcode.FromContext(ctx),
		commandID,
	)

	var txRow transactionRow
	var storedHash string
	var storedBizType string
	if err := row.Scan(&txRow.TransactionID, &storedHash, &storedBizType, &txRow.MetadataJSON); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return transactionRow{}, false, nil
		}
		return transactionRow{}, false, err
	}
	if storedHash != requestHash || storedBizType != bizType {
		// command_id 是钱包幂等主键，业务 payload 或命令类型变化必须 fail-closed。
		return transactionRow{}, true, xerr.New(conflictCode, "wallet command idempotency conflict")
	}
	return txRow, true, nil
}

type walletAccount struct {
	AppCode         string
	UserID          int64
	AssetType       string
	AvailableAmount int64
	FrozenAmount    int64
	Version         int64
	UpdatedAtMS     int64
}

func (r *Repository) lockAccount(ctx context.Context, tx *sql.Tx, userID int64, assetType string, createIfMissing bool, nowMs int64) (walletAccount, error) {
	account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType)
	if err != nil || exists || !createIfMissing {
		if err != nil {
			return walletAccount{}, err
		}
		if !exists {
			return walletAccount{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
		}
		return account, nil
	}

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO wallet_accounts (app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms)
		 VALUES (?, ?, ?, 0, 0, 1, ?, ?)`,
		appcode.FromContext(ctx), userID, assetType, nowMs, nowMs,
	); err != nil {
		return walletAccount{}, err
	}
	return r.queryRequiredAccountForUpdate(ctx, tx, userID, assetType)
}

func (r *Repository) queryAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, bool, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT app_code, user_id, asset_type, available_amount, frozen_amount, version, updated_at_ms
		 FROM wallet_accounts
		 WHERE app_code = ? AND user_id = ? AND asset_type = ?
		 FOR UPDATE`,
		appcode.FromContext(ctx),
		userID, assetType,
	)
	var account walletAccount
	if err := row.Scan(&account.AppCode, &account.UserID, &account.AssetType, &account.AvailableAmount, &account.FrozenAmount, &account.Version, &account.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return walletAccount{}, false, nil
		}
		return walletAccount{}, false, err
	}
	return account, true, nil
}

func (r *Repository) queryRequiredAccountForUpdate(ctx context.Context, tx *sql.Tx, userID int64, assetType string) (walletAccount, error) {
	account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, assetType)
	if err != nil {
		return walletAccount{}, err
	}
	if !exists {
		return walletAccount{}, xerr.New(xerr.Internal, "wallet account creation failed")
	}
	return account, nil
}

func (r *Repository) applyAccountDelta(ctx context.Context, tx *sql.Tx, account walletAccount, availableDelta int64, frozenDelta int64, nowMs int64) error {
	availableAfter := account.AvailableAmount + availableDelta
	frozenAfter := account.FrozenAmount + frozenDelta
	if availableAfter < 0 || frozenAfter < 0 || availableAfter > math.MaxInt64-frozenAfter {
		// 钱包负债不能出现负数，也不能因溢出绕过余额约束。
		return xerr.New(xerr.LedgerConflict, "wallet balance delta is invalid")
	}

	result, err := tx.ExecContext(ctx,
		`UPDATE wallet_accounts
		 SET available_amount = ?, frozen_amount = ?, version = version + 1, updated_at_ms = ?
		 WHERE app_code = ? AND user_id = ? AND asset_type = ? AND version = ?`,
		availableAfter,
		frozenAfter,
		nowMs,
		account.AppCode,
		account.UserID,
		account.AssetType,
		account.Version,
	)
	if err != nil {
		return err
	}
	rows, err := result.RowsAffected()
	if err != nil {
		return err
	}
	if rows != 1 {
		return xerr.New(xerr.LedgerConflict, "wallet account version conflict")
	}
	return nil
}

type giftPrice struct {
	GiftID          string
	PriceVersion    string
	ChargeAssetType string
	CoinPrice       int64
	GiftPointAmount int64
	HeatValue       int64
}

type rechargePolicy struct {
	PolicyID        int64
	RegionID        int64
	PolicyVersion   string
	CurrencyCode    string
	CoinAmount      int64
	USDMinorAmount  int64
	EffectiveFromMs int64
}

func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID string, priceVersion string, nowMs int64) (giftPrice, error) {
	var row *sql.Row
	if strings.TrimSpace(priceVersion) != "" {
		row = tx.QueryRowContext(ctx,
			`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
			 FROM wallet_gift_prices
			 WHERE app_code = ? AND gift_id = ? AND price_version = ? AND status = 'active' AND effective_at_ms <= ?`,
			appcode.FromContext(ctx), giftID, priceVersion, nowMs,
		)
	} else {
		row = tx.QueryRowContext(ctx,
			`SELECT gift_id, price_version, COALESCE(charge_asset_type, 'COIN'), coin_price, gift_point_amount, heat_value
			 FROM wallet_gift_prices
			 WHERE app_code = ? AND gift_id = ? AND status = 'active' AND effective_at_ms <= ?
			 ORDER BY effective_at_ms DESC, price_version DESC
			 LIMIT 1`,
			appcode.FromContext(ctx), giftID, nowMs,
		)
	}

	var price giftPrice
	if err := row.Scan(&price.GiftID, &price.PriceVersion, &price.ChargeAssetType, &price.CoinPrice, &price.GiftPointAmount, &price.HeatValue); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return giftPrice{}, xerr.New(xerr.NotFound, "gift price is not active")
		}
		return giftPrice{}, err
	}
	if price.CoinPrice <= 0 || price.GiftPointAmount < 0 || price.HeatValue < 0 {
		return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid")
	}
	price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
	return price, nil
}

func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms
		 FROM wallet_recharge_policies
		 WHERE app_code = ? AND region_id = ? AND status = 'active'
		   AND effective_from_ms <= ?
		   AND (effective_to_ms IS NULL OR effective_to_ms > ?)
		 ORDER BY effective_from_ms DESC, policy_id DESC
		 LIMIT 1`,
		appcode.FromContext(ctx),
		regionID,
		nowMs,
		nowMs,
	)
	var policy rechargePolicy
	if err := row.Scan(&policy.PolicyID, &policy.RegionID, &policy.PolicyVersion, &policy.CurrencyCode, &policy.CoinAmount, &policy.USDMinorAmount, &policy.EffectiveFromMs); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return rechargePolicy{}, xerr.New(xerr.NotFound, "recharge policy not found")
		}
		return rechargePolicy{}, err
	}
	if policy.CoinAmount <= 0 || policy.USDMinorAmount <= 0 || strings.TrimSpace(policy.CurrencyCode) == "" {
		return rechargePolicy{}, xerr.New(xerr.Internal, "recharge policy is invalid")
	}
	return policy, nil
}

func calculateRechargeUSDMinor(coinAmount int64, policy rechargePolicy) (int64, error) {
	numerator, err := checkedMul(coinAmount, policy.USDMinorAmount)
	if err != nil {
		return 0, err
	}
	if numerator%policy.CoinAmount != 0 {
		// 充值金额按最小货币单位入账；不能精确换算时拒绝，避免财务口径出现隐式四舍五入。
		return 0, xerr.New(xerr.InvalidArgument, "coin amount does not match recharge policy")
	}
	return numerator / policy.CoinAmount, nil
}

func (r *Repository) insertTransaction(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, bizType string, requestHash string, externalRef string, metadata any, nowMs int64) error {
	metadataJSON, err := json.Marshal(metadata)
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx,
		`INSERT INTO wallet_transactions (
		   app_code, transaction_id, command_id, biz_type, status, request_hash, external_ref, metadata_json, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx),
		transactionID,
		commandID,
		bizType,
		transactionStatusSucceeded,
		requestHash,
		externalRef,
		string(metadataJSON),
		nowMs,
		nowMs,
	)
	return err
}

func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, transactionID string, metadata coinSellerTransferMetadata, nowMs int64) error {
	_, err := tx.ExecContext(ctx,
		`INSERT INTO wallet_recharge_records (
		   app_code, transaction_id, user_id, seller_user_id, seller_region_id, target_region_id,
		   policy_id, policy_version, currency_code, coin_amount, usd_minor_amount,
		   exchange_coin_amount, exchange_usd_minor_amount, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		normalizedEntryAppCode(ctx, metadata.AppCode),
		transactionID,
		metadata.TargetUserID,
		metadata.SellerUserID,
		metadata.SellerRegionID,
		metadata.TargetRegionID,
		metadata.RechargePolicyID,
		metadata.RechargePolicyVersion,
		metadata.RechargeCurrencyCode,
		metadata.Amount,
		metadata.RechargeUSDMinor,
		metadata.RechargePolicyCoinAmount,
		metadata.RechargePolicyUSDMinorAmount,
		nowMs,
	)
	return err
}

func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error {
	metadataJSON, err := json.Marshal(metadata)
	if err != nil {
		return err
	}
	var paymentRef any
	if metadata.PaymentRef != "" {
		paymentRef = metadata.PaymentRef
	}
	_, err = tx.ExecContext(ctx,
		`INSERT INTO coin_seller_stock_records (
		   app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type,
		   counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro,
		   payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		normalizedEntryAppCode(ctx, metadata.AppCode),
		coinSellerStockID(metadata.AppCode, commandID),
		transactionID,
		commandID,
		metadata.SellerUserID,
		metadata.StockType,
		metadata.CountsAsSellerRecharge,
		metadata.CoinAmount,
		metadata.PaidCurrencyCode,
		metadata.PaidAmountMicro,
		paymentRef,
		metadata.EvidenceRef,
		metadata.OperatorUserID,
		metadata.Reason,
		metadata.BalanceAfter,
		string(metadataJSON),
		nowMs,
	)
	if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" {
		return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
	}
	return err
}

func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) {
	var stockID string
	err := tx.QueryRowContext(ctx,
		`SELECT stock_id
		 FROM coin_seller_stock_records
		 WHERE app_code = ? AND stock_type = ? AND payment_ref = ?
		 LIMIT 1`,
		appcode.FromContext(ctx),
		stockType,
		paymentRef,
	).Scan(&stockID)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return false, nil
		}
		return false, err
	}
	return true, nil
}

type walletEntry struct {
	AppCode            string
	TransactionID      string
	UserID             int64
	AssetType          string
	AvailableDelta     int64
	FrozenDelta        int64
	AvailableAfter     int64
	FrozenAfter        int64
	CounterpartyUserID int64
	RoomID             string
	CreatedAtMS        int64
}

func (r *Repository) insertEntry(ctx context.Context, tx *sql.Tx, entry walletEntry) error {
	_, err := tx.ExecContext(ctx,
		`INSERT INTO wallet_entries (
		   app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta,
		   available_after, frozen_after, counterparty_user_id, room_id, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		normalizedEntryAppCode(ctx, entry.AppCode),
		entry.TransactionID,
		entry.UserID,
		entry.AssetType,
		entry.AvailableDelta,
		entry.FrozenDelta,
		entry.AvailableAfter,
		entry.FrozenAfter,
		entry.CounterpartyUserID,
		entry.RoomID,
		entry.CreatedAtMS,
	)
	return err
}

type walletOutboxEvent struct {
	AppCode        string
	EventID        string
	EventType      string
	TransactionID  string
	CommandID      string
	UserID         int64
	AssetType      string
	AvailableDelta int64
	FrozenDelta    int64
	Payload        any
	CreatedAtMS    int64
}

func (r *Repository) insertWalletOutbox(ctx context.Context, tx *sql.Tx, events []walletOutboxEvent) error {
	for _, event := range events {
		payloadJSON, err := json.Marshal(event.Payload)
		if err != nil {
			return err
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO wallet_outbox (
			   app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
			   available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
			normalizedEntryAppCode(ctx, event.AppCode),
			event.EventID,
			event.EventType,
			event.TransactionID,
			event.CommandID,
			event.UserID,
			event.AssetType,
			event.AvailableDelta,
			event.FrozenDelta,
			string(payloadJSON),
			outboxStatusPending,
			event.CreatedAtMS,
			event.CreatedAtMS,
		); err != nil {
			return err
		}
	}
	return nil
}

func (r *Repository) receiptForGiftTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.Receipt, error) {
	var metadataJSON 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(&metadataJSON); err != nil {
		return ledger.Receipt{}, err
	}
	var metadata giftMetadata
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return ledger.Receipt{}, err
	}
	return receiptFromGiftMetadata(transactionID, metadata), nil
}

func (r *Repository) balanceAfterTransaction(ctx context.Context, tx *sql.Tx, transactionID string, userID int64, assetType string) (ledger.AssetBalance, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT app_code, user_id, asset_type, available_after, frozen_after
		 FROM wallet_entries
		 WHERE app_code = ? AND transaction_id = ? AND user_id = ? AND asset_type = ?
		 ORDER BY entry_id DESC LIMIT 1`,
		appcode.FromContext(ctx), transactionID, userID, assetType,
	)
	var balance ledger.AssetBalance
	if err := row.Scan(&balance.AppCode, &balance.UserID, &balance.AssetType, &balance.AvailableAmount, &balance.FrozenAmount); err != nil {
		return ledger.AssetBalance{}, err
	}
	return balance, nil
}

type giftMetadata struct {
	AppCode          string `json:"app_code"`
	GiftID           string `json:"gift_id"`
	GiftName         string `json:"gift_name"`
	ResourceID       int64  `json:"resource_id"`
	ResourceSnapshot string `json:"resource_snapshot_json"`
	GiftCount        int32  `json:"gift_count"`
	PriceVersion     string `json:"price_version"`
	CoinPrice        int64  `json:"coin_price"`
	GiftPointAmount  int64  `json:"gift_point_amount"`
	HeatUnitValue    int64  `json:"heat_unit_value"`
	ChargeAssetType  string `json:"charge_asset_type"`
	ChargeAmount     int64  `json:"charge_amount"`
	CoinSpent        int64  `json:"coin_spent"`
	GiftPointAdded   int64  `json:"gift_point_added"`
	HeatValue        int64  `json:"heat_value"`
	BalanceAfter     int64  `json:"balance_after"`
	BillingReceipt   string `json:"billing_receipt_id"`
	SenderUserID     int64  `json:"sender_user_id"`
	TargetUserID     int64  `json:"target_user_id"`
	RoomID           string `json:"room_id"`
}

type adminCreditMetadata struct {
	AppCode        string `json:"app_code"`
	TargetUserID   int64  `json:"target_user_id"`
	AssetType      string `json:"asset_type"`
	Amount         int64  `json:"amount"`
	OperatorUserID int64  `json:"operator_user_id"`
	Reason         string `json:"reason"`
	EvidenceRef    string `json:"evidence_ref"`
}

type taskRewardMetadata struct {
	AppCode      string `json:"app_code"`
	TargetUserID int64  `json:"target_user_id"`
	AssetType    string `json:"asset_type"`
	Amount       int64  `json:"amount"`
	TaskType     string `json:"task_type"`
	TaskID       string `json:"task_id"`
	CycleKey     string `json:"cycle_key"`
	Reason       string `json:"reason"`
	BalanceAfter int64  `json:"balance_after"`
	GrantedAtMS  int64  `json:"granted_at_ms"`
}

type gameCoinMetadata struct {
	AppCode         string `json:"app_code"`
	UserID          int64  `json:"user_id"`
	PlatformCode    string `json:"platform_code"`
	GameID          string `json:"game_id"`
	ProviderOrderID string `json:"provider_order_id"`
	ProviderRoundID string `json:"provider_round_id"`
	OpType          string `json:"op_type"`
	AssetType       string `json:"asset_type"`
	CoinAmount      int64  `json:"coin_amount"`
	AvailableDelta  int64  `json:"available_delta"`
	RoomID          string `json:"room_id"`
	BalanceAfter    int64  `json:"balance_after"`
	AppliedAtMS     int64  `json:"applied_at_ms"`
}

type coinSellerTransferMetadata struct {
	AppCode                      string `json:"app_code"`
	SellerUserID                 int64  `json:"seller_user_id"`
	TargetUserID                 int64  `json:"target_user_id"`
	SellerRegionID               int64  `json:"seller_region_id"`
	TargetRegionID               int64  `json:"target_region_id"`
	Amount                       int64  `json:"amount"`
	Reason                       string `json:"reason"`
	SellerAssetType              string `json:"seller_asset_type"`
	TargetAssetType              string `json:"target_asset_type"`
	SellerBalanceAfter           int64  `json:"seller_balance_after"`
	TargetBalanceAfter           int64  `json:"target_balance_after"`
	RechargeUSDMinor             int64  `json:"recharge_usd_minor"`
	RechargeCurrencyCode         string `json:"recharge_currency_code"`
	RechargePolicyID             int64  `json:"recharge_policy_id"`
	RechargePolicyVersion        string `json:"recharge_policy_version"`
	RechargePolicyCoinAmount     int64  `json:"recharge_policy_coin_amount"`
	RechargePolicyUSDMinorAmount int64  `json:"recharge_policy_usd_minor_amount"`
}

type coinSellerStockMetadata struct {
	AppCode                string `json:"app_code"`
	SellerUserID           int64  `json:"seller_user_id"`
	StockType              string `json:"stock_type"`
	CoinAmount             int64  `json:"coin_amount"`
	PaidCurrencyCode       string `json:"paid_currency_code"`
	PaidAmountMicro        int64  `json:"paid_amount_micro"`
	PaymentRef             string `json:"payment_ref"`
	EvidenceRef            string `json:"evidence_ref"`
	OperatorUserID         int64  `json:"operator_user_id"`
	Reason                 string `json:"reason"`
	AssetType              string `json:"asset_type"`
	CountsAsSellerRecharge bool   `json:"counts_as_seller_recharge"`
	BalanceAfter           int64  `json:"balance_after"`
	CreatedAtMS            int64  `json:"created_at_ms"`
}

func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
	chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
	chargeAmount := metadata.ChargeAmount
	if chargeAmount == 0 {
		chargeAmount = metadata.CoinSpent
	}
	return ledger.Receipt{
		BillingReceiptID: metadata.BillingReceipt,
		TransactionID:    transactionID,
		CoinSpent:        metadata.CoinSpent,
		ChargeAssetType:  chargeAssetType,
		ChargeAmount:     chargeAmount,
		GiftPointAdded:   metadata.GiftPointAdded,
		HeatValue:        metadata.HeatValue,
		PriceVersion:     metadata.PriceVersion,
		BalanceAfter:     metadata.BalanceAfter,
	}
}

func (r *Repository) receiptForTaskRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.TaskRewardReceipt, error) {
	var metadataJSON 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(&metadataJSON); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	var metadata taskRewardMetadata
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return ledger.TaskRewardReceipt{}, err
	}
	return receiptFromTaskRewardMetadata(transactionID, metadata), nil
}

func receiptFromTaskRewardMetadata(transactionID string, metadata taskRewardMetadata) ledger.TaskRewardReceipt {
	return ledger.TaskRewardReceipt{
		TransactionID: transactionID,
		Balance: ledger.AssetBalance{
			AppCode:         metadata.AppCode,
			UserID:          metadata.TargetUserID,
			AssetType:       metadata.AssetType,
			AvailableAmount: metadata.BalanceAfter,
		},
		Amount:      metadata.Amount,
		GrantedAtMS: metadata.GrantedAtMS,
	}
}

func (r *Repository) receiptForGameTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.GameCoinChangeReceipt, error) {
	var metadataJSON 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(&metadataJSON); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	var metadata gameCoinMetadata
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return ledger.GameCoinChangeReceipt{}, err
	}
	return receiptFromGameMetadata(transactionID, metadata, false), nil
}

func receiptFromGameMetadata(transactionID string, metadata gameCoinMetadata, idempotentReplay bool) ledger.GameCoinChangeReceipt {
	return ledger.GameCoinChangeReceipt{
		TransactionID:    transactionID,
		BalanceAfter:     metadata.BalanceAfter,
		IdempotentReplay: idempotentReplay,
	}
}

func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro,
		        counts_as_seller_recharge, balance_after, created_at_ms
		 FROM coin_seller_stock_records
		 WHERE app_code = ? AND transaction_id = ?`,
		appcode.FromContext(ctx),
		transactionID,
	)
	var receipt ledger.CoinSellerStockCreditReceipt
	if err := row.Scan(
		&receipt.SellerUserID,
		&receipt.StockType,
		&receipt.CoinAmount,
		&receipt.PaidCurrencyCode,
		&receipt.PaidAmountMicro,
		&receipt.CountsAsSellerRecharge,
		&receipt.BalanceAfter,
		&receipt.CreatedAtMS,
	); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	receipt.TransactionID = transactionID
	return receipt, nil
}

func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt {
	return ledger.CoinSellerStockCreditReceipt{
		TransactionID:          transactionID,
		SellerUserID:           metadata.SellerUserID,
		StockType:              metadata.StockType,
		CoinAmount:             metadata.CoinAmount,
		PaidCurrencyCode:       metadata.PaidCurrencyCode,
		PaidAmountMicro:        metadata.PaidAmountMicro,
		CountsAsSellerRecharge: metadata.CountsAsSellerRecharge,
		BalanceAfter:           metadata.BalanceAfter,
		CreatedAtMS:            metadata.CreatedAtMS,
	}
}

func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) {
	var metadataJSON 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(&metadataJSON); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	var metadata coinSellerTransferMetadata
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return ledger.CoinSellerTransferReceipt{}, err
	}
	return receiptFromCoinSellerTransferMetadata(transactionID, metadata), nil
}

func receiptFromCoinSellerTransferMetadata(transactionID string, metadata coinSellerTransferMetadata) ledger.CoinSellerTransferReceipt {
	return ledger.CoinSellerTransferReceipt{
		TransactionID:              transactionID,
		SellerBalanceAfter:         metadata.SellerBalanceAfter,
		TargetBalanceAfter:         metadata.TargetBalanceAfter,
		Amount:                     metadata.Amount,
		RechargeUSDMinor:           metadata.RechargeUSDMinor,
		RechargeCurrencyCode:       metadata.RechargeCurrencyCode,
		RechargePolicyID:           metadata.RechargePolicyID,
		RechargePolicyVersion:      metadata.RechargePolicyVersion,
		RechargePolicyCoinAmount:   metadata.RechargePolicyCoinAmount,
		RechargePolicyUSDMinorUnit: metadata.RechargePolicyUSDMinorAmount,
	}
}

func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, payload any, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletBalanceChanged", userID, assetType),
		EventType:      "WalletBalanceChanged",
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         userID,
		AssetType:      assetType,
		AvailableDelta: availableDelta,
		FrozenDelta:    frozenDelta,
		Payload: map[string]any{
			"transaction_id":  transactionID,
			"command_id":      commandID,
			"user_id":         userID,
			"asset_type":      assetType,
			"available_delta": availableDelta,
			"frozen_delta":    frozenDelta,
			"available_after": availableAfter,
			"frozen_after":    frozenAfter,
			"metadata":        payload,
			"created_at_ms":   nowMs,
		},
		CreatedAtMS: nowMs,
	}
}

func coinSellerTransferredEvent(transactionID string, commandID string, sellerUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletCoinSellerTransferred", sellerUserID, ledger.AssetCoinSellerCoin),
		EventType:      "WalletCoinSellerTransferred",
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         sellerUserID,
		AssetType:      ledger.AssetCoinSellerCoin,
		AvailableDelta: availableDelta,
		FrozenDelta:    0,
		Payload:        payload,
		CreatedAtMS:    nowMs,
	}
}

func rechargeRecordedEvent(transactionID string, commandID string, targetUserID int64, availableDelta int64, payload any, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletRechargeRecorded", targetUserID, ledger.AssetCoin),
		EventType:      "WalletRechargeRecorded",
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         targetUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: availableDelta,
		FrozenDelta:    0,
		Payload:        payload,
		CreatedAtMS:    nowMs,
	}
}

func taskRewardCreditedEvent(transactionID string, commandID string, metadata taskRewardMetadata, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletTaskRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
		EventType:      "WalletTaskRewardCredited",
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         metadata.TargetUserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: metadata.Amount,
		FrozenDelta:    0,
		Payload: map[string]any{
			"transaction_id": transactionID,
			"command_id":     commandID,
			"user_id":        metadata.TargetUserID,
			"task_type":      metadata.TaskType,
			"task_id":        metadata.TaskID,
			"cycle_key":      metadata.CycleKey,
			"amount":         metadata.Amount,
			"reason":         metadata.Reason,
			"created_at_ms":  nowMs,
		},
		CreatedAtMS: nowMs,
	}
}

func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent {
	eventType := "WalletCoinSellerCoinCompensated"
	if metadata.StockType == ledger.StockTypeUSDTPurchase {
		eventType = "WalletCoinSellerStockPurchased"
	}
	return walletOutboxEvent{
		EventID:        eventID(transactionID, eventType, metadata.SellerUserID, ledger.AssetCoinSellerCoin),
		EventType:      eventType,
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         metadata.SellerUserID,
		AssetType:      ledger.AssetCoinSellerCoin,
		AvailableDelta: metadata.CoinAmount,
		FrozenDelta:    0,
		Payload: map[string]any{
			"transaction_id":            transactionID,
			"command_id":                commandID,
			"seller_user_id":            metadata.SellerUserID,
			"stock_type":                metadata.StockType,
			"coin_amount":               metadata.CoinAmount,
			"paid_currency_code":        metadata.PaidCurrencyCode,
			"paid_amount_micro":         metadata.PaidAmountMicro,
			"counts_as_seller_recharge": metadata.CountsAsSellerRecharge,
			"operator_user_id":          metadata.OperatorUserID,
			"created_at_ms":             nowMs,
		},
		CreatedAtMS: nowMs,
	}
}

func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletGiftDebited", userID, assetType),
		EventType:      "WalletGiftDebited",
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         userID,
		AssetType:      assetType,
		AvailableDelta: availableDelta,
		FrozenDelta:    frozenDelta,
		Payload:        payload,
		CreatedAtMS:    nowMs,
	}
}

func normalizeAssetTypes(assetTypes []string) []string {
	seen := make(map[string]bool, len(assetTypes))
	result := make([]string, 0, len(assetTypes))
	for _, assetType := range assetTypes {
		assetType = strings.ToUpper(strings.TrimSpace(assetType))
		if assetType == "" || seen[assetType] {
			continue
		}
		seen[assetType] = true
		result = append(result, assetType)
	}
	return result
}

func contextWithCommandApp(ctx context.Context, value string) context.Context {
	if strings.TrimSpace(value) == "" {
		return appcode.WithContext(ctx, appcode.FromContext(ctx))
	}

	return appcode.WithContext(ctx, value)
}

func normalizedEntryAppCode(ctx context.Context, value string) string {
	if strings.TrimSpace(value) == "" {
		return appcode.FromContext(ctx)
	}

	return appcode.Normalize(value)
}

func checkedMul(unit int64, count int64) (int64, error) {
	if unit < 0 || count <= 0 {
		return 0, xerr.New(xerr.InvalidArgument, "amount is invalid")
	}
	if unit != 0 && count > math.MaxInt64/unit {
		return 0, xerr.New(xerr.InvalidArgument, "amount overflow")
	}
	return unit * count, nil
}

func checkedAdd(left int64, right int64) (int64, error) {
	if right < 0 || left > math.MaxInt64-right {
		return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow")
	}
	return left + right, nil
}

func debitRequestHash(command ledger.DebitGiftCommand) string {
	// 哈希覆盖所有账务语义字段；相同 command_id 只能重放完全相同的业务命令。
	return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s",
		appcode.Normalize(command.AppCode),
		command.RoomID,
		command.SenderUserID,
		command.TargetUserID,
		command.GiftID,
		command.GiftCount,
		strings.TrimSpace(command.PriceVersion),
	))
}

func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string {
	return stableHash(fmt.Sprintf("admin_credit|%s|%d|%s|%d|%d|%s|%s",
		appcode.Normalize(command.AppCode),
		command.TargetUserID,
		command.AssetType,
		command.Amount,
		command.OperatorUserID,
		command.Reason,
		command.EvidenceRef,
	))
}

func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
	return stableHash(fmt.Sprintf("task_reward|%s|%d|%d|%s|%s|%s|%s",
		appcode.Normalize(command.AppCode),
		command.TargetUserID,
		command.Amount,
		command.TaskType,
		command.TaskID,
		command.CycleKey,
		strings.TrimSpace(command.Reason),
	))
}

func gameBizTypeAndDelta(opType string, coinAmount int64) (string, int64, error) {
	switch ledger.NormalizeGameOpType(opType) {
	case ledger.GameOpDebit:
		return bizTypeGameDebit, -coinAmount, nil
	case ledger.GameOpCredit:
		return bizTypeGameCredit, coinAmount, nil
	case ledger.GameOpRefund:
		return bizTypeGameRefund, coinAmount, nil
	case ledger.GameOpReverse:
		// 冲正首版按“从用户 COIN 扣回”处理；后续接入平台若语义不同，必须在 adapter 层拆成 debit/refund。
		return bizTypeGameReverse, -coinAmount, nil
	default:
		return "", 0, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
	}
}

func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string {
	return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%s|%d|%s|%d|%s|%s|%d|%s",
		appcode.Normalize(command.AppCode),
		command.SellerUserID,
		command.StockType,
		command.CoinAmount,
		command.PaidCurrencyCode,
		command.PaidAmountMicro,
		command.PaymentRef,
		command.EvidenceRef,
		command.OperatorUserID,
		command.Reason,
	))
}

func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string {
	return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s",
		appcode.Normalize(command.AppCode),
		command.SellerUserID,
		command.TargetUserID,
		command.SellerRegionID,
		command.TargetRegionID,
		command.Amount,
		strings.TrimSpace(command.Reason),
	))
}

func coinSellerStockBizType(stockType string) string {
	switch stockType {
	case ledger.StockTypeUSDTPurchase:
		return bizTypeCoinSellerStockPurchase
	case ledger.StockTypeCoinCompensation:
		return bizTypeCoinSellerCoinCompensation
	default:
		return ""
	}
}

func stableHash(value string) string {
	sum := sha256.Sum256([]byte(value))
	return hex.EncodeToString(sum[:])
}

func mustJSON(value any) string {
	body, err := json.Marshal(value)
	if err != nil {
		return "{}"
	}
	return string(body)
}

func transactionID(appCode string, commandID string) string {
	return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}

func coinSellerStockID(appCode string, commandID string) string {
	return "cstock_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}

func billingReceiptID(appCode string, commandID string) string {
	return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}

func eventID(transactionID string, eventType string, userID int64, assetType string) string {
	return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType))
}

func isMySQLDuplicateError(err error) bool {
	var mysqlErr *mysqlDriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
