package service

import (
	"context"
	"fmt"
	"strings"
	"time"

	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	luckygiftv1 "hyapp.local/api/proto/luckygift/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/giftlimits"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/room/command"
	"hyapp/services/room-service/internal/room/outbox"
	"hyapp/services/room-service/internal/room/rank"
	"hyapp/services/room-service/internal/room/state"
)

type giftFlow struct {
	s     *Service
	ctx   context.Context
	req   *roomv1.SendGiftRequest
	robot robotGiftOptions

	cmd command.SendGift

	roomMeta RoomMeta
	// ownerUserID 与 roomLocked 在同一 Room Cell 串行区间读取，RoomGiftSent 必须复用该快照。
	ownerUserID int64
	// roomLocked 在进入钱包/幸运礼物副作用前从同一个 Room Cell 快照固化，保证后续所有飘屏描述同一时刻的锁房状态。
	roomLocked   bool
	rocketConfig RoomRocketConfig
	billing      *walletv1.DebitGiftResponse
	// targetBillings 保存 wallet-service 按目标结算后的账务事实；后续事件、麦位热度和 batch 展示都必须从这里读取，不能重新按客户端输入推导价格。
	targetBillings []giftTargetBilling
	walletDebitMS  int64

	settledCommand command.SendGift
	luckyEnabled   bool
	luckyGift      *roomv1.LuckyGiftDrawResult
	luckyGifts     []*roomv1.LuckyGiftDrawResult

	heatValue               int64
	targetGiftValueDeltas   map[int64]int64
	targetCurrentGiftValues map[int64]int64
	rocketApply             rocketGiftApplyResult
	commandPayload          []byte

	giftEvents                 []outbox.Record
	records                    []outbox.Record
	robotDisplayRecords        []outbox.Record
	rocketProgress             *roomRocketProgressPending
	batchDisplay               *roomv1.SendGiftBatchDisplay
	batchDisplayEnabled        bool
	suppressDirectIMEventTypes map[string]bool

	// operationStarted 后任何失败都必须由服务端更新 saga；客户端断线不能成为扣款后完成结算的必要条件。
	operationStarted bool
	debitCompleted   bool
	drawCompleted    bool
	recovering       bool
	operation        GiftOperation
}

func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions) *giftFlow {
	displayMode := ""
	if options.BatchDisplay {
		displayMode = "batch"
	}
	// SendGift 的账务不在 room-service 内结算，但房间表现、热度和榜单由 Room Cell 同步更新。
	cmd := command.SendGift{
		Base:            baseFromMeta(req.GetMeta()),
		TargetType:      normalizeGiftTargetType(req.GetTargetType()),
		TargetUserIDs:   normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()),
		GiftID:          req.GetGiftId(),
		PoolID:          strings.TrimSpace(req.GetPoolId()),
		GiftCount:       req.GetGiftCount(),
		EntitlementID:   strings.TrimSpace(req.GetEntitlementId()),
		Source:          strings.TrimSpace(req.GetSource()),
		DisplayMode:     displayMode,
		ComboSessionID:  strings.TrimSpace(req.GetComboSessionId()),
		BatchSeq:        req.GetBatchSeq(),
		SenderCountryID: req.GetSenderCountryId(),
		SenderRegionID:  req.GetSenderRegionId(),
		// 注册时间随 command 一起进入 gift_operation 和最终 command log；恢复时使用首次快照，
		// 不在 room-service 回查 user-service，也不让重试请求覆盖已经用于开奖判定的事实。
		SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(),
		TargetIsHost:         req.GetTargetIsHost(),
		// 工资区域由 gateway 从 user-service host profile 注入；房间 visible_region_id 只用于房间/礼物可见性。
		TargetHostRegionID:        req.GetTargetHostRegionId(),
		TargetAgencyOwnerUserID:   req.GetTargetAgencyOwnerUserId(),
		TargetHostScopes:          giftTargetHostScopesFromProto(req.GetTargetHostScopes()),
		SenderDisplayProfile:      giftDisplayProfileFromProto(req.GetSenderDisplayProfile()),
		TargetDisplayProfiles:     giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()),
		RobotGift:                 options.Robot.Enabled && !options.Robot.RealRoomHeat,
		RobotWalletGift:           options.Robot.Enabled,
		SyntheticLuckyGift:        options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
		SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0),
		// 0 留给 syntheticLuckyGiftResult 统一选择 5x 默认值；这里提前写成 1x 会让倍数和展示中奖额互相矛盾。
		SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 0),
	}
	if cmd.TargetType == "" {
		cmd.TargetType = "user"
	}
	cmd.RequestedTargetUserIDs = append([]int64(nil), cmd.TargetUserIDs...)
	if cmd.TargetType == "user" && len(cmd.TargetUserIDs) > 0 {
		cmd.TargetUserID = cmd.TargetUserIDs[0]
	}
	return &giftFlow{s: s, ctx: ctx, req: req, robot: options.Robot, cmd: cmd, settledCommand: cmd, batchDisplayEnabled: options.BatchDisplay}
}

