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"
	resourcedomain "hyapp/services/wallet-service/internal/domain/resource"

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

const (
	transactionStatusSucceeded        = "succeeded"
	bizTypeGiftDebit                  = "gift_debit"
	bizTypeManualCredit               = "manual_credit"
	bizTypeTaskReward                 = "task_reward"
	bizTypeLuckyGiftReward            = "lucky_gift_reward"
	bizTypeRoomTurnoverReward         = "room_turnover_reward"
	bizTypeCoinSellerTransfer         = "coin_seller_transfer"
	bizTypeCoinSellerStockPurchase    = "coin_seller_stock_purchase"
	bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
	bizTypeSalaryExchangeToCoin       = "salary_exchange_to_coin"
	bizTypeSalaryTransferToCoinSeller = "salary_transfer_to_coin_seller"
	bizTypeGooglePlayRecharge         = "google_play_recharge"
	bizTypeGameDebit                  = "game_debit"
	bizTypeGameCredit                 = "game_credit"
	bizTypeGameRefund                 = "game_refund"
	bizTypeGameReverse                = "game_reverse"
	bizTypeHostSalarySettlement       = "host_salary_settlement"
	bizTypeCPBreakupFee               = "cp_breakup_fee"
	outboxStatusPending               = "pending"
	outboxStatusDelivering            = "delivering"
	outboxStatusDelivered             = "delivered"
	outboxStatusRetryable             = "retryable"
	outboxStatusFailed                = "failed"
	giftWallChargeAssetMixed          = "MIXED"
)

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

// WalletOutboxRecord is a committed wallet fact ready for external fanout.
type WalletOutboxRecord struct {
	AppCode        string
	EventID        string
	EventType      string
	TransactionID  string
	CommandID      string
	UserID         int64
	AssetType      string
	AvailableDelta int64
	FrozenDelta    int64
	PayloadJSON    string
	CreatedAtMS    int64
	RetryCount     int
	LastError      string
	WorkerID       string
	LockUntilMS    int64
}

// WalletOutboxClaimFilter narrows one MQ fanout lane by event type.
type WalletOutboxClaimFilter struct {
	// IncludeEventTypes lets a realtime worker own only the configured UI event facts.
	IncludeEventTypes []string
	// ExcludeEventTypes lets the generic wallet worker skip facts owned by the realtime lane.
	ExcludeEventTypes []string
}

// 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
	}
	if err := ensureGiftDiamondRatioSchema(ctx, db); err != nil {
		_ = db.Close()
		return nil, err
	}
	if err := ensureCoinSellerSalaryExchangeRateSchema(ctx, db); err != nil {
		_ = db.Close()
		return nil, err
	}

	return &Repository{db: db}, nil
}

func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {
	if _, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
		  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码，用于多租户隔离',
		  region_id BIGINT NOT NULL DEFAULT 0 COMMENT '房间可见区域，0 表示全局默认',
		  gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
		  status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
		  ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例，百分比',
		  created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
		  updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
		  created_at_ms BIGINT NOT NULL COMMENT '创建时间，UTC epoch ms',
		  updated_at_ms BIGINT NOT NULL COMMENT '更新时间，UTC epoch ms',
		  PRIMARY KEY (app_code, region_id, gift_type_code),
		  KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil {
		return err
	}
	_, err := db.ExecContext(ctx, `
		INSERT IGNORE INTO gift_diamond_ratio_configs (
		  app_code, region_id, gift_type_code, status, ratio_percent,
		  created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
		) VALUES
		  ('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
		  ('lalu', 0, 'lucky', 'active', 10.00, 0, 0, 0, 0),
		  ('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0)`)
	return err
}

