package mysql

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	"hyapp/services/wallet-service/internal/domain/ledger"
	"strings"
	"time"
)

// 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() {

		_ = tx.Rollback()
	}()

	requestHash := debitRequestHash(command)
	bizType := giftDebitBizType(command)
	if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizType); err != nil || exists {
		if err != nil {
			if command.DirectGift && xerr.IsCode(err, xerr.LedgerConflict) {
				// 滚动部署期间旧 Gateway 尚不会调用 receipt RPC；正式 DebitDirectGift 遇到历史 hash
				// 也必须复用同一 metadata 兼容校验，避免短暂发布顺序影响已成功的 Lalu 命令。
				compatRow, compatExists, compatErr := r.lookupDirectGiftReceiptTransaction(ctx, tx, command, requestHash)
				if compatErr != nil || !compatExists {
					return ledger.Receipt{}, compatErr
				}
				return r.receiptForGiftTransaction(ctx, tx, compatRow.TransactionID)
			}
			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")
	}
	chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID)
	if command.RobotGift && chargeSource == giftChargeSourceBag {
		return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "robot gift cannot use bag entitlement")
	}
	resolvedSource, err := r.resolveGiftDebitSource(ctx, tx, command, int64(command.GiftCount), nowMs)
	if err != nil {
		return ledger.Receipt{}, err
	}
	giftConfig := resolvedSource.GiftConfig
	if !command.RobotGift {
		if err := r.requireVIPGiftLevel(ctx, tx, giftConfig, command.SenderUserID, nowMs); err != nil {
			return ledger.Receipt{}, err
		}
	}
	price := resolvedSource.Price
	chargeAssetType := price.ChargeAssetType
	if command.RobotGift {

		chargeAssetType = ledger.AssetRobotCoin
	} else if chargeSource == giftChargeSourceBag {
		chargeAssetType = ledger.AssetBagGift
	}
	chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
	if err != nil {
		return ledger.Receipt{}, err
	}
	rechargeSnapshot := luckyGiftRechargeSnapshot{}
	if !command.RobotGift {
		// 画像必须在扣费交易内读取并随交易快照落库；若放到 room/lucky 重试阶段实时查询，同一 command_id 可能因时间推进改变分层。
		rechargeSnapshot, err = r.getLuckyGiftRechargeSnapshot(ctx, tx, command.SenderUserID, giftConfig.GiftTypeCode, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
	}

	roomContributionRatio, roomContributionRatioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
	if err != nil {
		return ledger.Receipt{}, err
	}

	heatValue, err := giftDiamondAmount(chargeAmount, roomContributionRatio.PPM)
	if err != nil {
		return ledger.Receipt{}, err
	}
	giftIncomeRatio, giftIncomeRatioRegionID, err := r.resolveGiftReturnCoinRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode)
	if err != nil {
		return ledger.Receipt{}, err
	}
	giftIncomeCoinAmount := int64(0)
	if !command.RobotGift {

		giftIncomeCoinAmount, err = giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM)
		if err != nil {
			return ledger.Receipt{}, err
		}
	}
	// 主播礼物只累计周期钻石；第二页礼物类型倍率与 Host 政策系数都在扣费事务内固化，
	// 后续运营调整任一配置时，历史流水仍能完整还原本次累计公式。
	giftDiamondRatioPercent := "0.00"
	giftDiamondRatioPPM := int64(0)
	giftDiamondRatioRegionID := int64(0)
	hostSalaryPolicyID := uint64(0)
	hostSalaryPolicyVersion := uint64(0)
	hostSalaryPolicyDiamondCoefficient := ""
	hostSalaryPolicyDiamondCoefficientPPM := int64(0)
	pointDiamondsPerUSD := int64(0)
	coinsPerUSD := int64(0)
	hostPeriodDiamondAdded := int64(0)
	hostIncomePolicyType := ""
	hostIncomeAssetType := ""
	hostIncomeAmount := int64(0)
	agencyPointAmount := int64(0)
	agencyPointShareBPS := int32(0)
	hostPeriodCycleKey := ""
	if command.TargetIsHost && !command.RobotGift {
		// 礼物类型倍率按主播区域解析，并在区域缺失时读取全局后台配置；两者都缺失会在扣币前失败。
		// Host 政策只作为额外系数，任何一层都不能由应用名或礼物类型代码推导默认倍率。
		hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
		giftTypeRatio, effectiveRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.TargetHostRegionID, giftConfig.GiftTypeCode)
		if err != nil {
			return ledger.Receipt{}, err
		}
		diamondCoefficient := hostPeriodDiamondCoefficientSnapshot{}
		if resolvedCoefficient, found, resolveErr := r.resolveHostPeriodDiamondCoefficient(ctx, tx, command.TargetHostRegionID, hostPeriodCycleKey); resolveErr != nil {
			return ledger.Receipt{}, resolveErr
		} else if found {
			diamondCoefficient = resolvedCoefficient
			if diamondCoefficient.PPM > 0 {
				hostIncomeAmount, err = giftDiamondAmountWithPolicyCoefficient(chargeAmount, giftTypeRatio.PPM, diamondCoefficient.PPM)
				if err != nil {
					return ledger.Receipt{}, err
				}
				hostIncomePolicyType = diamondCoefficient.PolicyType
				switch diamondCoefficient.PolicyType {
				case ledger.HostPolicyTypeSalaryDiamond:
					hostIncomeAssetType = ledger.AssetHostPeriodDiamond
					hostPeriodDiamondAdded = hostIncomeAmount
				case ledger.HostPolicyTypePointDiamond:
					hostIncomeAssetType = ledger.AssetPointDiamond
					agencyPointShareBPS = diamondCoefficient.AgencyPointShareBPS
					if command.TargetAgencyOwnerUserID > 0 {
						agencyPointAmount, err = amountByBPS(hostIncomeAmount, agencyPointShareBPS)
						if err != nil {
							return ledger.Receipt{}, err
						}
					}
				default:
					return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "host policy type is invalid")
				}
			}
		} else {
			// Host 收礼必须先命中当前月或紧邻上月继承的发布政策；零收益后继续扣币会制造不可追溯的资金缺口。
			return ledger.Receipt{}, xerr.New(xerr.PermissionDenied, "host income policy is not configured")
		}
		giftDiamondRatioPercent = giftTypeRatio.Percent
		giftDiamondRatioPPM = giftTypeRatio.PPM
		giftDiamondRatioRegionID = effectiveRegionID
		hostSalaryPolicyID = diamondCoefficient.PolicyID
		hostSalaryPolicyVersion = diamondCoefficient.PolicyVersion
		hostSalaryPolicyDiamondCoefficient = diamondCoefficient.Value
		hostSalaryPolicyDiamondCoefficientPPM = diamondCoefficient.PPM
		pointDiamondsPerUSD = diamondCoefficient.PointDiamondsPerUSD
		coinsPerUSD = diamondCoefficient.CoinsPerUSD
	}

	accountAssetType := chargeAssetType
	if chargeSource == giftChargeSourceBag {
		accountAssetType = ledger.AssetCoin
	}
	var senderState *walletAccountState
	var targetCoinState *walletAccountState
	var targetPointDiamondState *walletAccountState
	var agencyPointDiamondState *walletAccountState
	if command.RobotGift {

		sender, err := r.lockAccount(ctx, tx, command.SenderUserID, accountAssetType, true, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		senderState = &walletAccountState{account: sender}
	} else {
		lockRequests := []walletAccountLockRequest{{
			UserID:          command.SenderUserID,
			AssetType:       accountAssetType,
			CreateIfMissing: chargeAmount == 0 || chargeSource == giftChargeSourceBag,
		}}
		if giftIncomeCoinAmount > 0 {
			lockRequests = append(lockRequests, walletAccountLockRequest{
				UserID:          command.TargetUserID,
				AssetType:       ledger.AssetCoin,
				CreateIfMissing: true,
			})
		}
		if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 {
			lockRequests = append(lockRequests, walletAccountLockRequest{
				UserID:          command.TargetUserID,
				AssetType:       ledger.AssetPointDiamond,
				CreateIfMissing: true,
			})
		}
		if agencyPointAmount > 0 {
			lockRequests = append(lockRequests, walletAccountLockRequest{
				UserID:          command.TargetAgencyOwnerUserID,
				AssetType:       ledger.AssetPointDiamond,
				CreateIfMissing: true,
			})
		}
		states, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		senderState = states[walletAccountLockKey(command.SenderUserID, accountAssetType)]
		if giftIncomeCoinAmount > 0 {
			targetCoinState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetCoin)]
		}
		if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 {
			targetPointDiamondState = states[walletAccountLockKey(command.TargetUserID, ledger.AssetPointDiamond)]
		}
		if agencyPointAmount > 0 {
			agencyPointDiamondState = states[walletAccountLockKey(command.TargetAgencyOwnerUserID, ledger.AssetPointDiamond)]
		}
	}
	sender := senderState.account
	robotCoinTopUp := int64(0)
	if command.RobotGift && sender.AvailableAmount < chargeAmount {

		robotCoinTopUp = chargeAmount - sender.AvailableAmount
	}
	if !command.RobotGift && chargeSource != giftChargeSourceBag && sender.AvailableAmount < chargeAmount {
		return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	senderAfter := sender.AvailableAmount + robotCoinTopUp - chargeAmount
	if chargeSource == giftChargeSourceBag {

		senderAfter = sender.AvailableAmount
	}
	giftIncomeBalanceAfter := int64(0)
	if giftIncomeCoinAmount > 0 {
		if targetCoinState == nil {
			return ledger.Receipt{}, xerr.New(xerr.Internal, "gift income account is missing")
		}
		giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + giftIncomeCoinAmount
		if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag {

			giftIncomeBalanceAfter = senderAfter + giftIncomeCoinAmount
			senderAfter = giftIncomeBalanceAfter
		}
	}
	transactionID := transactionID(command.AppCode, command.CommandID)
	// metadata 在账户更新前写入交易表，因此版本必须按本事务即将执行的 sender 分录精确预测；
	// 事务内实际更新仍由 wallet_accounts.version 乐观锁裁决，失败会整体回滚该预测值。
	senderBalanceVersionAfter := giftSenderBalanceVersionAfter(
		senderState.account.Version,
		robotCoinTopUp > 0,
		chargeSource != giftChargeSourceBag,
		giftIncomeCoinAmount > 0 && command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin,
	)
	metadata := giftMetadata{
		AppCode:          command.AppCode,
		GiftID:           command.GiftID,
		GiftName:         giftConfig.Name,
		GiftIconURL:      giftDisplayIconURL(giftConfig.Resource),
		GiftAnimationURL: strings.TrimSpace(giftConfig.Resource.AnimationURL),

		GiftEffectTypes:                       normalizeGiftEffectTypes(giftConfig.EffectTypes),
		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:                       chargeAssetType,
		ChargeAmount:                          chargeAmount,
		CoinSpent:                             chargeAmount,
		ChargeSource:                          chargeSource,
		EntitlementID:                         strings.TrimSpace(command.EntitlementID),
		GiftPointAdded:                        0,
		HeatValue:                             heatValue,
		BalanceAfter:                          senderAfter,
		BalanceVersion:                        senderBalanceVersionAfter,
		GiftIncomeCoinAmount:                  giftIncomeCoinAmount,
		GiftIncomeRatioPercent:                giftIncomeRatio.Percent,
		GiftIncomeRatioRegionID:               giftIncomeRatioRegionID,
		GiftIncomeBalanceAfter:                giftIncomeBalanceAfter,
		RechargeSevenDayCoins:                 rechargeSnapshot.SevenDayCoins,
		RechargeThirtyDayCoins:                rechargeSnapshot.ThirtyDayCoins,
		LastRechargedAtMS:                     rechargeSnapshot.LastAtMS,
		PaidAtMS:                              nowMs,
		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,
		HostIncomePolicyType:                  hostIncomePolicyType,
		IncomeAssetType:                       hostIncomeAssetType,
		HostBaseAmount:                        hostIncomeAmount,
		AgencyAmount:                          agencyPointAmount,
		AgencyPointShareBPS:                   agencyPointShareBPS,
		GiftDiamondRatioPercent:               giftDiamondRatioPercent,
		GiftDiamondRatioPPM:                   giftDiamondRatioPPM,
		GiftDiamondRatioRegionID:              giftDiamondRatioRegionID,
		HostSalaryPolicyID:                    hostSalaryPolicyID,
		HostSalaryPolicyVersion:               hostSalaryPolicyVersion,
		PointDiamondsPerUSD:                   pointDiamondsPerUSD,
		CoinsPerUSD:                           coinsPerUSD,
		HostSalaryPolicyDiamondCoefficient:    hostSalaryPolicyDiamondCoefficient,
		HostSalaryPolicyDiamondCoefficientPPM: hostSalaryPolicyDiamondCoefficientPPM,
		HostPeriodCycleKey:                    hostPeriodCycleKey,
		RoomID:                                command.RoomID,
		RoomRegionID:                          command.RegionID,
		RoomContributionRatioPercent:          roomContributionRatio.Percent,
		RoomContributionRatioRegionID:         roomContributionRatioRegionID,
		CoinPrice:                             price.CoinPrice,
		GiftPointAmount:                       0,
		HeatUnitValue:                         price.CoinPrice,
		RobotGift:                             command.RobotGift,
		DirectGift:                            command.DirectGift,
	}
	if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
		return ledger.Receipt{}, err
	}
	events := make([]walletOutboxEvent, 0, 5)
	if robotCoinTopUp > 0 {
		topUpAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, robotCoinTopUp, 0, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:      transactionID,
			UserID:             command.SenderUserID,
			AssetType:          chargeAssetType,
			AvailableDelta:     robotCoinTopUp,
			FrozenDelta:        0,
			AvailableAfter:     topUpAccount.AvailableAmount,
			FrozenAfter:        topUpAccount.FrozenAmount,
			CounterpartyUserID: 0,
			RoomID:             command.RoomID,
			CreatedAtMS:        nowMs,
		}); err != nil {
			return ledger.Receipt{}, err
		}

		sender = senderState.account
	}
	if chargeSource == giftChargeSourceBag {
		if resolvedSource.Entitlement == nil {
			return ledger.Receipt{}, xerr.New(xerr.Internal, "bag gift entitlement lock is missing")
		}
		resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, command, *resolvedSource.Entitlement, int64(command.GiftCount), metadata, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		events = append(events, resourceEvent)
	} else {
		debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:      transactionID,
			UserID:             command.SenderUserID,
			AssetType:          chargeAssetType,
			AvailableDelta:     -chargeAmount,
			FrozenDelta:        0,
			AvailableAfter:     debitAccount.AvailableAmount,
			FrozenAfter:        debitAccount.FrozenAmount,
			CounterpartyUserID: command.TargetUserID,
			RoomID:             command.RoomID,
			CreatedAtMS:        nowMs,
		}); err != nil {
			return ledger.Receipt{}, err
		}
	}
	if giftIncomeCoinAmount > 0 {
		incomeState := targetCoinState
		if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {

			incomeState = senderState
		}
		incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, giftIncomeCoinAmount, 0, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount
		if command.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
			metadata.BalanceAfter = incomeAccount.AvailableAmount
		}
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:      transactionID,
			UserID:             command.TargetUserID,
			AssetType:          ledger.AssetCoin,
			AvailableDelta:     giftIncomeCoinAmount,
			FrozenDelta:        0,
			AvailableAfter:     incomeAccount.AvailableAmount,
			FrozenAfter:        incomeAccount.FrozenAmount,
			CounterpartyUserID: command.SenderUserID,
			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
		metadata.HostIncomeBalanceAfter = hostPeriodDiamondAfter
	}
	if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 {
		if targetPointDiamondState == nil {
			return ledger.Receipt{}, xerr.New(xerr.Internal, "point diamond account is missing")
		}
		combinedAmount := hostIncomeAmount
		if agencyPointAmount > 0 && command.TargetAgencyOwnerUserID == command.TargetUserID {
			combinedAmount, err = checkedAdd(combinedAmount, agencyPointAmount)
			if err != nil {
				return ledger.Receipt{}, err
			}
		}
		pointAccount, err := r.applyTrackedAccountDelta(ctx, tx, targetPointDiamondState, combinedAmount, 0, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		metadata.HostIncomeBalanceAfter = pointAccount.AvailableAmount
		if command.TargetAgencyOwnerUserID == command.TargetUserID && agencyPointAmount > 0 {
			metadata.AgencyIncomeBalanceAfter = pointAccount.AvailableAmount
		}
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:      transactionID,
			UserID:             command.TargetUserID,
			AssetType:          ledger.AssetPointDiamond,
			AvailableDelta:     combinedAmount,
			AvailableAfter:     pointAccount.AvailableAmount,
			FrozenAfter:        pointAccount.FrozenAmount,
			CounterpartyUserID: command.SenderUserID,
			RoomID:             command.RoomID,
			CreatedAtMS:        nowMs,
		}); err != nil {
			return ledger.Receipt{}, err
		}
	}
	if agencyPointAmount > 0 && command.TargetAgencyOwnerUserID != command.TargetUserID {
		if agencyPointDiamondState == nil {
			return ledger.Receipt{}, xerr.New(xerr.Internal, "agency point diamond account is missing")
		}
		agencyAccount, err := r.applyTrackedAccountDelta(ctx, tx, agencyPointDiamondState, agencyPointAmount, 0, nowMs)
		if err != nil {
			return ledger.Receipt{}, err
		}
		metadata.AgencyIncomeBalanceAfter = agencyAccount.AvailableAmount
		if err := r.insertEntry(ctx, tx, walletEntry{
			TransactionID:      transactionID,
			UserID:             command.TargetAgencyOwnerUserID,
			AssetType:          ledger.AssetPointDiamond,
			AvailableDelta:     agencyPointAmount,
			AvailableAfter:     agencyAccount.AvailableAmount,
			FrozenAfter:        agencyAccount.FrozenAmount,
			CounterpartyUserID: command.SenderUserID,
			RoomID:             command.RoomID,
			CreatedAtMS:        nowMs,
		}); err != nil {
			return ledger.Receipt{}, err
		}
	}
	// 收益投影与钱包分录共享事务：任一侧失败都会回滚，查询因此不需要扫描或解析
	// wallet_transactions.metadata_json，也不会把自营主播合并后的钱包 delta 全算作 Host。
	if err := r.insertPointDiamondGiftIncomeProjection(ctx, tx, transactionID, metadata, nowMs); err != nil {
		return ledger.Receipt{}, err
	}
	// The transaction row is inserted before ledger entries, so its first JSON value is necessarily a
	// provisional snapshot. Replace it with the locked account's actual post-mutation version inside the
	// same transaction; this also covers robot top-up and bag self-gift paths where one command can move
	// the sender account more than once and prediction alone is too fragile for financial replay.
	metadata.BalanceAfter = senderState.account.AvailableAmount
	metadata.BalanceVersion = senderState.account.Version
	if err := r.updateTransactionMetadata(ctx, tx, transactionID, metadata, nowMs); err != nil {
		return ledger.Receipt{}, err
	}

	if !command.RobotGift {

		if chargeSource != giftChargeSourceBag {
			if command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
				events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs, bizType))
			} else {
				events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs, bizType))
			}
		}
		if giftIncomeCoinAmount > 0 && !(command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
			events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs, bizType))
		}
		events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
		if giftIncomeCoinAmount > 0 {
			events = append(events, giftIncomeCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
		}
	}
	if hostPeriodDiamondAdded > 0 {
		events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
	}
	if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 {
		hostDelta := hostIncomeAmount
		if command.TargetAgencyOwnerUserID == command.TargetUserID {
			hostDelta, err = checkedAdd(hostDelta, agencyPointAmount)
			if err != nil {
				return ledger.Receipt{}, err
			}
		}
		events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetPointDiamond, hostDelta, 0, metadata.HostIncomeBalanceAfter, targetPointDiamondState.account.FrozenAmount, targetPointDiamondState.account.Version, metadata, nowMs, bizType))
	}
	if agencyPointAmount > 0 && command.TargetAgencyOwnerUserID != command.TargetUserID {
		events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetAgencyOwnerUserID, ledger.AssetPointDiamond, agencyPointAmount, 0, metadata.AgencyIncomeBalanceAfter, agencyPointDiamondState.account.FrozenAmount, agencyPointDiamondState.account.Version, metadata, nowMs, bizType))
	}
	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
}