func (f *giftFlow) validateBeforeDebit(current *state.RoomState) error {
	// 普通礼物事实的房主身份属于 Room Cell 核心状态；异步 activity 不能用发送者或收礼人替代。
	f.ownerUserID = current.OwnerUserID
	// 新命令在 Room Cell 串行区间固化锁状态；恢复优先复用首次快照。历史 saga 没有该字段时才用当前 Cell 状态兼容补齐。
	if f.recovering && f.cmd.RoomLocked != nil {
		f.roomLocked = *f.cmd.RoomLocked
	} else {
		f.roomLocked = strings.TrimSpace(current.RoomPasswordHash) != ""
		locked := f.roomLocked
		f.cmd.RoomLocked = &locked
		f.settledCommand.RoomLocked = &locked
	}
	if !f.recovering {
		if _, exists := current.OnlineUsers[f.cmd.ActorUserID()]; !exists {
			// 送礼者必须在房间业务 presence 内；没有进入 Room Cell 的用户不能触发钱包扣费。
			return xerr.New(xerr.NotFound, "sender not in room")
		}
	}
	if f.cmd.TargetType != "user" {
		// v1 HTTP 契约已预留 all_mic/all_room/couple；账务拆单和房间表现策略补齐前先 fail-close。
		return xerr.New(xerr.InvalidArgument, "target_type is unsupported")
	}
	if len(f.cmd.TargetUserIDs) == 0 || f.cmd.TargetUserID <= 0 {
		return xerr.New(xerr.InvalidArgument, "target_user_ids is required")
	}
	if f.recovering {
		// operation 只会在首次 presence/目标校验通过后创建；扣款未知或已完成时必须使用当时固化的实际目标继续结算，
		// 不能因用户随后离房而永久卡住已扣款 saga。
	} else if f.batchDisplayEnabled {
		if len(f.cmd.RequestedTargetUserIDs) < 2 {
			// batch-send 只承载“全部人/多人”语义；过滤后可以只剩一个真实送达目标，但原始请求不能退化成单人入口。
			return xerr.New(xerr.InvalidArgument, "batch target_user_ids requires at least two users")
		}
		if !f.retainOnlineBatchTargets(current) {
			// 全部目标都已离房时不能做成功空操作，也不能扣费。
			return xerr.New(xerr.NotFound, "target not in room")
		}
	} else {
		for _, targetUserID := range f.cmd.TargetUserIDs {
			if _, exists := current.OnlineUsers[targetUserID]; !exists {
				// 普通 SendGift 继续 fail-close，避免旧单目标入口把“目标不存在”静默吞掉。
				return xerr.New(xerr.NotFound, "target not in room")
			}
		}
	}
	if f.robot.Enabled && !f.recovering {
		if err := requireRobotGiftParticipants(current, f.cmd.ActorUserID(), f.cmd.TargetUserIDs, f.robot.RobotUserIDs, !f.robot.RealRoomHeat); err != nil {
			return err
		}
	}
	if f.cmd.GiftID == "" || f.cmd.GiftCount <= 0 {
		// 礼物 ID 和数量是钱包查服务端价格的最小输入，客户端不再提交礼物单价。
		return xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
	}
	if len(f.cmd.TargetUserIDs) > maxGiftTargetsPerCommand {
		// 限制必须由 Room owner 再执行一次，不能只相信 gateway；内部调用、旧网关或回放入口
		// 都可能绕过 HTTP 层。gift_count 和目标数分别限制，避免分配异常大的账务请求。
		return xerr.New(xerr.InvalidArgument, "gift command exceeds owner limits")
	}
	if _, ok := giftlimits.WorkUnits(int64(f.cmd.GiftCount), len(f.cmd.TargetUserIDs)); !ok {
		// 幸运礼物按“数量 x 目标数”逐份推进奖池状态，真实成本不是 HTTP 请求数；总单位上限
		// 防止一个合法外形的大批次独占 Room Cell 和 lucky-gift 数据库事务。
		return xerr.New(xerr.InvalidArgument, "gift command units exceed owner limit")
	}
	if len(f.cmd.ComboSessionID) > maxGiftComboSessionIDLength || f.cmd.BatchSeq < 0 || (f.cmd.ComboSessionID != "" && f.cmd.BatchSeq <= 0) {
		return xerr.New(xerr.InvalidArgument, "gift combo metadata is invalid")
	}
	if strings.EqualFold(f.cmd.Source, "bag") && strings.TrimSpace(f.cmd.EntitlementID) == "" {
		// 背包送礼只改变扣费来源，不改变房间事件；因此必须在扣费前明确指定要扣的权益，不能让 wallet 猜测某个库存。
		return xerr.New(xerr.InvalidArgument, "entitlement_id is required for bag gift")
	}
	return nil
}