func ensureCoinSellerSalaryExchangeRateSchema(ctx context.Context, db *sql.DB) error {
	_, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS coin_seller_salary_exchange_rate_tiers (
		  app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码，用于多租户隔离',
		  region_id BIGINT NOT NULL COMMENT '币商所属区域 ID',
		  tier_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '区间 ID',
		  min_usd_minor BIGINT NOT NULL COMMENT '起始美元金额，单位美分，包含',
		  max_usd_minor BIGINT NOT NULL COMMENT '结束美元金额，单位美分，包含',
		  coin_per_usd BIGINT NOT NULL COMMENT '每 1 USD 工资可兑换的币商专用金币数量',
		  status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT '状态：active/disabled',
		  sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重',
		  created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
		  updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
		  created_at_ms BIGINT NOT NULL COMMENT '创建时间，UTC epoch ms',
		  updated_at_ms BIGINT NOT NULL COMMENT '更新时间，UTC epoch ms',
		  PRIMARY KEY (tier_id),
		  KEY idx_coin_seller_salary_rates_region (app_code, region_id, status, min_usd_minor, max_usd_minor),
		  KEY idx_coin_seller_salary_rates_sort (app_code, region_id, status, sort_order)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='币商工资兑换比例区间表'`)
	return err
}

// 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 扣费、主播周期钻石入账、分录和 outbox；历史 GIFT_POINT 回执字段保留但不再入账。
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")
	}
	if command.SenderRegionID < 0 {
		return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender_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
	}
	// 礼物积分已经从运营配置和收礼收益里下线；回执字段只保留 0，兼容旧客户端和历史事件解析。
	roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
	if err != nil {
		return ledger.Receipt{}, err
	}
	// 房间贡献只以真实扣费 COIN 为基数；旧 gift price heat_value 不再参与结算，避免后台热度配置覆盖实际消费。
	heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM)
	if err != nil {
		return ledger.Receipt{}, err
	}
	hostPeriodDiamondAdded := int64(0)
	hostPeriodCycleKey := ""
	giftDiamondRatioPercent := "0.00"
	giftDiamondRatioRegionID := int64(0)
	if command.TargetIsHost {
		ratio, ratioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
		if err != nil {
			return ledger.Receipt{}, err
		}
		// 非主播不会进入该分支，因此普通用户收礼不会产生工资周期钻石。
		hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM)
		if err != nil {
			return ledger.Receipt{}, err
		}
		giftDiamondRatioPercent = ratio.Percent
		giftDiamondRatioRegionID = ratioRegionID
		hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
	}

	sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, chargeAmount == 0, nowMs)
	if err != nil {
		return ledger.Receipt{}, err
	}
	if sender.AvailableAmount < chargeAmount {
		return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	metadata := giftMetadata{
		AppCode:                       command.AppCode,
		GiftID:                        command.GiftID,
		GiftName:                      giftConfig.Name,
		GiftIconURL:                   giftDisplayIconURL(giftConfig.Resource),
		GiftAnimationURL:              strings.TrimSpace(giftConfig.Resource.AnimationURL),
		ResourceID:                    giftConfig.ResourceID,
		ResourceSnapshot:              mustJSON(giftConfig.Resource),
		GiftTypeCode:                  giftConfig.GiftTypeCode,
		CPRelationType:                cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON),
		PresentationJSON:              giftConfig.PresentationJSON,
		SortOrder:                     giftConfig.SortOrder,
		GiftCount:                     command.GiftCount,
		PriceVersion:                  price.PriceVersion,
		ChargeAssetType:               price.ChargeAssetType,
		ChargeAmount:                  chargeAmount,
		CoinSpent:                     chargeAmount,
		GiftPointAdded:                0,
		HeatValue:                     heatValue,
		BalanceAfter:                  sender.AvailableAmount - chargeAmount,
		BillingReceipt:                billingReceiptID(command.AppCode, command.CommandID),
		SenderUserID:                  command.SenderUserID,
		SenderRegionID:                command.SenderRegionID,
		TargetUserID:                  command.TargetUserID,
		TargetIsHost:                  command.TargetIsHost,
		TargetHostRegionID:            command.TargetHostRegionID,
		TargetAgencyOwnerUserID:       command.TargetAgencyOwnerUserID,
		HostPeriodDiamondAdded:        hostPeriodDiamondAdded,
		GiftDiamondRatioPercent:       giftDiamondRatioPercent,
		GiftDiamondRatioRegionID:      giftDiamondRatioRegionID,
		HostPeriodCycleKey:            hostPeriodCycleKey,
		RoomID:                        command.RoomID,
		RoomRegionID:                  command.RegionID,
		RoomContributionRatioPercent:  roomContributionRatio.Percent,
		RoomContributionRatioRegionID: roomContributionRatioRegionID,
		CoinPrice:                     price.CoinPrice,
		GiftPointAmount:               0,
		HeatUnitValue:                 price.CoinPrice,
	}
	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 hostPeriodDiamondAdded > 0 {
		// 周期钻石与送礼扣费同事务提交；如果后续任一步失败，用户扣币和主播工资周期钻石会一起回滚。
		hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, command.CommandID, metadata, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter
		metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
	}

	events := []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs),
		giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs),
	}
	if hostPeriodDiamondAdded > 0 {
		events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
	}
	if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
		return ledger.Receipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.Receipt{}, err
	}

	return receiptFromGiftMetadata(transactionID, metadata), nil
}

// BatchDebitGift 在一个 MySQL 事务内完成多目标送礼；任一目标失败时整批回滚，避免房间命令和账务出现部分成功。
func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDebitGiftCommand) (ledger.BatchGiftReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.BatchGiftReceipt{}, 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.BatchGiftReceipt{}, err
	}
	defer func() {
		// Commit 成功后 Rollback 会返回 sql.ErrTxDone；忽略它可保证所有早退路径释放事务。
		_ = tx.Rollback()
	}()

	nowMs := time.Now().UnixMilli()
	if command.RegionID < 0 {
		return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid")
	}
	if command.SenderRegionID < 0 {
		return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid")
	}
	giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	price, err := r.resolveGiftPrice(ctx, tx, command.GiftID, command.PriceVersion, nowMs)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	// 礼物积分已经下线；批量回执仍保留字段，但每个目标都固定返回 0。
	roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	// 批量多目标仍然是每个目标各送一份同款礼物；每份房间贡献先按真实扣费折算，再由聚合回执累加。
	heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	totalChargeAmount, err := checkedMul(chargeAmount, int64(len(command.Targets)))
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}

	// 批量命令的每个目标都有独立 wallet command_id；幂等重试必须是“全部已存在”或“全部未存在”，不接受半批历史。
	existing := make(map[string]ledger.BatchGiftTargetReceipt, len(command.Targets))
	for _, target := range command.Targets {
		targetCommand := debitGiftCommandFromBatchTarget(command, target)
		requestHash := debitRequestHash(targetCommand)
		txRow, exists, err := r.lookupTransaction(ctx, tx, target.CommandID, requestHash, bizTypeGiftDebit)
		if err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		if !exists {
			continue
		}
		receipt, err := r.receiptForGiftTransaction(ctx, tx, txRow.TransactionID)
		if err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		existing[target.CommandID] = ledger.BatchGiftTargetReceipt{
			TargetUserID: target.TargetUserID,
			CommandID:    target.CommandID,
			Receipt:      receipt,
		}
	}
	if len(existing) > 0 {
		if len(existing) != len(command.Targets) {
			// 同一个 room 命令不应该只存在部分 wallet 子交易；直接 fail-close，等待人工核对账本。
			return ledger.BatchGiftReceipt{}, xerr.New(xerr.IdempotencyConflict, "wallet batch command idempotency conflict")
		}
		targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
		for _, target := range command.Targets {
			targetReceipts = append(targetReceipts, existing[target.CommandID])
		}
		aggregate, err := aggregateGiftReceipts(targetReceipts)
		if err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, nil
	}

	sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, totalChargeAmount == 0, nowMs)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	if sender.AvailableAmount < totalChargeAmount {
		return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}

	anyHostTarget := false
	for _, target := range command.Targets {
		if target.TargetIsHost {
			anyHostTarget = true
			break
		}
	}
	hostRatio := giftDiamondRatioSnapshot{Percent: "0.00", PPM: 0}
	hostRatioRegionID := int64(0)
	if anyHostTarget {
		hostRatio, hostRatioRegionID, err = r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
		if err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
	}

	events := make([]walletOutboxEvent, 0, len(command.Targets)*3)
	targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
	for _, target := range command.Targets {
		hostPeriodDiamondAdded := int64(0)
		hostPeriodCycleKey := ""
		giftDiamondRatioPercent := "0.00"
		giftDiamondRatioRegionID := int64(0)
		if target.TargetIsHost {
			// 每个目标只使用自己的 host 快照；不能把某个主播的 agency/region 套给批量里的其他用户。
			hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, hostRatio.PPM)
			if err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
			giftDiamondRatioPercent = hostRatio.Percent
			giftDiamondRatioRegionID = hostRatioRegionID
			hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
		}

		transactionID := transactionID(command.AppCode, target.CommandID)
		senderAfter := sender.AvailableAmount - chargeAmount
		metadata := giftMetadata{
			AppCode:                       command.AppCode,
			GiftID:                        command.GiftID,
			GiftName:                      giftConfig.Name,
			GiftIconURL:                   giftDisplayIconURL(giftConfig.Resource),
			GiftAnimationURL:              strings.TrimSpace(giftConfig.Resource.AnimationURL),
			ResourceID:                    giftConfig.ResourceID,
			ResourceSnapshot:              mustJSON(giftConfig.Resource),
			GiftTypeCode:                  giftConfig.GiftTypeCode,
			CPRelationType:                cpRelationTypeFromPresentationJSON(giftConfig.PresentationJSON),
			PresentationJSON:              giftConfig.PresentationJSON,
			SortOrder:                     giftConfig.SortOrder,
			GiftCount:                     command.GiftCount,
			PriceVersion:                  price.PriceVersion,
			ChargeAssetType:               price.ChargeAssetType,
			ChargeAmount:                  chargeAmount,
			CoinSpent:                     chargeAmount,
			GiftPointAdded:                0,
			HeatValue:                     heatValue,
			BalanceAfter:                  senderAfter,
			BillingReceipt:                billingReceiptID(command.AppCode, target.CommandID),
			SenderUserID:                  command.SenderUserID,
			SenderRegionID:                command.SenderRegionID,
			TargetUserID:                  target.TargetUserID,
			TargetIsHost:                  target.TargetIsHost,
			TargetHostRegionID:            target.TargetHostRegionID,
			TargetAgencyOwnerUserID:       target.TargetAgencyOwnerUserID,
			HostPeriodDiamondAdded:        hostPeriodDiamondAdded,
			GiftDiamondRatioPercent:       giftDiamondRatioPercent,
			GiftDiamondRatioRegionID:      giftDiamondRatioRegionID,
			HostPeriodCycleKey:            hostPeriodCycleKey,
			RoomID:                        command.RoomID,
			RoomRegionID:                  command.RegionID,
			RoomContributionRatioPercent:  roomContributionRatio.Percent,
			RoomContributionRatioRegionID: roomContributionRatioRegionID,
			CoinPrice:                     price.CoinPrice,
			GiftPointAmount:               0,
			HeatUnitValue:                 price.CoinPrice,
		}
		if err := r.insertTransaction(ctx, tx, transactionID, target.CommandID, bizTypeGiftDebit, debitRequestHash(debitGiftCommandFromBatchTarget(command, target)), fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
		sender.AvailableAmount = senderAfter
		sender.Version++

		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: target.TargetUserID,
			RoomID:             command.RoomID,
			CreatedAtMS:        nowMs,
		}); err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		if hostPeriodDiamondAdded > 0 {
			hostPeriodDiamondAfter, hostPeriodDiamondVersion, err := r.creditHostPeriodDiamonds(ctx, tx, transactionID, target.CommandID, metadata, nowMs)
			if err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
			metadata.HostPeriodDiamondAfter = hostPeriodDiamondAfter
			metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
		}

		events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs))
		if hostPeriodDiamondAdded > 0 {
			events = append(events, hostPeriodDiamondCreditedEvent(transactionID, target.CommandID, metadata, nowMs))
		}
		targetReceipts = append(targetReceipts, ledger.BatchGiftTargetReceipt{
			TargetUserID: target.TargetUserID,
			CommandID:    target.CommandID,
			Receipt:      receiptFromGiftMetadata(transactionID, metadata),
		})
	}

	if err := r.insertWalletOutbox(ctx, tx, events); err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.BatchGiftReceipt{}, err
	}

	aggregate, err := aggregateGiftReceipts(targetReceipts)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	return ledger.BatchGiftReceipt{Aggregate: aggregate, Targets: targetReceipts}, 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
}

func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadata giftMetadata, nowMs int64) error {
	chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
	chargeAmount := metadata.ChargeAmount
	if chargeAmount == 0 {
		// metadata 同时保留 coin_spent 和 charge_amount；投影使用最终收费金额作为礼物价值。
		chargeAmount = metadata.CoinSpent
	}
	totalCoinValue, totalDiamondValue := chargeAmount, int64(0)
	resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot)
	if resourceSnapshotJSON == "" {
		resourceSnapshotJSON = "{}"
	}
	presentationJSON := strings.TrimSpace(metadata.PresentationJSON)
	if presentationJSON == "" {
		presentationJSON = "{}"
	}
	giftTypeCode := strings.TrimSpace(metadata.GiftTypeCode)
	if giftTypeCode == "" {
		giftTypeCode = "normal"
	}

	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_gift_wall (
			app_code, user_id, gift_id, gift_name, resource_id, resource_snapshot_json,
			gift_type_code, presentation_json, gift_count, total_value, total_coin_value,
			total_diamond_value, total_gift_point, total_heat_value, charge_asset_type,
			last_price_version, first_received_at_ms, last_received_at_ms, sort_order,
			created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		ON DUPLICATE KEY UPDATE
			gift_name = VALUES(gift_name),
			resource_id = VALUES(resource_id),
			resource_snapshot_json = VALUES(resource_snapshot_json),
			gift_type_code = VALUES(gift_type_code),
			presentation_json = VALUES(presentation_json),
			gift_count = gift_count + VALUES(gift_count),
			total_value = total_value + VALUES(total_value),
			total_coin_value = total_coin_value + VALUES(total_coin_value),
			total_diamond_value = total_diamond_value + VALUES(total_diamond_value),
			total_gift_point = total_gift_point + VALUES(total_gift_point),
			total_heat_value = total_heat_value + VALUES(total_heat_value),
			charge_asset_type = IF(charge_asset_type = VALUES(charge_asset_type), charge_asset_type, ?),
			last_price_version = VALUES(last_price_version),
			last_received_at_ms = VALUES(last_received_at_ms),
			sort_order = VALUES(sort_order),
			updated_at_ms = VALUES(updated_at_ms)
	`,
		appcode.FromContext(ctx),
		metadata.TargetUserID,
		metadata.GiftID,
		metadata.GiftName,
		metadata.ResourceID,
		resourceSnapshotJSON,
		giftTypeCode,
		presentationJSON,
		int64(metadata.GiftCount),
		chargeAmount,
		totalCoinValue,
		totalDiamondValue,
		0,
		metadata.HeatValue,
		chargeAssetType,
		metadata.PriceVersion,
		nowMs,
		nowMs,
		metadata.SortOrder,
		nowMs,
		nowMs,
		giftWallChargeAssetMixed,
	)
	return err
}

// 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()
	// 扣账不能隐式创建 0 余额账户；入账可以创建账户，保证运营补发能落到新用户。
	account, err := r.lockAccount(ctx, tx, command.TargetUserID, command.AssetType, command.Amount > 0, nowMs)
	if err != nil {
		return ledger.AssetBalance{}, "", err
	}
	if command.Amount < 0 && (command.Amount == math.MinInt64 || account.AvailableAmount < -command.Amount) {
		return ledger.AssetBalance{}, "", xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	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, balance.Version, metadata, nowMs),
	}); err != nil {
		return ledger.AssetBalance{}, "", err
	}
	if err := tx.Commit(); err != nil {
		return ledger.AssetBalance{}, "", err
	}

	return balance, transactionID, nil
}