// lookupDirectGiftReceiptTransaction 兼容 request_hash 改为“只含稳定意图”之前创建的 direct gift。
// 历史 hash 含 region/Host/Agency 派生字段，重试时无法也不应重查这些旧值；只有 Wallet 自身 metadata
// 中的稳定入参与当前请求逐项一致才允许恢复回执，目标、礼物或扣费来源变化仍返回幂等冲突。
func (r *Repository) lookupDirectGiftReceiptTransaction(ctx context.Context, tx *sql.Tx, command ledger.DebitGiftCommand, requestHash string) (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 = ?`,
		appcode.FromContext(ctx),
		command.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 storedBizType != bizTypeDirectGiftDebit {
		return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict")
	}
	if storedHash == requestHash {
		return txRow, true, nil
	}

	var metadata giftMetadata
	if err := json.Unmarshal([]byte(txRow.MetadataJSON), &metadata); err != nil {
		return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict")
	}
	priceVersion := strings.TrimSpace(command.PriceVersion)
	if normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) == giftChargeSourceBag {
		priceVersion = ""
	}
	metadataPriceVersion := strings.TrimSpace(metadata.PriceVersion)
	if normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID) == giftChargeSourceBag {
		metadataPriceVersion = ""
	}
	if !metadata.DirectGift ||
		metadata.RoomID != command.RoomID ||
		metadata.SenderUserID != command.SenderUserID ||
		metadata.TargetUserID != command.TargetUserID ||
		strings.TrimSpace(metadata.GiftID) != strings.TrimSpace(command.GiftID) ||
		metadata.GiftCount != command.GiftCount ||
		metadataPriceVersion != priceVersion ||
		metadata.RobotGift != command.RobotGift ||
		strings.TrimSpace(metadata.EntitlementID) != strings.TrimSpace(command.EntitlementID) ||
		normalizeGiftChargeSource(metadata.ChargeSource, metadata.EntitlementID) != normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID) {
		return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict")
	}
	return txRow, true, nil
}