func (f *giftFlow) retainOnlineBatchTargets(current *state.RoomState) bool {
	if f == nil || current == nil || len(f.cmd.TargetUserIDs) == 0 {
		return false
	}
	kept := make([]int64, 0, len(f.cmd.TargetUserIDs))
	for _, targetUserID := range f.cmd.TargetUserIDs {
		if _, exists := current.OnlineUsers[targetUserID]; exists {
			kept = append(kept, targetUserID)
		}
	}
	if len(kept) == 0 {
		return false
	}
	if len(kept) == len(f.cmd.TargetUserIDs) {
		return true
	}
	originalPrimaryTargetUserID := f.cmd.TargetUserID
	primaryHostScope, hasPrimaryHostScope := giftTargetHostScopeForExact(f.cmd.TargetHostScopes, kept[0])
	// 后续 wallet、幸运礼物、RoomGiftSent、RoomGiftBatchSent 和 command log 都只能看到实际送达目标。
	f.cmd.TargetUserIDs = kept
	f.cmd.TargetUserID = kept[0]
	f.cmd.TargetHostScopes = filterGiftTargetHostScopes(f.cmd.TargetHostScopes, kept)
	f.cmd.TargetDisplayProfiles = filterGiftDisplayProfiles(f.cmd.TargetDisplayProfiles, kept)
	if hasPrimaryHostScope {
		f.cmd.TargetIsHost = primaryHostScope.TargetIsHost
		f.cmd.TargetHostRegionID = primaryHostScope.TargetHostRegionID
		f.cmd.TargetAgencyOwnerUserID = primaryHostScope.TargetAgencyOwnerUserID
	} else if originalPrimaryTargetUserID != f.cmd.TargetUserID {
		// 旧单目标 host 字段只描述原主目标；主目标被跳过时必须清空，避免工资入账串到剩余用户。
		f.cmd.TargetIsHost = false
		f.cmd.TargetHostRegionID = 0
		f.cmd.TargetAgencyOwnerUserID = 0
	}
	f.settledCommand.TargetUserIDs = append([]int64(nil), f.cmd.TargetUserIDs...)
	f.settledCommand.TargetUserID = f.cmd.TargetUserID
	f.settledCommand.TargetIsHost = f.cmd.TargetIsHost
	f.settledCommand.TargetHostRegionID = f.cmd.TargetHostRegionID
	f.settledCommand.TargetAgencyOwnerUserID = f.cmd.TargetAgencyOwnerUserID
	f.settledCommand.TargetHostScopes = append([]command.GiftTargetHostScope(nil), f.cmd.TargetHostScopes...)
	f.settledCommand.TargetDisplayProfiles = append([]command.GiftDisplayProfile(nil), f.cmd.TargetDisplayProfiles...)
	return true
}