// DebitCPBreakupFee 在钱包账本中扣除解除关系费用；关系状态不在 wallet 中修改，避免 wallet 反向拥有用户关系事实。
func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBreakupFeeCommand) (ledger.CPBreakupFeeReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.CPBreakupFeeReceipt{}, 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.CPBreakupFeeReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := cpBreakupFeeRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeCPBreakupFee); err != nil || exists {
		if err != nil || !exists {
			return ledger.CPBreakupFeeReceipt{}, err
		}
		balance, balanceErr := r.balanceAfterTransaction(ctx, tx, txRow.TransactionID, command.UserID, ledger.AssetCoin)
		if balanceErr != nil {
			return ledger.CPBreakupFeeReceipt{}, balanceErr
		}
		return ledger.CPBreakupFeeReceipt{
			TransactionID:    txRow.TransactionID,
			CoinSpent:        command.Amount,
			CoinBalanceAfter: balance.AvailableAmount,
			Balance:          balance,
		}, nil
	}

	nowMs := time.Now().UnixMilli()
	account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, false, nowMs)
	if err != nil {
		return ledger.CPBreakupFeeReceipt{}, err
	}
	if account.AvailableAmount < command.Amount {
		return ledger.CPBreakupFeeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	balanceAfter := account.AvailableAmount - command.Amount
	metadata := cpBreakupFeeMetadata{
		AppCode:          command.AppCode,
		UserID:           command.UserID,
		RelationshipID:   command.RelationshipID,
		RelationType:     command.RelationType,
		Amount:           command.Amount,
		CoinBalanceAfter: balanceAfter,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeCPBreakupFee, requestHash, command.RelationshipID, metadata, nowMs); err != nil {
		return ledger.CPBreakupFeeReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, -command.Amount, 0, nowMs); err != nil {
		return ledger.CPBreakupFeeReceipt{}, err
	}
	balance := ledger.AssetBalance{
		AppCode:         command.AppCode,
		UserID:          command.UserID,
		AssetType:       ledger.AssetCoin,
		AvailableAmount: balanceAfter,
		FrozenAmount:    account.FrozenAmount,
		Version:         account.Version + 1,
		UpdatedAtMs:     nowMs,
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: -command.Amount,
		FrozenDelta:    0,
		AvailableAfter: balance.AvailableAmount,
		FrozenAfter:    balance.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.CPBreakupFeeReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
	}); err != nil {
		return ledger.CPBreakupFeeReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.CPBreakupFeeReceipt{}, err
	}
	return ledger.CPBreakupFeeReceipt{
		TransactionID:    transactionID,
		CoinSpent:        command.Amount,
		CoinBalanceAfter: balance.AvailableAmount,
		Balance:          balance,
	}, 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, account.Version+1, 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
}

// CreditLuckyGiftReward 在同一事务里完成幸运礼物中奖 COIN 入账、交易分录和钱包 outbox。
func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.LuckyGiftRewardCommand) (ledger.LuckyGiftRewardReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.LuckyGiftRewardReceipt{}, 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.LuckyGiftRewardReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := luckyGiftRewardRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeLuckyGiftReward); err != nil || exists {
		if err != nil || !exists {
			return ledger.LuckyGiftRewardReceipt{}, err
		}
		return r.receiptForLuckyGiftRewardTransaction(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.LuckyGiftRewardReceipt{}, err
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	balanceAfter := account.AvailableAmount + command.Amount
	metadata := luckyGiftRewardMetadata{
		AppCode:         command.AppCode,
		TargetUserID:    command.TargetUserID,
		AssetType:       ledger.AssetCoin,
		Amount:          command.Amount,
		DrawID:          command.DrawID,
		RoomID:          command.RoomID,
		VisibleRegionID: command.VisibleRegionID,
		GiftID:          command.GiftID,
		PoolID:          command.PoolID,
		Reason:          command.Reason,
		BalanceAfter:    balanceAfter,
		GrantedAtMS:     nowMs,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeLuckyGiftReward, requestHash, command.DrawID, metadata, nowMs); err != nil {
		return ledger.LuckyGiftRewardReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
		return ledger.LuckyGiftRewardReceipt{}, 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,
		RoomID:         command.RoomID,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.LuckyGiftRewardReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
		luckyGiftRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
	}); err != nil {
		return ledger.LuckyGiftRewardReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.LuckyGiftRewardReceipt{}, err
	}

	return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}

// CreditRoomTurnoverReward 在同一事务里完成每周房间流水奖励 COIN 入账、交易分录和钱包 outbox。
func (r *Repository) CreditRoomTurnoverReward(ctx context.Context, command ledger.RoomTurnoverRewardCommand) (ledger.RoomTurnoverRewardReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.RoomTurnoverRewardReceipt{}, 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.RoomTurnoverRewardReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := roomTurnoverRewardRequestHash(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeRoomTurnoverReward); err != nil || exists {
		if err != nil || !exists {
			return ledger.RoomTurnoverRewardReceipt{}, err
		}
		// command_id 相同且请求摘要一致说明是 activity-service 的重复发奖调用；直接返回原交易回执，不再改余额。
		return r.receiptForRoomTurnoverRewardTransaction(ctx, tx, txRow.TransactionID)
	}

	nowMs := time.Now().UnixMilli()
	// 锁定房主 COIN 账户后再计算余额，保证同一用户同时收到多笔奖励时 available_after 仍是线性账本结果。
	account, err := r.lockAccount(ctx, tx, command.TargetUserID, ledger.AssetCoin, true, nowMs)
	if err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, err
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	balanceAfter := account.AvailableAmount + command.Amount
	// metadata 是交易、分录和 outbox 的共享事实快照；后续补发事件或幂等查询都从这里还原原始结算上下文。
	metadata := roomTurnoverRewardMetadata{
		AppCode:       command.AppCode,
		TargetUserID:  command.TargetUserID,
		AssetType:     ledger.AssetCoin,
		Amount:        command.Amount,
		SettlementID:  command.SettlementID,
		RoomID:        command.RoomID,
		PeriodStartMS: command.PeriodStartMS,
		PeriodEndMS:   command.PeriodEndMS,
		CoinSpent:     command.CoinSpent,
		TierID:        command.TierID,
		TierCode:      command.TierCode,
		Reason:        command.Reason,
		BalanceAfter:  balanceAfter,
		GrantedAtMS:   nowMs,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeRoomTurnoverReward, requestHash, command.SettlementID, metadata, nowMs); err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, err
	}
	// 交易、账户余额、账本分录和 outbox 必须在同一事务提交；任意一步失败都会回滚，避免出现发奖记录和余额不一致。
	if err := r.applyAccountDelta(ctx, tx, account, command.Amount, 0, nowMs); err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, 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,
		RoomID:         command.RoomID,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
		roomTurnoverRewardCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
	}); err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, err
	}

	return receiptFromRoomTurnoverRewardMetadata(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, account.Version+1, 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,
		SellerCountryID:        command.SellerCountryID,
		SellerRegionID:         command.SellerRegionID,
		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, account.Version+1, 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)
	// 币商向用户转普通金币只计普通金币到账和充值用户，不计入用户 USDT/USD 充值金额。
	rechargeUSDMinor := int64(0)
	rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.TargetUserID, transactionID, command.Amount, rechargeUSDMinor, nowMs)
	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,
		TargetCountryID:              command.TargetCountryID,
		SellerRegionID:               command.SellerRegionID,
		TargetRegionID:               command.TargetRegionID,
		Amount:                       command.Amount,
		Reason:                       command.Reason,
		SellerAssetType:              ledger.AssetCoinSellerCoin,
		TargetAssetType:              ledger.AssetCoin,
		SellerBalanceAfter:           sellerAfter,
		TargetBalanceAfter:           targetAfter,
		RechargeSequence:             rechargeSequence,
		RechargeUSDMinor:             rechargeUSDMinor,
		RechargeCurrencyCode:         "",
		RechargePolicyID:             0,
		RechargePolicyVersion:        "",
		RechargePolicyCoinAmount:     0,
		RechargePolicyUSDMinorAmount: 0,
	}
	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, seller.Version+1, metadata, nowMs),
		balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, command.Amount, 0, targetAfter, target.FrozenAmount, target.Version+1, 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
}

