package mysql

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

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

		_ = 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")
	}
	// Replay must not depend on a catalog row that may have been disabled after the original transaction.
	// Resolve all target idempotency records before locking entitlement/catalog state.
	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) {

			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
	}
	totalGiftQuantity, err := checkedMul(int64(command.GiftCount), int64(len(command.Targets)))
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	chargeSource := normalizeGiftChargeSource(command.ChargeSource, command.EntitlementID)
	resolvedSource, err := r.resolveGiftDebitSource(ctx, tx, debitGiftCommandFromBatchCommand(command), totalGiftQuantity, nowMs)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	giftConfig := resolvedSource.GiftConfig
	if err := r.requireVIPGiftLevel(ctx, tx, giftConfig, command.SenderUserID, nowMs); err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	// 批量钱包命令只承载真实用户多目标扣费，不存在 RobotGift 语义；同一 sender 共享一次查询，
	// 每个目标交易固化同一快照，避免 room-service 拆成多个 lucky draw 时因目标顺序或重试时刻跨层。
	rechargeSnapshot, err := r.getLuckyGiftRechargeSnapshot(ctx, tx, command.SenderUserID, giftConfig.GiftTypeCode, nowMs)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	price := resolvedSource.Price
	chargeAssetType := price.ChargeAssetType
	if chargeSource == giftChargeSourceBag {
		chargeAssetType = ledger.AssetBagGift
	}
	chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	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
	}
	giftIncomeRatio, giftIncomeRatioRegionID, err := r.resolveGiftReturnCoinRatio(ctx, tx, command.AppCode, command.RegionID, giftConfig.GiftTypeCode)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	giftIncomeCoinAmount, err := giftDiamondAmount(chargeAmount, giftIncomeRatio.PPM)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	totalChargeAmount, err := checkedMul(chargeAmount, int64(len(command.Targets)))
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}

	type resolvedHostDiamondRatio struct {
		giftTypeRatio         giftDiamondRatioSnapshot
		giftTypeRatioRegionID int64
		coefficient           hostPeriodDiamondCoefficientSnapshot
		coefficientFound      bool
	}
	// 同一批目标可能跨主播区域。礼物类型倍率与 Host 政策系数都按 region_id 缓存，
	// 既避免为每个 target 重复查询，也确保不同区域不会误用第一位主播的配置。
	hostRatiosByRegion := make(map[int64]resolvedHostDiamondRatio)
	resolveHostRatio := func(regionID int64) (resolvedHostDiamondRatio, error) {
		if resolved, exists := hostRatiosByRegion[regionID]; exists {
			return resolved, nil
		}
		giftTypeRatio, effectiveRegionID, resolveErr := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, regionID, giftConfig.GiftTypeCode)
		if resolveErr != nil {
			return resolvedHostDiamondRatio{}, resolveErr
		}
		diamondCoefficient := hostPeriodDiamondCoefficientSnapshot{}
		resolvedCoefficient, found, resolveErr := r.resolveHostPeriodDiamondCoefficient(ctx, tx, regionID, hostPeriodCycleKeyFromMS(nowMs))
		if resolveErr != nil {
			return resolvedHostDiamondRatio{}, resolveErr
		}
		if !found {
			// 批量命令在构造账户锁和扣 sender 前解析所有主播；任一 Host 缺政策则整批原子失败。
			return resolvedHostDiamondRatio{}, xerr.New(xerr.PermissionDenied, "host income policy is not configured")
		}
		diamondCoefficient = resolvedCoefficient
		resolved := resolvedHostDiamondRatio{
			giftTypeRatio:         giftTypeRatio,
			giftTypeRatioRegionID: effectiveRegionID,
			coefficient:           diamondCoefficient,
			coefficientFound:      found,
		}
		hostRatiosByRegion[regionID] = resolved
		return resolved, nil
	}
	// 先解析所有主播政策，再一次性生成有序钱包锁集合。POINT_DIAMOND 目标账户必须和 sender/返币账户
	// 在同一个顺序中加锁，避免批量送礼因目标排列不同形成交叉锁等待。
	for _, target := range command.Targets {
		if !target.TargetIsHost {
			continue
		}
		if _, err := resolveHostRatio(target.TargetHostRegionID); err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
	}

	accountAssetType := chargeAssetType
	if chargeSource == giftChargeSourceBag {
		accountAssetType = ledger.AssetCoin
	}
	lockRequests := []walletAccountLockRequest{{
		UserID:          command.SenderUserID,
		AssetType:       accountAssetType,
		CreateIfMissing: totalChargeAmount == 0 || chargeSource == giftChargeSourceBag,
	}}
	if giftIncomeCoinAmount > 0 {
		for _, target := range command.Targets {
			lockRequests = append(lockRequests, walletAccountLockRequest{
				UserID:          target.TargetUserID,
				AssetType:       ledger.AssetCoin,
				CreateIfMissing: true,
			})
		}
	}
	for _, target := range command.Targets {
		resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID]
		if !target.TargetIsHost || !exists || !resolvedRatio.coefficientFound || resolvedRatio.coefficient.PolicyType != ledger.HostPolicyTypePointDiamond {
			continue
		}
		lockRequests = append(lockRequests, walletAccountLockRequest{
			UserID:          target.TargetUserID,
			AssetType:       ledger.AssetPointDiamond,
			CreateIfMissing: true,
		})
		if target.TargetAgencyOwnerUserID > 0 && resolvedRatio.coefficient.AgencyPointShareBPS > 0 {
			lockRequests = append(lockRequests, walletAccountLockRequest{
				UserID:          target.TargetAgencyOwnerUserID,
				AssetType:       ledger.AssetPointDiamond,
				CreateIfMissing: true,
			})
		}
	}
	accountStates, err := r.lockAccountsOrdered(ctx, tx, lockRequests, nowMs)
	if err != nil {
		return ledger.BatchGiftReceipt{}, err
	}
	senderState := accountStates[walletAccountLockKey(command.SenderUserID, accountAssetType)]
	sender := senderState.account
	if chargeSource != giftChargeSourceBag && sender.AvailableAmount < totalChargeAmount {
		return ledger.BatchGiftReceipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
	}
	events := make([]walletOutboxEvent, 0, len(command.Targets)*5+1)
	if chargeSource == giftChargeSourceBag {
		if resolvedSource.Entitlement == nil {
			return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "bag gift entitlement lock is missing")
		}
		resourceEvent, err := r.consumeGiftEntitlementForSend(ctx, tx, debitGiftCommandFromBatchCommand(command), *resolvedSource.Entitlement, totalGiftQuantity, giftMetadata{
			AppCode:         command.AppCode,
			GiftID:          command.GiftID,
			ResourceID:      giftConfig.ResourceID,
			GiftCount:       command.GiftCount,
			ChargeAssetType: chargeAssetType,
			ChargeSource:    chargeSource,
			EntitlementID:   strings.TrimSpace(command.EntitlementID),
			CoinSpent:       totalChargeAmount,
			SenderUserID:    command.SenderUserID,
			RoomID:          command.RoomID,
		}, nowMs)
		if err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		events = append(events, resourceEvent)
	}

	targetReceipts := make([]ledger.BatchGiftTargetReceipt, 0, len(command.Targets))
	for _, target := range command.Targets {
		hostPeriodDiamondAdded := int64(0)
		hostIncomePolicyType := ""
		hostIncomeAssetType := ""
		hostIncomeAmount := int64(0)
		agencyPointAmount := int64(0)
		agencyPointShareBPS := int32(0)
		hostPeriodCycleKey := ""
		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)
		if target.TargetIsHost {
			resolvedRatio, exists := hostRatiosByRegion[target.TargetHostRegionID]
			if !exists {
				return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "host gift diamond ratio is missing")
			}
			hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
			if resolvedRatio.coefficientFound && resolvedRatio.coefficient.PPM > 0 {
				hostIncomeAmount, err = giftDiamondAmountWithPolicyCoefficient(chargeAmount, resolvedRatio.giftTypeRatio.PPM, resolvedRatio.coefficient.PPM)
				if err != nil {
					return ledger.BatchGiftReceipt{}, err
				}
				hostIncomePolicyType = resolvedRatio.coefficient.PolicyType
				switch resolvedRatio.coefficient.PolicyType {
				case ledger.HostPolicyTypeSalaryDiamond:
					hostIncomeAssetType = ledger.AssetHostPeriodDiamond
					hostPeriodDiamondAdded = hostIncomeAmount
				case ledger.HostPolicyTypePointDiamond:
					hostIncomeAssetType = ledger.AssetPointDiamond
					agencyPointShareBPS = resolvedRatio.coefficient.AgencyPointShareBPS
					if target.TargetAgencyOwnerUserID > 0 {
						agencyPointAmount, err = amountByBPS(hostIncomeAmount, agencyPointShareBPS)
						if err != nil {
							return ledger.BatchGiftReceipt{}, err
						}
					}
				default:
					return ledger.BatchGiftReceipt{}, xerr.New(xerr.InvalidArgument, "host policy type is invalid")
				}
			}
			giftDiamondRatioPercent = resolvedRatio.giftTypeRatio.Percent
			giftDiamondRatioPPM = resolvedRatio.giftTypeRatio.PPM
			giftDiamondRatioRegionID = resolvedRatio.giftTypeRatioRegionID
			hostSalaryPolicyID = resolvedRatio.coefficient.PolicyID
			hostSalaryPolicyVersion = resolvedRatio.coefficient.PolicyVersion
			hostSalaryPolicyDiamondCoefficient = resolvedRatio.coefficient.Value
			hostSalaryPolicyDiamondCoefficientPPM = resolvedRatio.coefficient.PPM
			pointDiamondsPerUSD = resolvedRatio.coefficient.PointDiamondsPerUSD
			coinsPerUSD = resolvedRatio.coefficient.CoinsPerUSD
		}

		transactionID := transactionID(command.AppCode, target.CommandID)
		senderAfter := senderState.account.AvailableAmount - chargeAmount
		if chargeSource == giftChargeSourceBag {

			senderAfter = senderState.account.AvailableAmount
		}
		targetGiftIncomeCoinAmount := giftIncomeCoinAmount
		giftIncomeBalanceAfter := int64(0)
		targetCoinState := accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetCoin)]
		if targetGiftIncomeCoinAmount > 0 {
			if targetCoinState == nil {
				return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "gift income account is missing")
			}
			giftIncomeBalanceAfter = targetCoinState.account.AvailableAmount + targetGiftIncomeCoinAmount
			if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {

				giftIncomeBalanceAfter = senderAfter + targetGiftIncomeCoinAmount
				senderAfter = giftIncomeBalanceAfter
			}
		}
		// 批量事务逐目标生成独立账务交易；这里从当前 sender 版本预测本目标完成后的版本，
		// 下一轮会读取已推进的 senderState，因此最后一条回执自然代表整批最终版本。
		senderBalanceVersionAfter := giftSenderBalanceVersionAfter(
			senderState.account.Version,
			false,
			chargeSource != giftChargeSourceBag,
			targetGiftIncomeCoinAmount > 0 && target.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:                  targetGiftIncomeCoinAmount,
			GiftIncomeRatioPercent:                giftIncomeRatio.Percent,
			GiftIncomeRatioRegionID:               giftIncomeRatioRegionID,
			GiftIncomeBalanceAfter:                giftIncomeBalanceAfter,
			RechargeSevenDayCoins:                 rechargeSnapshot.SevenDayCoins,
			RechargeThirtyDayCoins:                rechargeSnapshot.ThirtyDayCoins,
			LastRechargedAtMS:                     rechargeSnapshot.LastAtMS,
			PaidAtMS:                              nowMs,
			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,
			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,
		}
		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 chargeSource != giftChargeSourceBag {
			debitAccount, err := r.applyTrackedAccountDelta(ctx, tx, senderState, -chargeAmount, 0, nowMs)
			if err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
			if err := r.insertEntry(ctx, tx, walletEntry{
				TransactionID:      transactionID,
				UserID:             command.SenderUserID,
				AssetType:          price.ChargeAssetType,
				AvailableDelta:     -chargeAmount,
				FrozenDelta:        0,
				AvailableAfter:     debitAccount.AvailableAmount,
				FrozenAfter:        debitAccount.FrozenAmount,
				CounterpartyUserID: target.TargetUserID,
				RoomID:             command.RoomID,
				CreatedAtMS:        nowMs,
			}); err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
		}
		if targetGiftIncomeCoinAmount > 0 {
			incomeState := targetCoinState
			if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {

				incomeState = senderState
			}
			incomeAccount, err := r.applyTrackedAccountDelta(ctx, tx, incomeState, targetGiftIncomeCoinAmount, 0, nowMs)
			if err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
			metadata.GiftIncomeBalanceAfter = incomeAccount.AvailableAmount
			if target.TargetUserID == command.SenderUserID && accountAssetType == ledger.AssetCoin {
				metadata.BalanceAfter = incomeAccount.AvailableAmount
			}
			if err := r.insertEntry(ctx, tx, walletEntry{
				TransactionID:      transactionID,
				UserID:             target.TargetUserID,
				AssetType:          ledger.AssetCoin,
				AvailableDelta:     targetGiftIncomeCoinAmount,
				FrozenDelta:        0,
				AvailableAfter:     incomeAccount.AvailableAmount,
				FrozenAfter:        incomeAccount.FrozenAmount,
				CounterpartyUserID: command.SenderUserID,
				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
			metadata.HostIncomeBalanceAfter = hostPeriodDiamondAfter
		}
		var targetPointDiamondState *walletAccountState
		var agencyPointDiamondState *walletAccountState
		if hostIncomeAssetType == ledger.AssetPointDiamond && hostIncomeAmount > 0 {
			targetPointDiamondState = accountStates[walletAccountLockKey(target.TargetUserID, ledger.AssetPointDiamond)]
			if targetPointDiamondState == nil {
				return ledger.BatchGiftReceipt{}, xerr.New(xerr.Internal, "point diamond account is missing")
			}
			combinedAmount := hostIncomeAmount
			if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID == target.TargetUserID {
				combinedAmount, err = checkedAdd(combinedAmount, agencyPointAmount)
				if err != nil {
					return ledger.BatchGiftReceipt{}, err
				}
			}
			pointAccount, err := r.applyTrackedAccountDelta(ctx, tx, targetPointDiamondState, combinedAmount, 0, nowMs)
			if err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
			metadata.HostIncomeBalanceAfter = pointAccount.AvailableAmount
			if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID == target.TargetUserID {
				metadata.AgencyIncomeBalanceAfter = pointAccount.AvailableAmount
			}
			if err := r.insertEntry(ctx, tx, walletEntry{
				TransactionID:      transactionID,
				UserID:             target.TargetUserID,
				AssetType:          ledger.AssetPointDiamond,
				AvailableDelta:     combinedAmount,
				AvailableAfter:     pointAccount.AvailableAmount,
				FrozenAfter:        pointAccount.FrozenAmount,
				CounterpartyUserID: command.SenderUserID,
				RoomID:             command.RoomID,
				CreatedAtMS:        nowMs,
			}); err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
		}
		if agencyPointAmount > 0 && target.TargetAgencyOwnerUserID != target.TargetUserID {
			agencyPointDiamondState = accountStates[walletAccountLockKey(target.TargetAgencyOwnerUserID, ledger.AssetPointDiamond)]
			if agencyPointDiamondState == nil {
				return ledger.BatchGiftReceipt{}, 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.BatchGiftReceipt{}, err
			}
			metadata.AgencyIncomeBalanceAfter = agencyAccount.AvailableAmount
			if err := r.insertEntry(ctx, tx, walletEntry{
				TransactionID:      transactionID,
				UserID:             target.TargetAgencyOwnerUserID,
				AssetType:          ledger.AssetPointDiamond,
				AvailableDelta:     agencyPointAmount,
				AvailableAfter:     agencyAccount.AvailableAmount,
				FrozenAfter:        agencyAccount.FrozenAmount,
				CounterpartyUserID: command.SenderUserID,
				RoomID:             command.RoomID,
				CreatedAtMS:        nowMs,
			}); err != nil {
				return ledger.BatchGiftReceipt{}, err
			}
		}
		// 批量命令的每个 target 都有独立 transaction_id；逐目标写投影既继承原幂等域，
		// 又让整批事务任一失败时同时回滚扣币、积分和所有统计事实。
		if err := r.insertPointDiamondGiftIncomeProjection(ctx, tx, transactionID, metadata, nowMs); err != nil {
			return ledger.BatchGiftReceipt{}, err
		}
		// Each target owns an independent idempotent wallet transaction. Freeze the actual sender snapshot
		// after this target's ledger entries, so aggregate replay can take the last receipt and recover the
		// exact final balance_version without consulting today's wallet account.
		metadata.BalanceAfter = senderState.account.AvailableAmount
		metadata.BalanceVersion = senderState.account.Version
		if err := r.updateTransactionMetadata(ctx, tx, transactionID, metadata, nowMs); err != nil {
			return ledger.BatchGiftReceipt{}, err
		}

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

func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, target ledger.DebitGiftTargetCommand) ledger.DebitGiftCommand {

	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,
		EntitlementID:           command.EntitlementID,
		ChargeSource:            command.ChargeSource,
	}
}

func debitGiftCommandFromBatchCommand(command ledger.BatchDebitGiftCommand) ledger.DebitGiftCommand {

	return ledger.DebitGiftCommand{
		AppCode:        command.AppCode,
		CommandID:      command.CommandID,
		RoomID:         command.RoomID,
		SenderUserID:   command.SenderUserID,
		GiftID:         command.GiftID,
		GiftCount:      command.GiftCount,
		PriceVersion:   command.PriceVersion,
		RegionID:       command.RegionID,
		SenderRegionID: command.SenderRegionID,
		EntitlementID:  command.EntitlementID,
		ChargeSource:   command.ChargeSource,
	}
}