func (f *giftFlow) prepareRoomFacts() error {
	roomMeta, exists, err := f.s.repository.GetRoomMeta(f.ctx, f.cmd.RoomID())
	if err != nil {
		return err
	}
	if !exists {
		return xerr.New(xerr.NotFound, "room not found")
	}
	f.roomMeta = roomMeta
	if f.cmd.PoolID != "" && !f.cmd.SyntheticLuckyGift {
		if f.recovering {
			// saga 只在首次 CheckLuckyGift 已通过后创建；恢复阶段直接复用稳定 command_id 开奖，
			// 避免规则临时下线把已经扣款的操作卡在预检查上。
			f.luckyEnabled = true
		} else {
			// 客户端显式传 pool_id 时必须先检查活动规则；规则未启用就拒绝，避免先扣费再发现不能抽奖。
			if f.s.luckyGift == nil {
				return xerr.New(xerr.Unavailable, "lucky gift service is not configured")
			}
			checkResp, err := f.s.luckyGift.CheckLuckyGift(f.ctx, &luckygiftv1.CheckLuckyGiftRequest{
				Meta:     luckyGiftMetaFromRoom(f.ctx, f.req.GetMeta()),
				UserId:   f.cmd.ActorUserID(),
				DeviceId: f.cmd.DeviceID,
				RoomId:   f.cmd.RoomID(),
				GiftId:   f.cmd.GiftID,
				PoolId:   f.cmd.PoolID,
			})
			if err != nil {
				return err
			}
			if checkResp == nil || !checkResp.GetEnabled() {
				return xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
			}
			// 规则版本由 lucky-gift owner 判定并随 room saga 命令固化；恢复阶段不重新 Check，
			// 防止后台切换策略后把一笔已扣费命令从 fixed_v2 兼容语义改成 dynamic_v3。
			f.cmd.LuckyGiftStrategyVersion = normalizeLuckyGiftStrategyVersion(checkResp.GetStrategyVersion())
			f.settledCommand.LuckyGiftStrategyVersion = f.cmd.LuckyGiftStrategyVersion
			// Check 只说明“允许按该 pool 抽奖”，真实价格、区域可用性和扣费仍以 wallet-service 为准。
			f.luckyEnabled = true
		}
	}
	rocketConfig, err := f.s.roomRocketConfig(f.ctx)
	if err != nil {
		return err
	}
	f.rocketConfig = rocketConfig
	return nil
}

func (f *giftFlow) debitAndSettle(now time.Time) error {
	return f.s.withLuckyGiftSendLock(f.ctx, f.luckyEnabled, f.cmd.ActorUserID(), func() error {
		// 钱包扣费在房间状态变更前完成；失败时不写 command log、不进入 Room Cell 提交态。
		walletStartedAt := time.Now()
		var err error
		f.billing, f.targetBillings, err = f.s.debitGiftTargets(f.ctx, f.cmd, f.roomMeta)
		f.walletDebitMS = elapsedMS(walletStartedAt)
		if err != nil {
			return err
		}
		if f.billing == nil {
			return xerr.New(xerr.Unavailable, "wallet debit response is empty")
		}
		f.settledCommand.BillingReceiptID = f.billing.GetBillingReceiptId()
		f.settledCommand.CoinSpent = f.billing.GetCoinSpent()
		f.settledCommand.HeatValue = f.billing.GetHeatValue()
		f.settledCommand.PriceVersion = f.billing.GetPriceVersion()
		f.settledCommand.GiftTypeCode = f.billing.GetGiftTypeCode()
		f.settledCommand.HostPeriodDiamondAdded = f.billing.GetHostPeriodDiamondAdded()
		f.settledCommand.HostPeriodCycleKey = f.billing.GetHostPeriodCycleKey()
		if err := f.markGiftOperationDebited(now); err != nil {
			// wallet 已返回稳定回执但 saga 落盘失败时不能继续抽奖；后台会用相同 command_id 重取钱包回执后推进。
			return err
		}
		if f.cmd.SyntheticLuckyGift {
			f.luckyGift = syntheticLuckyGiftResult(f.cmd, f.targetBillings, now)
			if f.luckyGift != nil {
				f.luckyGifts = []*roomv1.LuckyGiftDrawResult{f.luckyGift}
			}
		} else if !f.luckyEnabled {
			// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑；新客户端应传明确 pool_id。
			f.luckyEnabled = f.s.shouldDrawLuckyGift(f.cmd.PoolID, f.billing.GetGiftTypeCode())
		}
		if !f.cmd.SyntheticLuckyGift && f.luckyEnabled {
			f.luckyGifts, err = f.s.executeLuckyGiftDraws(f.ctx, f.req.GetMeta(), f.cmd, f.roomMeta, f.roomLocked, f.targetBillings)
			if err != nil {
				// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试；不能落普通礼物后丢失幸运礼物抽奖事实。
				return err
			}
			if len(f.luckyGifts) > 0 {
				// lucky_gifts 保留每个收礼目标的独立抽奖事实；lucky_gift 返回整次送礼的聚合表现，复用已有批量送礼的倍率累加语义。
				f.luckyGift = aggregateLuckyGiftResults(f.cmd.ID(), f.luckyGifts)
			}
		}
		if err := f.markGiftOperationDrawn(now); err != nil {
			// 抽奖 owner 已按 command_id 幂等；即使进度写入失败，恢复也只会重放同一结果，不会再次产生奖金。
			return err
		}
		return nil
	})
}