// ListCoinSellerSalaryExchangeRateTiers 返回指定区域工资转币商的兑换区间；默认只返回 active 区间。
func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	ctx = contextWithCommandApp(ctx, appCode)
	statusClause := "AND status = 'active'"
	if includeDisabled {
		statusClause = ""
	}
	query := fmt.Sprintf(`
		SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
		FROM coin_seller_salary_exchange_rate_tiers
		WHERE app_code = ? AND region_id = ? %s
		ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause)
	rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	tiers := make([]ledger.CoinSellerSalaryExchangeRateTier, 0)
	for rows.Next() {
		var tier ledger.CoinSellerSalaryExchangeRateTier
		if err := rows.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil {
			return nil, err
		}
		tiers = append(tiers, tier)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return tiers, nil
}

// ExchangeSalaryToCoin 在同一事务里扣工资美元钱包、给当前用户普通 COIN 入账，保证两边余额和幂等一起落库。
func (r *Repository) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.SalaryExchangeReceipt{}, 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.SalaryExchangeReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := salaryExchangeRequestHash(command)
	if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryExchangeToCoin, xerr.IdempotencyConflict); err != nil || exists {
		if err != nil || !exists {
			return ledger.SalaryExchangeReceipt{}, err
		}
		return r.receiptForSalaryExchangeTransaction(ctx, tx, txRow.TransactionID)
	}

	nowMs := time.Now().UnixMilli()
	coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, ledger.SalaryExchangeCoinPerUSD)
	if err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	salaryAccount, err := r.lockAccount(ctx, tx, command.UserID, command.SalaryAssetType, false, nowMs)
	if err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if salaryAccount.AvailableAmount < command.SalaryUSDMinor {
		return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	coinAccount, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs)
	if err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	salaryAfter := salaryAccount.AvailableAmount - command.SalaryUSDMinor
	coinAfter, err := checkedAdd(coinAccount.AvailableAmount, coinAmount)
	if err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	metadata := salaryExchangeMetadata{
		AppCode:            command.AppCode,
		UserID:             command.UserID,
		SalaryAssetType:    command.SalaryAssetType,
		CoinAssetType:      ledger.AssetCoin,
		SalaryUSDMinor:     command.SalaryUSDMinor,
		CoinPerUSD:         ledger.SalaryExchangeCoinPerUSD,
		CoinAmount:         coinAmount,
		SalaryBalanceAfter: salaryAfter,
		CoinBalanceAfter:   coinAfter,
		Reason:             command.Reason,
		CreatedAtMS:        nowMs,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryExchangeToCoin, requestHash, fmt.Sprintf("salary_exchange:%d", command.UserID), metadata, nowMs); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, salaryAccount, -command.SalaryUSDMinor, 0, nowMs); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      command.SalaryAssetType,
		AvailableDelta: -command.SalaryUSDMinor,
		FrozenDelta:    0,
		AvailableAfter: salaryAfter,
		FrozenAfter:    salaryAccount.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, coinAccount, coinAmount, 0, nowMs); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:  transactionID,
		UserID:         command.UserID,
		AssetType:      ledger.AssetCoin,
		AvailableDelta: coinAmount,
		FrozenDelta:    0,
		AvailableAfter: coinAfter,
		FrozenAfter:    coinAccount.FrozenAmount,
		CreatedAtMS:    nowMs,
	}); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, salaryAfter, salaryAccount.FrozenAmount, salaryAccount.Version+1, metadata, nowMs),
		balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, coinAmount, 0, coinAfter, coinAccount.FrozenAmount, coinAccount.Version+1, metadata, nowMs),
	}); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.SalaryExchangeReceipt{}, err
	}

	return receiptFromSalaryExchangeMetadata(transactionID, metadata), nil
}

// TransferSalaryToCoinSeller 在同一事务里扣来源工资钱包、按当前区域配置给币商专用金币库存入账。
func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) {
	if r == nil || r.db == nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, 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.SalaryTransferToCoinSellerReceipt{}, err
	}
	defer func() { _ = tx.Rollback() }()

	requestHash := salaryTransferToCoinSellerRequestHash(command)
	if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizTypeSalaryTransferToCoinSeller, xerr.IdempotencyConflict); err != nil || exists {
		if err != nil || !exists {
			return ledger.SalaryTransferToCoinSellerReceipt{}, err
		}
		return r.receiptForSalaryTransferToCoinSellerTransaction(ctx, tx, txRow.TransactionID)
	}

	nowMs := time.Now().UnixMilli()
	rate, err := r.resolveCoinSellerSalaryExchangeRateTier(ctx, tx, command.RegionID, command.SalaryUSDMinor)
	if err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	coinAmount, err := calculateSalaryCoinAmount(command.SalaryUSDMinor, rate.CoinPerUSD)
	if err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	source, err := r.lockAccount(ctx, tx, command.SourceUserID, command.SalaryAssetType, false, nowMs)
	if err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if source.AvailableAmount < command.SalaryUSDMinor {
		return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	seller, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
	if err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}

	transactionID := transactionID(command.AppCode, command.CommandID)
	sourceAfter := source.AvailableAmount - command.SalaryUSDMinor
	sellerAfter, err := checkedAdd(seller.AvailableAmount, coinAmount)
	if err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	metadata := salaryTransferToCoinSellerMetadata{
		AppCode:                  command.AppCode,
		SourceUserID:             command.SourceUserID,
		SellerUserID:             command.SellerUserID,
		RegionID:                 command.RegionID,
		SalaryAssetType:          command.SalaryAssetType,
		SellerAssetType:          ledger.AssetCoinSellerCoin,
		SalaryUSDMinor:           command.SalaryUSDMinor,
		CoinPerUSD:               rate.CoinPerUSD,
		CoinAmount:               coinAmount,
		RateMinUSDMinor:          rate.MinUSDMinor,
		RateMaxUSDMinor:          rate.MaxUSDMinor,
		SourceSalaryBalanceAfter: sourceAfter,
		SellerBalanceAfter:       sellerAfter,
		Reason:                   command.Reason,
		CreatedAtMS:              nowMs,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeSalaryTransferToCoinSeller, requestHash, fmt.Sprintf("salary_coin_seller:%d:%d", command.SourceUserID, command.SellerUserID), metadata, nowMs); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, source, -command.SalaryUSDMinor, 0, nowMs); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.SourceUserID,
		AssetType:          command.SalaryAssetType,
		AvailableDelta:     -command.SalaryUSDMinor,
		FrozenDelta:        0,
		AvailableAfter:     sourceAfter,
		FrozenAfter:        source.FrozenAmount,
		CounterpartyUserID: command.SellerUserID,
		CreatedAtMS:        nowMs,
	}); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if err := r.applyAccountDelta(ctx, tx, seller, coinAmount, 0, nowMs); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if err := r.insertEntry(ctx, tx, walletEntry{
		TransactionID:      transactionID,
		UserID:             command.SellerUserID,
		AssetType:          ledger.AssetCoinSellerCoin,
		AvailableDelta:     coinAmount,
		FrozenDelta:        0,
		AvailableAfter:     sellerAfter,
		FrozenAfter:        seller.FrozenAmount,
		CounterpartyUserID: command.SourceUserID,
		CreatedAtMS:        nowMs,
	}); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
		balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs),
		balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs),
	}); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}
	if err := tx.Commit(); err != nil {
		return ledger.SalaryTransferToCoinSellerReceipt{}, err
	}

	return receiptFromSalaryTransferToCoinSellerMetadata(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 是 wallet_gift_prices 的历史列；送礼结算只按 CoinPrice 和比例计算，不再读取它做收益。
	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.HeatValue < 0 {
		return giftPrice{}, xerr.New(xerr.Internal, "gift price is invalid")
	}
	price.ChargeAssetType = ledger.NormalizeGiftChargeAssetType(price.ChargeAssetType)
	return price, nil
}

type giftDiamondRatioSnapshot struct {
	Percent string
	PPM     int64
}

func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
	giftTypeCode = strings.TrimSpace(giftTypeCode)
	if giftTypeCode == "" {
		giftTypeCode = "normal"
	}
	regions := []int64{regionID}
	if regionID != 0 {
		regions = append(regions, 0)
	}
	for _, regionID := range regions {
		ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode)
		if err != nil {
			return giftDiamondRatioSnapshot{}, 0, err
		}
		if exists {
			return ratio, regionID, nil
		}
	}
	return defaultGiftDiamondRatio(giftTypeCode), 0, nil
}

func (r *Repository) resolveGlobalGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
	// 送礼运行链路只认后台“全局默认”行，避免房间区域或送礼人区域把贡献值和主播钻石算成不同口径。
	return r.resolveGiftDiamondRatio(ctx, tx, appCode, 0, giftTypeCode)
}

func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) {
	var percent string
	var ppm int64
	err := tx.QueryRowContext(ctx, `
		SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED)
		FROM gift_diamond_ratio_configs
		WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active'
		LIMIT 1`,
		appcode.Normalize(appCode), regionID, giftTypeCode,
	).Scan(&percent, &ppm)
	if errors.Is(err, sql.ErrNoRows) {
		return giftDiamondRatioSnapshot{}, false, nil
	}
	if err != nil {
		return giftDiamondRatioSnapshot{}, false, err
	}
	if ppm < 0 || ppm > 1_000_000 {
		return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid")
	}
	percent = strings.TrimSpace(percent)
	if percent == "" {
		percent = "100.00"
	}
	return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil
}

func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) {
	if chargeAmount < 0 || ratioPPM < 0 {
		return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid")
	}
	product, err := checkedMul(chargeAmount, ratioPPM)
	if err != nil {
		return 0, err
	}
	return product / 1_000_000, nil
}

func defaultGiftDiamondRatio(giftTypeCode string) giftDiamondRatioSnapshot {
	switch strings.TrimSpace(giftTypeCode) {
	case "lucky":
		return giftDiamondRatioSnapshot{Percent: "10.00", PPM: 100_000}
	case "super_lucky":
		return giftDiamondRatioSnapshot{Percent: "1.00", PPM: 10_000}
	default:
		return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}
	}
}

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
	}
	// 金币必须完整到账；充值 USD 统计只能落到最小货币单位，不能表示的尾差向下取整。
	return numerator / policy.CoinAmount, nil
}

func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context, tx *sql.Tx, regionID int64, salaryUSDMinor int64) (ledger.CoinSellerSalaryExchangeRateTier, error) {
	row := tx.QueryRowContext(ctx,
		`SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
		 FROM coin_seller_salary_exchange_rate_tiers
		 WHERE app_code = ? AND region_id = ? AND status = 'active'
		   AND min_usd_minor <= ? AND max_usd_minor >= ?
		 ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
		 LIMIT 1
		 FOR UPDATE`,
		appcode.FromContext(ctx),
		regionID,
		salaryUSDMinor,
		salaryUSDMinor,
	)
	var tier ledger.CoinSellerSalaryExchangeRateTier
	if err := row.Scan(&tier.RegionID, &tier.MinUSDMinor, &tier.MaxUSDMinor, &tier.CoinPerUSD, &tier.Status, &tier.SortOrder, &tier.UpdatedAtMS); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.NotFound, "exchange rate not configured")
		}
		return ledger.CoinSellerSalaryExchangeRateTier{}, err
	}
	if tier.MinUSDMinor <= 0 || tier.MaxUSDMinor < tier.MinUSDMinor || tier.CoinPerUSD <= 0 || tier.CoinPerUSD%100 != 0 {
		// 工资以美分入账，coin_per_usd 必须能被 100 整除，避免转账金币出现隐式四舍五入。
		return ledger.CoinSellerSalaryExchangeRateTier{}, xerr.New(xerr.Internal, "coin seller salary exchange rate is invalid")
	}
	return tier, nil
}

func calculateSalaryCoinAmount(salaryUSDMinor int64, coinPerUSD int64) (int64, error) {
	numerator, err := checkedMul(salaryUSDMinor, coinPerUSD)
	if err != nil {
		return 0, err
	}
	if numerator%100 != 0 {
		return 0, xerr.New(xerr.InvalidArgument, "salary amount does not match exchange rate")
	}
	return numerator / 100, 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, recharge_sequence, 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.RechargeSequence,
		metadata.SellerUserID,
		metadata.SellerRegionID,
		metadata.TargetRegionID,
		metadata.RechargePolicyID,
		metadata.RechargePolicyVersion,
		metadata.RechargeCurrencyCode,
		metadata.Amount,
		metadata.RechargeUSDMinor,
		metadata.Amount,
		metadata.RechargePolicyUSDMinorAmount,
		nowMs,
	)
	return err
}

func (r *Repository) reserveUserRechargeSequence(ctx context.Context, tx *sql.Tx, appCode string, userID int64, transactionID string, coinAmount int64, usdMinorAmount int64, nowMs int64) (int64, error) {
	normalizedApp := normalizedEntryAppCode(ctx, appCode)
	var count int64
	err := tx.QueryRowContext(ctx, `
		SELECT recharge_count
		FROM wallet_user_recharge_stats
		WHERE app_code = ? AND user_id = ?
		FOR UPDATE`,
		normalizedApp, userID,
	).Scan(&count)
	if errors.Is(err, sql.ErrNoRows) {
		_, err = tx.ExecContext(ctx, `
			INSERT INTO wallet_user_recharge_stats (
				app_code, user_id, recharge_count, first_transaction_id, first_recharged_at_ms,
				total_coin_amount, total_usd_minor_amount, created_at_ms, updated_at_ms
			) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?)`,
			normalizedApp, userID, transactionID, nowMs, coinAmount, usdMinorAmount, nowMs, nowMs,
		)
		if err != nil {
			return 0, err
		}
		return 1, nil
	}
	if err != nil {
		return 0, err
	}
	next := count + 1
	_, err = tx.ExecContext(ctx, `
		UPDATE wallet_user_recharge_stats
		SET recharge_count = ?,
		    total_coin_amount = total_coin_amount + ?,
		    total_usd_minor_amount = total_usd_minor_amount + ?,
		    updated_at_ms = ?
		WHERE app_code = ? AND user_id = ?`,
		next, coinAmount, usdMinorAmount, nowMs, normalizedApp, userID,
	)
	if err != nil {
		return 0, err
	}
	return next, nil
}

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
}

func (r *Repository) creditHostPeriodDiamonds(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, nowMs int64) (int64, int64, error) {
	// host_period_diamond_accounts 是工资引擎读取的投影表，不是可提现钱包资产；只有主播身份收礼会写入。
	if metadata.HostPeriodDiamondAdded <= 0 {
		return 0, 0, nil
	}
	after, version, exists, err := r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey)
	if err != nil {
		return 0, 0, err
	}
	if !exists {
		// 周期账户只在主播首次收礼时创建；工资结算任务按 cycle_key 扫描，不依赖通用 wallet_accounts。
		// 首笔送礼写入当时区域和代理；同周期后续收礼会刷新快照，结算以周期账户最终快照为准。
		after = metadata.HostPeriodDiamondAdded
		version = 1
		_, err = tx.ExecContext(ctx,
			`INSERT INTO host_period_diamond_accounts (
			   app_code, user_id, cycle_key, region_id, agency_owner_user_id, total_diamonds, gift_diamond_total,
			   version, first_gift_at_ms, last_gift_at_ms, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			appcode.FromContext(ctx),
			metadata.TargetUserID,
			metadata.HostPeriodCycleKey,
			metadata.TargetHostRegionID,
			metadata.TargetAgencyOwnerUserID,
			after,
			metadata.HostPeriodDiamondAdded,
			version,
			nowMs,
			nowMs,
			nowMs,
			nowMs,
		)
		if err != nil && isMySQLDuplicateError(err) {
			// 并发首笔送礼可能同时发现账户不存在；退回锁读更新路径，保证不丢钻石。
			after, version, exists, err = r.lockHostPeriodDiamondAccount(ctx, tx, metadata.TargetUserID, metadata.HostPeriodCycleKey)
		}
		if err != nil {
			return 0, 0, err
		}
		if !exists {
			if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil {
				return 0, 0, err
			}
			return after, version, nil
		}
	}

	after, err = checkedAdd(after, metadata.HostPeriodDiamondAdded)
	if err != nil {
		return 0, 0, err
	}
	version++
	// 已存在账户时更新累计钻石，同时刷新区域/代理快照为最近一次收礼状态；这符合“当月当前归属”口径。
	result, err := tx.ExecContext(ctx,
		`UPDATE host_period_diamond_accounts
		 SET region_id = ?, agency_owner_user_id = ?, total_diamonds = ?, gift_diamond_total = gift_diamond_total + ?,
		     version = ?, last_gift_at_ms = ?, updated_at_ms = ?
		 WHERE app_code = ? AND user_id = ? AND cycle_key = ?`,
		metadata.TargetHostRegionID,
		metadata.TargetAgencyOwnerUserID,
		after,
		metadata.HostPeriodDiamondAdded,
		version,
		nowMs,
		nowMs,
		appcode.FromContext(ctx),
		metadata.TargetUserID,
		metadata.HostPeriodCycleKey,
	)
	if err != nil {
		return 0, 0, err
	}
	rows, err := result.RowsAffected()
	if err != nil {
		return 0, 0, err
	}
	if rows != 1 {
		return 0, 0, xerr.New(xerr.LedgerConflict, "host period diamond account version conflict")
	}
	if err := r.insertHostPeriodDiamondEntry(ctx, tx, transactionID, commandID, metadata, after, nowMs); err != nil {
		return 0, 0, err
	}
	return after, version, nil
}

func (r *Repository) lockHostPeriodDiamondAccount(ctx context.Context, tx *sql.Tx, userID int64, cycleKey string) (int64, int64, bool, error) {
	// FOR UPDATE 串行化同主播同周期的并发送礼，保证 total_diamonds 不会被覆盖丢失。
	row := tx.QueryRowContext(ctx,
		`SELECT total_diamonds, version
		 FROM host_period_diamond_accounts
		 WHERE app_code = ? AND user_id = ? AND cycle_key = ?
		 FOR UPDATE`,
		appcode.FromContext(ctx),
		userID,
		cycleKey,
	)
	var totalDiamonds int64
	var version int64
	if err := row.Scan(&totalDiamonds, &version); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, 0, false, nil
		}
		return 0, 0, false, err
	}
	return totalDiamonds, version, true, nil
}

func (r *Repository) insertHostPeriodDiamondEntry(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata giftMetadata, diamondAfter int64, nowMs int64) error {
	// 明细表记录每一笔送礼带来的工资钻石，后续核对周期账户累计值时不需要解析钱包 metadata。
	_, err := tx.ExecContext(ctx,
		`INSERT INTO host_period_diamond_entries (
		   app_code, transaction_id, command_id, user_id, cycle_key, region_id, agency_owner_user_id,
		   diamond_delta, diamond_after, gift_charge_asset_type, gift_charge_amount,
		   gift_id, gift_count, sender_user_id, room_id, created_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.FromContext(ctx),
		transactionID,
		commandID,
		metadata.TargetUserID,
		metadata.HostPeriodCycleKey,
		metadata.TargetHostRegionID,
		metadata.TargetAgencyOwnerUserID,
		metadata.HostPeriodDiamondAdded,
		diamondAfter,
		metadata.ChargeAssetType,
		metadata.ChargeAmount,
		metadata.GiftID,
		metadata.GiftCount,
		metadata.SenderUserID,
		metadata.RoomID,
		nowMs,
	)
	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
}

// ClaimPendingWalletOutbox atomically claims committed wallet facts for MQ fanout.
func (r *Repository) ClaimPendingWalletOutbox(ctx context.Context, workerID string, limit int, lockUntilMS int64) ([]WalletOutboxRecord, error) {
	return r.ClaimPendingWalletOutboxFiltered(ctx, workerID, limit, lockUntilMS, WalletOutboxClaimFilter{})
}

// ClaimPendingWalletOutboxFiltered atomically claims committed wallet facts for one MQ fanout lane.
func (r *Repository) ClaimPendingWalletOutboxFiltered(ctx context.Context, workerID string, limit int, lockUntilMS int64, filter WalletOutboxClaimFilter) ([]WalletOutboxRecord, error) {
	if r == nil || r.db == nil {
		return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return nil, xerr.New(xerr.InvalidArgument, "worker_id is required")
	}
	if limit <= 0 {
		limit = 100
	}
	if limit > 500 {
		limit = 500
	}
	nowMS := time.Now().UTC().UnixMilli()
	if lockUntilMS <= nowMS {
		lockUntilMS = time.Now().UTC().Add(30 * time.Second).UnixMilli()
	}

	appCode := appcode.FromContext(ctx)
	eventFilterSQL, eventFilterArgs := walletOutboxEventFilterSQL(filter)
	pendingIndex, claimIndex := walletOutboxClaimIndexes(filter)
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer func() { _ = tx.Rollback() }()

	records := make([]WalletOutboxRecord, 0, limit)
	claimBranches := []struct {
		query string
		args  []any
	}{
		{
			query: walletOutboxClaimQuery(pendingIndex, `
		  AND next_retry_at_ms IS NULL
		  AND lock_until_ms IS NULL`, eventFilterSQL),
			args: append([]any{appCode, outboxStatusPending}, eventFilterArgs...),
		},
		{
			query: walletOutboxClaimQuery(pendingIndex, `
		  AND next_retry_at_ms <= ?
		  AND lock_until_ms IS NULL`, eventFilterSQL),
			args: append([]any{appCode, outboxStatusRetryable, nowMS}, eventFilterArgs...),
		},
		{
			query: walletOutboxClaimQuery(claimIndex, `
		  AND lock_until_ms <= ?`, eventFilterSQL),
			args: append([]any{appCode, outboxStatusDelivering, nowMS}, eventFilterArgs...),
		},
	}
	for _, branch := range claimBranches {
		remaining := limit - len(records)
		if remaining <= 0 {
			break
		}
		args := append(append([]any{}, branch.args...), remaining)
		branchRecords, err := queryWalletOutboxRecords(ctx, tx, branch.query, args...)
		if err != nil {
			return nil, err
		}
		records = append(records, branchRecords...)
	}

	for _, record := range records {
		if _, err := tx.ExecContext(ctx, `
			UPDATE wallet_outbox
			SET status = ?, worker_id = ?, lock_until_ms = ?, updated_at_ms = ?
			WHERE app_code = ? AND event_id = ?`,
			outboxStatusDelivering,
			workerID,
			lockUntilMS,
			nowMS,
			record.AppCode,
			record.EventID,
		); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(); err != nil {
		return nil, err
	}
	for index := range records {
		records[index].WorkerID = workerID
		records[index].LockUntilMS = lockUntilMS
	}
	return records, nil
}

func walletOutboxClaimQuery(indexName string, statusCondition string, eventFilterSQL string) string {
	return `
		SELECT app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
		       available_delta, frozen_delta, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms,
		       retry_count, COALESCE(last_error, ''), COALESCE(worker_id, ''), COALESCE(lock_until_ms, 0)
		FROM wallet_outbox FORCE INDEX (` + indexName + `)
		WHERE app_code = ? AND status = ?` + statusCondition + eventFilterSQL + `
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT ?
		FOR UPDATE SKIP LOCKED`
}

func walletOutboxEventFilterSQL(filter WalletOutboxClaimFilter) (string, []any) {
	include := normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)
	if len(include) > 0 {
		return " AND event_type IN (" + walletOutboxPlaceholders(len(include)) + ")", walletOutboxEventArgs(include)
	}
	exclude := normalizeWalletOutboxEventTypes(filter.ExcludeEventTypes)
	if len(exclude) > 0 {
		return " AND event_type NOT IN (" + walletOutboxPlaceholders(len(exclude)) + ")", walletOutboxEventArgs(exclude)
	}
	return "", nil
}

func walletOutboxClaimIndexes(filter WalletOutboxClaimFilter) (string, string) {
	if len(normalizeWalletOutboxEventTypes(filter.IncludeEventTypes)) > 0 {
		return "idx_wallet_outbox_event_pending", "idx_wallet_outbox_event_claim"
	}
	return "idx_wallet_outbox_pending", "idx_wallet_outbox_claim"
}

func normalizeWalletOutboxEventTypes(values []string) []string {
	normalized := make([]string, 0, len(values))
	seen := make(map[string]struct{}, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value == "" {
			continue
		}
		if _, exists := seen[value]; exists {
			continue
		}
		seen[value] = struct{}{}
		normalized = append(normalized, value)
	}
	return normalized
}

func walletOutboxPlaceholders(count int) string {
	if count <= 0 {
		return ""
	}
	placeholders := make([]string, count)
	for index := range placeholders {
		placeholders[index] = "?"
	}
	return strings.Join(placeholders, ",")
}

func walletOutboxEventArgs(values []string) []any {
	args := make([]any, 0, len(values))
	for _, value := range values {
		args = append(args, value)
	}
	return args
}

func queryWalletOutboxRecords(ctx context.Context, tx *sql.Tx, query string, args ...any) ([]WalletOutboxRecord, error) {
	rows, err := tx.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	records := make([]WalletOutboxRecord, 0)
	for rows.Next() {
		var record WalletOutboxRecord
		if err := rows.Scan(
			&record.AppCode,
			&record.EventID,
			&record.EventType,
			&record.TransactionID,
			&record.CommandID,
			&record.UserID,
			&record.AssetType,
			&record.AvailableDelta,
			&record.FrozenDelta,
			&record.PayloadJSON,
			&record.CreatedAtMS,
			&record.RetryCount,
			&record.LastError,
			&record.WorkerID,
			&record.LockUntilMS,
		); err != nil {
			return nil, err
		}
		records = append(records, record)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return records, nil
}

// MarkWalletOutboxDelivered marks one wallet fact as accepted by the MQ broker.
func (r *Repository) MarkWalletOutboxDelivered(ctx context.Context, eventID string) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE wallet_outbox
		SET status = ?, worker_id = '', lock_until_ms = NULL, next_retry_at_ms = NULL, last_error = NULL, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?`,
		outboxStatusDelivered,
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)
	return err
}