func (f *giftFlow) applyRoomState(now time.Time, current *state.RoomState, localRank *rank.LocalRank) error {
	f.heatValue = f.billing.GetHeatValue()
	// 扣费成功后，Room Cell 同步更新热度和本地礼物榜；这些状态才是 IM 和 snapshot 的权威来源。
	current.Heat += f.heatValue
	current.GiftRank = localRank.ApplyGift(f.cmd.ActorUserID(), f.heatValue, now)
	f.targetGiftValueDeltas = make(map[int64]int64, len(f.targetBillings))
	f.targetCurrentGiftValues = make(map[int64]int64, len(f.targetBillings))
	for _, targetBilling := range f.targetBillings {
		if targetBilling.Billing == nil || targetBilling.TargetUserID <= 0 {
			continue
		}
		targetGiftValue := targetBilling.Billing.GetHeatValue()
		if userState := current.OnlineUsers[targetBilling.TargetUserID]; userState != nil {
			// 收礼用户热度完全使用 wallet-service 已结算的 HeatValue，room-service 不重新判断普通/幸运/超级幸运比例。
			if targetGiftValue > 0 {
				userState.GiftValue += targetGiftValue
				f.targetGiftValueDeltas[targetBilling.TargetUserID] += targetGiftValue
			}
			f.targetCurrentGiftValues[targetBilling.TargetUserID] = userState.GiftValue
		}
	}
	if len(f.targetGiftValueDeltas) > 0 {
		// 命令日志只保存每个目标本次增量；累计值由在线用户态按 replay 顺序重建。
		f.settledCommand.TargetGiftValues = f.targetGiftValueDeltas
	}
	current.Version++
	rocketApply, err := f.s.applyRoomRocketGift(now, current, f.rocketConfig, f.cmd, &f.settledCommand, f.billing, f.roomMeta)
	if err != nil {
		return err
	}
	f.rocketApply = rocketApply
	commandPayload, err := command.Serialize(f.settledCommand)
	if err != nil {
		return err
	}
	f.commandPayload = commandPayload
	return nil
}