// MarkWalletOutboxRetryable releases one failed MQ publish with a bounded retry schedule.
func (r *Repository) MarkWalletOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE wallet_outbox
		SET status = ?, worker_id = '', lock_until_ms = NULL, retry_count = retry_count + 1, next_retry_at_ms = ?, last_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?`,
		outboxStatusRetryable,
		nextRetryAtMS,
		trimWalletOutboxError(lastErr),
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)
	return err
}

// MarkWalletOutboxDead stops retrying a poison wallet fact after the configured retry ceiling.
func (r *Repository) MarkWalletOutboxDead(ctx context.Context, eventID string, lastErr string) error {
	if r == nil || r.db == nil {
		return xerr.New(xerr.Unavailable, "mysql repository is not configured")
	}
	_, err := r.db.ExecContext(ctx, `
		UPDATE wallet_outbox
		SET status = ?, worker_id = '', lock_until_ms = NULL, last_error = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?`,
		outboxStatusFailed,
		trimWalletOutboxError(lastErr),
		time.Now().UTC().UnixMilli(),
		appcode.FromContext(ctx),
		eventID,
	)
	return err
}

func trimWalletOutboxError(value string) string {
	value = strings.TrimSpace(value)
	if len(value) > 1024 {
		return value[:1024]
	}
	return value
}

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"`
	GiftIconURL      string `json:"gift_icon_url"`
	GiftAnimationURL string `json:"gift_animation_url"`
	ResourceID       int64  `json:"resource_id"`
	ResourceSnapshot string `json:"resource_snapshot_json"`
	GiftTypeCode     string `json:"gift_type_code"`
	CPRelationType   string `json:"cp_relation_type"`
	PresentationJSON string `json:"presentation_json"`
	SortOrder        int32  `json:"sort_order"`
	GiftCount        int32  `json:"gift_count"`
	PriceVersion     string `json:"price_version"`
	CoinPrice        int64  `json:"coin_price"`
	// GiftPointAmount 是历史礼物积分单价字段，新送礼固定写 0，业务统计只读真实扣费和热度。
	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 是历史收礼积分字段，新送礼固定写 0，保留 JSON 字段只为旧事件解析兼容。
	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"`
	SenderRegionID                int64  `json:"sender_region_id"`
	TargetUserID                  int64  `json:"target_user_id"`
	TargetIsHost                  bool   `json:"target_is_host"`
	TargetHostRegionID            int64  `json:"target_host_region_id"`
	TargetAgencyOwnerUserID       int64  `json:"target_agency_owner_user_id"`
	HostPeriodDiamondAdded        int64  `json:"host_period_diamond_added"`
	GiftDiamondRatioPercent       string `json:"gift_diamond_ratio_percent"`
	GiftDiamondRatioRegionID      int64  `json:"gift_diamond_ratio_region_id"`
	HostPeriodDiamondAfter        int64  `json:"host_period_diamond_after"`
	HostPeriodDiamondVersion      int64  `json:"host_period_diamond_version"`
	HostPeriodCycleKey            string `json:"host_period_cycle_key"`
	RoomID                        string `json:"room_id"`
	RoomRegionID                  int64  `json:"room_region_id"`
	RoomContributionRatioPercent  string `json:"room_contribution_ratio_percent"`
	RoomContributionRatioRegionID int64  `json:"room_contribution_ratio_region_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 cpBreakupFeeMetadata struct {
	AppCode          string `json:"app_code"`
	UserID           int64  `json:"user_id"`
	RelationshipID   string `json:"relationship_id"`
	RelationType     string `json:"relation_type"`
	Amount           int64  `json:"amount"`
	CoinBalanceAfter int64  `json:"coin_balance_after"`
}

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 luckyGiftRewardMetadata struct {
	AppCode         string `json:"app_code"`
	TargetUserID    int64  `json:"target_user_id"`
	AssetType       string `json:"asset_type"`
	Amount          int64  `json:"amount"`
	DrawID          string `json:"draw_id"`
	RoomID          string `json:"room_id"`
	VisibleRegionID int64  `json:"visible_region_id"`
	GiftID          string `json:"gift_id"`
	PoolID          string `json:"pool_id"`
	Reason          string `json:"reason"`
	BalanceAfter    int64  `json:"balance_after"`
	GrantedAtMS     int64  `json:"granted_at_ms"`
}

type roomTurnoverRewardMetadata struct {
	AppCode       string `json:"app_code"`
	TargetUserID  int64  `json:"target_user_id"`
	AssetType     string `json:"asset_type"`
	Amount        int64  `json:"amount"`
	SettlementID  string `json:"settlement_id"`
	RoomID        string `json:"room_id"`
	PeriodStartMS int64  `json:"period_start_ms"`
	PeriodEndMS   int64  `json:"period_end_ms"`
	CoinSpent     int64  `json:"coin_spent"`
	TierID        int64  `json:"tier_id"`
	TierCode      string `json:"tier_code"`
	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"`
	TargetCountryID              int64  `json:"target_country_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"`
	RechargeSequence             int64  `json:"recharge_sequence"`
	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 salaryExchangeMetadata struct {
	AppCode            string `json:"app_code"`
	UserID             int64  `json:"user_id"`
	SalaryAssetType    string `json:"salary_asset_type"`
	CoinAssetType      string `json:"coin_asset_type"`
	SalaryUSDMinor     int64  `json:"salary_usd_minor"`
	CoinPerUSD         int64  `json:"coin_per_usd"`
	CoinAmount         int64  `json:"coin_amount"`
	SalaryBalanceAfter int64  `json:"salary_balance_after"`
	CoinBalanceAfter   int64  `json:"coin_balance_after"`
	Reason             string `json:"reason"`
	CreatedAtMS        int64  `json:"created_at_ms"`
}