func (f *giftFlow) buildMutationResult(now time.Time, current *state.RoomState) (mutationResult, []outbox.Record, error) {
	giftEvents, err := buildRoomGiftSentRecords(current.RoomID, current.Version, now, f.cmd, f.roomMeta, f.roomLocked, f.ownerUserID, f.targetBillings, f.targetCurrentGiftValues)
	if err != nil {
		return mutationResult{}, nil, err
	}
	f.giftEvents = giftEvents
	f.records = make([]outbox.Record, 0, len(f.giftEvents)+4)
	f.robotDisplayRecords = make([]outbox.Record, 0, len(f.giftEvents)+5)
	// RobotWalletGift 只表示这笔礼物由机器人钱包结算；真实房机器人仍会同步写房间贡献、火箭和麦位热度，但展示事件改为提交后直发 IM，避免机器人流量抢占真人主 outbox。
	robotDisplayGift := f.cmd.RobotWalletGift
	if robotDisplayGift {
		f.robotDisplayRecords = append(f.robotDisplayRecords, f.giftEvents...)
	} else {
		f.records = append(f.records, f.giftEvents...)
	}
	heatEvent, err := buildRoomHeatChangedRecord(current.RoomID, current.Version, now, f.heatValue, current.Heat)
	if err != nil {
		return mutationResult{}, nil, err
	}
	rankItem := findRankItem(current.GiftRank, f.cmd.ActorUserID())
	rankEvent, err := buildRoomRankChangedRecord(current.RoomID, current.Version, now, rankItem)
	if err != nil {
		return mutationResult{}, nil, err
	}
	if robotDisplayGift {
		// 房间热度和麦位收礼热度仍要给客户端展示；是否真实计入统计由 RobotGift 语义单独控制，不能再拿投递通道反推业务真实性。
		f.robotDisplayRecords = append(f.robotDisplayRecords, heatEvent, rankEvent)
	} else {
		f.records = append(f.records, heatEvent, rankEvent)
	}
	if !f.cmd.RobotGift {
		// 真实房间礼物才广播火箭进度；真人房机器人送礼使用机器人钱包结算，但仍是真实房间贡献，所以火箭事件跟随机器人展示直发通道投递。
		if f.rocketApply.progressEvent != nil {
			progressRecord, err := outbox.Build(current.RoomID, "RoomRocketFuelChanged", current.Version, now, f.rocketApply.progressEvent)
			if err != nil {
				return mutationResult{}, nil, err
			}
			progressRecord.AppCode = appcode.FromContext(f.ctx)
			if progressRecord.Envelope != nil {
				progressRecord.Envelope.AppCode = progressRecord.AppCode
			}
			// 燃料变化是高频展示事件，不逐笔写 outbox；命令提交成功后由内存合并器按 1 秒窗口只落最大进度。
			f.rocketProgress = &roomRocketProgressPending{
				record:      progressRecord,
				robotLane:   robotDisplayGift,
				rocketID:    f.rocketApply.progressEvent.GetRocketId(),
				level:       f.rocketApply.progressEvent.GetLevel(),
				currentFuel: f.rocketApply.progressEvent.GetCurrentFuel(),
				roomVersion: current.Version,
			}
		}
		if f.rocketApply.ignited != nil {
			// 点火飘屏可能在其他房间展示；锁状态必须复用本次送礼命令已固化的 Room Cell 快照。
			f.rocketApply.ignited.RoomLocked = f.roomLocked
			ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, f.rocketApply.ignited)
			if err != nil {
				return mutationResult{}, nil, err
			}
			if robotDisplayGift {
				f.robotDisplayRecords = append(f.robotDisplayRecords, ignitedEvent)
			} else {
				f.records = append(f.records, ignitedEvent)
			}
		}
	}
	if f.cmd.SyntheticLuckyGift && f.luckyGift != nil {
		drawEvent, err := outbox.Build(current.RoomID, "RoomRobotLuckyGiftDrawn", current.Version, now, &roomeventsv1.RoomRobotLuckyGiftDrawn{
			DrawId:               f.luckyGift.GetDrawId(),
			CommandId:            f.cmd.ID(),
			SenderUserId:         f.cmd.ActorUserID(),
			TargetUserId:         f.cmd.TargetUserID,
			GiftId:               f.cmd.GiftID,
			GiftCount:            f.cmd.GiftCount,
			PoolId:               f.cmd.PoolID,
			MultiplierPpm:        f.luckyGift.GetMultiplierPpm(),
			BaseRewardCoins:      f.luckyGift.GetBaseRewardCoins(),
			EffectiveRewardCoins: f.luckyGift.GetEffectiveRewardCoins(),
			CreatedAtMs:          f.luckyGift.GetCreatedAtMs(),
			VisibleRegionId:      f.roomMeta.VisibleRegionID,
			IsRobot:              true,
			Synthetic:            true,
		})
		if err != nil {
			return mutationResult{}, nil, err
		}
		if robotDisplayGift {
			f.robotDisplayRecords = append(f.robotDisplayRecords, drawEvent)
		} else {
			f.records = append(f.records, drawEvent)
		}
	}
	if f.batchDisplayEnabled {
		// 新 batch-send 只合并房间展示消息：逐目标 RoomGiftSent 已经在 giftEvents 中保留为 durable 业务事实。
		f.batchDisplay = buildSendGiftBatchDisplay(f.cmd, f.billing, f.targetBillings, f.targetCurrentGiftValues, f.luckyGifts, current.Heat)
		batchEvent := roomGiftBatchSentEventFromDisplay(f.batchDisplay, f.roomMeta.VisibleRegionID)
		batchRecord, err := outbox.Build(current.RoomID, "RoomGiftBatchSent", current.Version, now, batchEvent)
		if err != nil {
			return mutationResult{}, nil, err
		}
		f.batchDisplay.EventId = batchRecord.EventID
		f.records = append(f.records, batchRecord)
		f.suppressDirectIMEventTypes = map[string]bool{"RoomGiftSent": true}
	}
	return f.toMutationResult(now, current), f.records, nil
}

func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mutationResult {
	finalCoinBalanceAfter := giftFinalCoinBalanceAfter(f.billing)
	firstTargetDisplayProfile := giftDisplayProfileForUser(f.cmd.TargetDisplayProfiles, f.cmd.TargetUserID)
	result := mutationResult{
		snapshot:                   current.ToProto(),
		billingReceiptID:           f.billing.GetBillingReceiptId(),
		coinBalanceAfter:           finalCoinBalanceAfter,
		giftIncomeBalanceAfter:     f.billing.GetGiftIncomeBalanceAfter(),
		roomHeat:                   current.Heat,
		giftRank:                   cloneProtoRank(current.GiftRank),
		luckyGift:                  f.luckyGift,
		luckyGifts:                 f.luckyGifts,
		batchDisplay:               f.batchDisplay,
		suppressDirectIMEventTypes: f.suppressDirectIMEventTypes,
		commandPayload:             f.commandPayload,
		walletDebitMS:              f.walletDebitMS,
		robotDisplayRecords:        f.robotDisplayRecords,
		roomRocketProgress:         f.rocketProgress,
		roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
			AppCode:      appcode.FromContext(f.ctx),
			RoomID:       current.RoomID,
			RegionID:     f.roomMeta.VisibleRegionID,
			CoinSpent:    roomGiftLeaderboardValue(f.billing),
			OccurredAtMS: now.UnixMilli(),
		},
		roomUserContributionPeriodStats: roomContributionPeriodStatIncrements(appcode.FromContext(f.ctx), current.RoomID, f.cmd.ActorUserID(), f.heatValue, now.UnixMilli()),
		roomUserGiftStats: []RoomUserGiftStatIncrement{
			{
				AppCode:      appcode.FromContext(f.ctx),
				RoomID:       current.RoomID,
				UserID:       f.cmd.ActorUserID(),
				GiftValue:    f.heatValue,
				LastGiftAtMS: now.UnixMilli(),
			},
		},
		roomWeeklyContribution: &RoomWeeklyContributionIncrement{
			AppCode:    appcode.FromContext(f.ctx),
			RoomID:     current.RoomID,
			GiftValue:  f.heatValue,
			OccurredMS: now.UnixMilli(),
		},
		syncEvent: &tencentim.RoomEvent{
			// 同步广播只选 GiftSent 主事件作为客户端房间系统消息，Heat/Rank 通过字段携带。
			EventID:      f.giftEvents[0].EventID,
			RoomID:       current.RoomID,
			EventType:    "room_gift_sent",
			ActorUserID:  tencentim.FormatOptionalUserID(f.cmd.ActorUserID()),
			TargetUserID: tencentim.FormatOptionalUserID(f.cmd.TargetUserID),
			GiftValue:    f.heatValue,
			RoomHeat:     current.Heat,
			RoomVersion:  current.Version,
			Attributes: map[string]string{
				"gift_id":                         f.cmd.GiftID,
				"room_locked":                     fmt.Sprintf("%t", f.roomLocked),
				"pool_id":                         f.cmd.PoolID,
				"gift_count":                      fmt.Sprintf("%d", f.cmd.GiftCount),
				"billing_receipt_id":              f.billing.GetBillingReceiptId(),
				"coin_spent":                      fmt.Sprintf("%d", f.settledCommand.CoinSpent),
				"target_count":                    fmt.Sprintf("%d", len(f.cmd.TargetUserIDs)),
				"target_user_ids":                 giftTargetUserIDsAttribute(f.cmd.TargetUserIDs),
				"price_version":                   f.settledCommand.PriceVersion,
				"host_period_diamond_added":       fmt.Sprintf("%d", f.settledCommand.HostPeriodDiamondAdded),
				"host_period_cycle_key":           f.settledCommand.HostPeriodCycleKey,
				"gift_type_code":                  f.billing.GetGiftTypeCode(),
				"cp_relation_type":                f.billing.GetCpRelationType(),
				"gift_name":                       f.billing.GetGiftName(),
				"gift_icon_url":                   f.billing.GetGiftIconUrl(),
				"gift_animation_url":              f.billing.GetGiftAnimationUrl(),
				"target_gift_value":               fmt.Sprintf("%d", f.targetCurrentGiftValues[f.cmd.TargetUserID]),
				"is_robot_gift":                   fmt.Sprintf("%t", f.cmd.RobotWalletGift),
				"synthetic_lucky_gift":            fmt.Sprintf("%t", f.cmd.SyntheticLuckyGift),
				"sender_name":                     giftDisplayName(f.cmd.SenderDisplayProfile),
				"sender_avatar":                   strings.TrimSpace(f.cmd.SenderDisplayProfile.Avatar),
				"sender_display_user_id":          strings.TrimSpace(f.cmd.SenderDisplayProfile.DisplayUserID),
				"sender_pretty_display_user_id":   strings.TrimSpace(f.cmd.SenderDisplayProfile.PrettyDisplayUserID),
				"receiver_nickname":               giftDisplayName(firstTargetDisplayProfile),
				"receiver_avatar":                 strings.TrimSpace(firstTargetDisplayProfile.Avatar),
				"receiver_display_user_id":        strings.TrimSpace(firstTargetDisplayProfile.DisplayUserID),
				"receiver_pretty_display_user_id": strings.TrimSpace(firstTargetDisplayProfile.PrettyDisplayUserID),
			},
		},
	}
	result.giftV2 = &roomv1.SendGiftResultV2{
		ApiVersion:             2,
		CommandId:              f.cmd.ID(),
		ComboSessionId:         f.cmd.ComboSessionID,
		BatchSeq:               f.cmd.BatchSeq,
		IdempotentReplay:       false,
		CommittedRoomVersion:   current.Version,
		BillingReceiptId:       f.billing.GetBillingReceiptId(),
		CoinBalanceAfter:       finalCoinBalanceAfter,
		GiftIncomeBalanceAfter: f.billing.GetGiftIncomeBalanceAfter(),
		SettledGiftCount:       f.cmd.GiftCount,
		SettledTargetUserIds:   append([]int64(nil), f.cmd.TargetUserIDs...),
		BalanceVersion:         f.billing.GetBalanceVersion(),
		LuckyGift:              f.luckyGift,
		LuckyGifts:             append([]*roomv1.LuckyGiftDrawResult(nil), f.luckyGifts...),
		LuckyHits:              luckyHitsFromResults(f.luckyGifts),
		BatchDisplay:           f.batchDisplay,
		RoomHeat:               current.Heat,
		GiftRank:               cloneProtoRank(current.GiftRank),
		OperationStatus:        GiftOperationStatusCommitted,
	}
	if f.cmd.RobotGift {
		// 机器人房间可以增加房间热度和麦位收礼热度，但不进入跨房礼物榜和房间送礼统计。
		result.roomGiftLeaderboard = nil
		result.roomUserContributionPeriodStats = nil
		result.roomUserGiftStats = nil
		result.roomWeeklyContribution = nil
	}
	return result
}