type salaryTransferToCoinSellerMetadata struct {
	AppCode                  string `json:"app_code"`
	SourceUserID             int64  `json:"source_user_id"`
	SellerUserID             int64  `json:"seller_user_id"`
	RegionID                 int64  `json:"region_id"`
	SalaryAssetType          string `json:"salary_asset_type"`
	SellerAssetType          string `json:"seller_asset_type"`
	SalaryUSDMinor           int64  `json:"salary_usd_minor"`
	CoinPerUSD               int64  `json:"coin_per_usd"`
	CoinAmount               int64  `json:"coin_amount"`
	RateMinUSDMinor          int64  `json:"rate_min_usd_minor"`
	RateMaxUSDMinor          int64  `json:"rate_max_usd_minor"`
	SourceSalaryBalanceAfter int64  `json:"source_salary_balance_after"`
	SellerBalanceAfter       int64  `json:"seller_balance_after"`
	Reason                   string `json:"reason"`
	CreatedAtMS              int64  `json:"created_at_ms"`
}

type coinSellerStockMetadata struct {
	AppCode                string `json:"app_code"`
	SellerUserID           int64  `json:"seller_user_id"`
	SellerCountryID        int64  `json:"seller_country_id"`
	SellerRegionID         int64  `json:"seller_region_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:         0,
		HeatValue:              metadata.HeatValue,
		GiftTypeCode:           metadata.GiftTypeCode,
		CPRelationType:         metadata.CPRelationType,
		GiftName:               metadata.GiftName,
		GiftIconURL:            metadata.GiftIconURL,
		GiftAnimationURL:       metadata.GiftAnimationURL,
		PriceVersion:           metadata.PriceVersion,
		BalanceAfter:           metadata.BalanceAfter,
		HostPeriodDiamondAdded: metadata.HostPeriodDiamondAdded,
		HostPeriodCycleKey:     metadata.HostPeriodCycleKey,
	}
}

func giftDisplayIconURL(resource resourcedomain.Resource) string {
	// preview_url 是面板和 IM 的首选静态图；旧资源没有 preview 时回退 asset_url，避免关系广播缺图。
	if value := strings.TrimSpace(resource.PreviewURL); value != "" {
		return value
	}
	return strings.TrimSpace(resource.AssetURL)
}

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) receiptForLuckyGiftRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.LuckyGiftRewardReceipt, 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.LuckyGiftRewardReceipt{}, err
	}
	var metadata luckyGiftRewardMetadata
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return ledger.LuckyGiftRewardReceipt{}, err
	}
	return receiptFromLuckyGiftRewardMetadata(transactionID, metadata), nil
}

func receiptFromLuckyGiftRewardMetadata(transactionID string, metadata luckyGiftRewardMetadata) ledger.LuckyGiftRewardReceipt {
	return ledger.LuckyGiftRewardReceipt{
		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) receiptForRoomTurnoverRewardTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.RoomTurnoverRewardReceipt, 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.RoomTurnoverRewardReceipt{}, err
	}
	var metadata roomTurnoverRewardMetadata
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		return ledger.RoomTurnoverRewardReceipt{}, err
	}
	return receiptFromRoomTurnoverRewardMetadata(transactionID, metadata), nil
}

func receiptFromRoomTurnoverRewardMetadata(transactionID string, metadata roomTurnoverRewardMetadata) ledger.RoomTurnoverRewardReceipt {
	return ledger.RoomTurnoverRewardReceipt{
		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, metadata_json, created_at_ms
		 FROM coin_seller_stock_records
		 WHERE app_code = ? AND transaction_id = ?`,
		appcode.FromContext(ctx),
		transactionID,
	)
	var receipt ledger.CoinSellerStockCreditReceipt
	var metadataJSON string
	if err := row.Scan(
		&receipt.SellerUserID,
		&receipt.StockType,
		&receipt.CoinAmount,
		&receipt.PaidCurrencyCode,
		&receipt.PaidAmountMicro,
		&receipt.CountsAsSellerRecharge,
		&receipt.BalanceAfter,
		&metadataJSON,
		&receipt.CreatedAtMS,
	); err != nil {
		return ledger.CoinSellerStockCreditReceipt{}, err
	}
	if metadataJSON != "" {
		var metadata coinSellerStockMetadata
		if err := json.Unmarshal([]byte(metadataJSON), &metadata); err == nil {
			receipt.SellerCountryID = metadata.SellerCountryID
			receipt.SellerRegionID = metadata.SellerRegionID
		}
	}
	receipt.TransactionID = transactionID
	return receipt, nil
}

func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt {
	return ledger.CoinSellerStockCreditReceipt{
		TransactionID:          transactionID,
		SellerUserID:           metadata.SellerUserID,
		SellerCountryID:        metadata.SellerCountryID,
		SellerRegionID:         metadata.SellerRegionID,
		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,
		RechargeSequence:           metadata.RechargeSequence,
		RechargeUSDMinor:           metadata.RechargeUSDMinor,
		RechargeCurrencyCode:       metadata.RechargeCurrencyCode,
		RechargePolicyID:           metadata.RechargePolicyID,
		RechargePolicyVersion:      metadata.RechargePolicyVersion,
		RechargePolicyCoinAmount:   metadata.RechargePolicyCoinAmount,
		RechargePolicyUSDMinorUnit: metadata.RechargePolicyUSDMinorAmount,
	}
}

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

func receiptFromSalaryExchangeMetadata(transactionID string, metadata salaryExchangeMetadata) ledger.SalaryExchangeReceipt {
	return ledger.SalaryExchangeReceipt{
		TransactionID:      transactionID,
		UserID:             metadata.UserID,
		SalaryAssetType:    metadata.SalaryAssetType,
		SalaryBalanceAfter: metadata.SalaryBalanceAfter,
		CoinBalanceAfter:   metadata.CoinBalanceAfter,
		SalaryUSDMinor:     metadata.SalaryUSDMinor,
		CoinAmount:         metadata.CoinAmount,
		CoinPerUSD:         metadata.CoinPerUSD,
		CreatedAtMS:        metadata.CreatedAtMS,
	}
}

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

func receiptFromSalaryTransferToCoinSellerMetadata(transactionID string, metadata salaryTransferToCoinSellerMetadata) ledger.SalaryTransferToCoinSellerReceipt {
	return ledger.SalaryTransferToCoinSellerReceipt{
		TransactionID:            transactionID,
		SourceUserID:             metadata.SourceUserID,
		SellerUserID:             metadata.SellerUserID,
		SalaryAssetType:          metadata.SalaryAssetType,
		SourceSalaryBalanceAfter: metadata.SourceSalaryBalanceAfter,
		SellerBalanceAfter:       metadata.SellerBalanceAfter,
		SalaryUSDMinor:           metadata.SalaryUSDMinor,
		CoinAmount:               metadata.CoinAmount,
		CoinPerUSD:               metadata.CoinPerUSD,
		RateMinUSDMinor:          metadata.RateMinUSDMinor,
		RateMaxUSDMinor:          metadata.RateMaxUSDMinor,
		CreatedAtMS:              metadata.CreatedAtMS,
	}
}

func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion 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,
			"balance_version": balanceVersion,
			"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 {
	if metadata, ok := payload.(coinSellerTransferMetadata); ok {
		// WalletRechargeRecorded 是统计服务的充值入口；币商转账没有 USDT 金额，但必须带目标用户真实国家/区域，避免统计继续把 region 当 country。
		payload = map[string]any{
			"app_code":                         metadata.AppCode,
			"seller_user_id":                   metadata.SellerUserID,
			"target_user_id":                   metadata.TargetUserID,
			"target_country_id":                metadata.TargetCountryID,
			"country_id":                       metadata.TargetCountryID,
			"seller_region_id":                 metadata.SellerRegionID,
			"target_region_id":                 metadata.TargetRegionID,
			"region_id":                        metadata.TargetRegionID,
			"amount":                           metadata.Amount,
			"reason":                           metadata.Reason,
			"seller_asset_type":                metadata.SellerAssetType,
			"target_asset_type":                metadata.TargetAssetType,
			"seller_balance_after":             metadata.SellerBalanceAfter,
			"target_balance_after":             metadata.TargetBalanceAfter,
			"recharge_sequence":                metadata.RechargeSequence,
			"recharge_usd_minor":               metadata.RechargeUSDMinor,
			"recharge_currency_code":           metadata.RechargeCurrencyCode,
			"recharge_policy_id":               metadata.RechargePolicyID,
			"recharge_policy_version":          metadata.RechargePolicyVersion,
			"recharge_policy_coin_amount":      metadata.RechargePolicyCoinAmount,
			"recharge_policy_usd_minor_amount": metadata.RechargePolicyUSDMinorAmount,
			"recharge_type":                    "coin_seller_transfer",
			"created_at_ms":                    nowMs,
		}
	}
	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 luckyGiftRewardCreditedEvent(transactionID string, commandID string, metadata luckyGiftRewardMetadata, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletLuckyGiftRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
		EventType:      "WalletLuckyGiftRewardCredited",
		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,
			"draw_id":           metadata.DrawID,
			"room_id":           metadata.RoomID,
			"visible_region_id": metadata.VisibleRegionID,
			"region_id":         metadata.VisibleRegionID,
			"gift_id":           metadata.GiftID,
			"pool_id":           metadata.PoolID,
			"amount":            metadata.Amount,
			"reason":            metadata.Reason,
			"created_at_ms":     nowMs,
		},
		CreatedAtMS: nowMs,
	}
}

func roomTurnoverRewardCreditedEvent(transactionID string, commandID string, metadata roomTurnoverRewardMetadata, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "WalletRoomTurnoverRewardCredited", metadata.TargetUserID, ledger.AssetCoin),
		EventType:      "WalletRoomTurnoverRewardCredited",
		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,
			"settlement_id":   metadata.SettlementID,
			"room_id":         metadata.RoomID,
			"period_start_ms": metadata.PeriodStartMS,
			"period_end_ms":   metadata.PeriodEndMS,
			"coin_spent":      metadata.CoinSpent,
			"tier_id":         metadata.TierID,
			"tier_code":       metadata.TierCode,
			"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,
			"seller_country_id":         metadata.SellerCountryID,
			"seller_region_id":          metadata.SellerRegionID,
			"country_id":                metadata.SellerCountryID,
			"region_id":                 metadata.SellerRegionID,
			"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 hostPeriodDiamondCreditedEvent(transactionID string, commandID string, metadata giftMetadata, nowMs int64) walletOutboxEvent {
	return walletOutboxEvent{
		EventID:        eventID(transactionID, "HostPeriodDiamondCredited", metadata.TargetUserID, ledger.AssetHostPeriodDiamond),
		EventType:      "HostPeriodDiamondCredited",
		TransactionID:  transactionID,
		CommandID:      commandID,
		UserID:         metadata.TargetUserID,
		AssetType:      ledger.AssetHostPeriodDiamond,
		AvailableDelta: metadata.HostPeriodDiamondAdded,
		FrozenDelta:    0,
		Payload: map[string]any{
			"transaction_id":               transactionID,
			"command_id":                   commandID,
			"host_user_id":                 metadata.TargetUserID,
			"cycle_key":                    metadata.HostPeriodCycleKey,
			"region_id":                    metadata.TargetHostRegionID,
			"agency_owner_user_id":         metadata.TargetAgencyOwnerUserID,
			"diamond_delta":                metadata.HostPeriodDiamondAdded,
			"diamond_after":                metadata.HostPeriodDiamondAfter,
			"gift_charge_asset_type":       metadata.ChargeAssetType,
			"gift_charge_amount":           metadata.ChargeAmount,
			"gift_id":                      metadata.GiftID,
			"gift_count":                   metadata.GiftCount,
			"sender_user_id":               metadata.SenderUserID,
			"sender_region_id":             metadata.SenderRegionID,
			"room_id":                      metadata.RoomID,
			"gift_diamond_ratio_percent":   metadata.GiftDiamondRatioPercent,
			"gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID,
			"created_at_ms":                nowMs,
		},
		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 debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, target ledger.DebitGiftTargetCommand) ledger.DebitGiftCommand {
	// 批量目标最终仍落成独立 gift_debit 交易；复用单目标 hash 可以保证账务语义变化时照样触发幂等冲突。
	return ledger.DebitGiftCommand{
		AppCode:                 command.AppCode,
		CommandID:               target.CommandID,
		RoomID:                  command.RoomID,
		SenderUserID:            command.SenderUserID,
		TargetUserID:            target.TargetUserID,
		GiftID:                  command.GiftID,
		GiftCount:               command.GiftCount,
		PriceVersion:            command.PriceVersion,
		RegionID:                command.RegionID,
		SenderRegionID:          command.SenderRegionID,
		TargetIsHost:            target.TargetIsHost,
		TargetHostRegionID:      target.TargetHostRegionID,
		TargetAgencyOwnerUserID: target.TargetAgencyOwnerUserID,
	}
}

func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Receipt, error) {
	aggregate := ledger.Receipt{}
	billingReceiptIDs := make([]string, 0, len(targets))
	transactionIDs := make([]string, 0, len(targets))
	for _, target := range targets {
		receipt := target.Receipt
		if receipt.BillingReceiptID != "" {
			billingReceiptIDs = append(billingReceiptIDs, receipt.BillingReceiptID)
		}
		if receipt.TransactionID != "" {
			transactionIDs = append(transactionIDs, receipt.TransactionID)
		}
		var err error
		if aggregate.CoinSpent, err = checkedAdd(aggregate.CoinSpent, receipt.CoinSpent); err != nil {
			return ledger.Receipt{}, err
		}
		if aggregate.ChargeAmount, err = checkedAdd(aggregate.ChargeAmount, receipt.ChargeAmount); err != nil {
			return ledger.Receipt{}, err
		}
		if aggregate.HeatValue, err = checkedAdd(aggregate.HeatValue, receipt.HeatValue); err != nil {
			return ledger.Receipt{}, err
		}
		if aggregate.HostPeriodDiamondAdded, err = checkedAdd(aggregate.HostPeriodDiamondAdded, receipt.HostPeriodDiamondAdded); err != nil {
			return ledger.Receipt{}, err
		}
		if aggregate.ChargeAssetType == "" {
			aggregate.ChargeAssetType = receipt.ChargeAssetType
		} else if receipt.ChargeAssetType != "" && aggregate.ChargeAssetType != receipt.ChargeAssetType {
			aggregate.ChargeAssetType = giftWallChargeAssetMixed
		}
		if aggregate.GiftTypeCode == "" {
			aggregate.GiftTypeCode = receipt.GiftTypeCode
		}
		if aggregate.CPRelationType == "" {
			aggregate.CPRelationType = receipt.CPRelationType
		}
		if aggregate.GiftName == "" {
			aggregate.GiftName = receipt.GiftName
		}
		if aggregate.GiftIconURL == "" {
			aggregate.GiftIconURL = receipt.GiftIconURL
		}
		if aggregate.GiftAnimationURL == "" {
			aggregate.GiftAnimationURL = receipt.GiftAnimationURL
		}
		if aggregate.PriceVersion == "" {
			aggregate.PriceVersion = receipt.PriceVersion
		}
		if aggregate.HostPeriodCycleKey == "" {
			aggregate.HostPeriodCycleKey = receipt.HostPeriodCycleKey
		}
		// sender 账后余额随目标顺序逐笔递减；聚合响应返回最后一笔后的余额，表示整批完成后的余额。
		aggregate.BalanceAfter = receipt.BalanceAfter
	}
	aggregate.BillingReceiptID = strings.Join(billingReceiptIDs, ",")
	aggregate.TransactionID = strings.Join(transactionIDs, ",")
	return aggregate, nil
}

func hostPeriodCycleKeyFromMS(ms int64) string {
	// 工资周期用 UTC 月份锚定，避免服务节点本地时区差异导致月底礼物落到不同周期。
	return time.UnixMilli(ms).UTC().Format("2006-01")
}

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

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 cpBreakupFeeRequestHash(command ledger.CPBreakupFeeCommand) string {
	// 解除扣费幂等覆盖关系、关系类型、用户和费用；同 command_id 换任何字段都必须拒绝。
	return stableHash(fmt.Sprintf("cp_breakup_fee|%s|%d|%s|%s|%d",
		appcode.Normalize(command.AppCode),
		command.UserID,
		strings.TrimSpace(command.RelationshipID),
		strings.ToLower(strings.TrimSpace(command.RelationType)),
		command.Amount,
	))
}

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 luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string {
	return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%s|%s|%s",
		appcode.Normalize(command.AppCode),
		command.TargetUserID,
		command.Amount,
		strings.TrimSpace(command.DrawID),
		strings.TrimSpace(command.RoomID),
		strings.TrimSpace(command.GiftID),
		strings.TrimSpace(command.PoolID),
		strings.TrimSpace(command.Reason),
	))
}

func roomTurnoverRewardRequestHash(command ledger.RoomTurnoverRewardCommand) string {
	return stableHash(fmt.Sprintf("room_turnover_reward|%s|%d|%d|%s|%s|%d|%d|%d|%d|%s|%s",
		appcode.Normalize(command.AppCode),
		command.TargetUserID,
		command.Amount,
		strings.TrimSpace(command.SettlementID),
		strings.TrimSpace(command.RoomID),
		command.PeriodStartMS,
		command.PeriodEndMS,
		command.CoinSpent,
		command.TierID,
		strings.TrimSpace(command.TierCode),
		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|%d|%d|%s|%d|%s|%d|%s|%s|%d|%s",
		appcode.Normalize(command.AppCode),
		command.SellerUserID,
		command.SellerCountryID,
		command.SellerRegionID,
		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 salaryExchangeRequestHash(command ledger.SalaryExchangeCommand) string {
	return stableHash(fmt.Sprintf("salary_exchange|%s|%d|%s|%d|%s",
		appcode.Normalize(command.AppCode),
		command.UserID,
		strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
		command.SalaryUSDMinor,
		strings.TrimSpace(command.Reason),
	))
}

func salaryTransferToCoinSellerRequestHash(command ledger.SalaryTransferToCoinSellerCommand) string {
	return stableHash(fmt.Sprintf("salary_transfer_coin_seller|%s|%d|%d|%s|%d|%d|%s",
		appcode.Normalize(command.AppCode),
		command.SourceUserID,
		command.SellerUserID,
		strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)),
		command.SalaryUSDMinor,
		command.RegionID,
		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
}
