package service

import (
	"context"
	"fmt"
	"strings"

	"google.golang.org/protobuf/proto"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/xerr"
	"hyapp/services/room-service/internal/room/cell"
	"hyapp/services/room-service/internal/room/command"
	"hyapp/services/room-service/internal/room/rank"
	"hyapp/services/room-service/internal/room/state"
	"hyapp/services/room-service/internal/router"
)

// roomOwnedByOtherNodeMsgPrefix 是跨节点执行权冲突的稳定错误文案前缀；
// isRoomOwnedByOtherNodeError 依赖它把这类短暂冲突和业务终态 Conflict 区分开。
const roomOwnedByOtherNodeMsgPrefix = "room is owned by "

// roomOwnedByOtherNodeError 表示房间执行权由其他节点的有效 lease 持有。
func roomOwnedByOtherNodeError(nodeID string) error {
	return xerr.New(xerr.Conflict, roomOwnedByOtherNodeMsgPrefix+nodeID)
}

// isRoomOwnedByOtherNodeError 判断错误是否为跨节点执行权冲突。
// 它只在 lease TTL 窗口内成立，不代表业务不可重试；补偿判定不能把它当作终态。
func isRoomOwnedByOtherNodeError(err error) bool {
	return xerr.CodeOf(err) == xerr.Conflict && strings.HasPrefix(xerr.MessageOf(err), roomOwnedByOtherNodeMsgPrefix)
}

// ensureCell 确保当前节点拥有房间执行权，并在本地缺少 Cell 时从 MySQL 恢复。
func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, router.Lease, error) {
	// 每次写命令先刷新或接管 lease，确保同一房间同一时刻只有一个执行节点。
	lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, s.clock.Now(), s.leaseTTL)
	if err != nil {
		return nil, router.Lease{}, err
	}

	if lease.NodeID != s.nodeID {
		// 仍有其他有效 owner 时当前节点不能执行该房间命令。
		return nil, router.Lease{}, roomOwnedByOtherNodeError(lease.NodeID)
	}

	if roomCell := s.loadCellForLease(ctx, roomID, lease); roomCell != nil {
		// 本节点已经装载该房间且 lease token 连续，才能直接复用已有 Room Cell。
		return roomCell, lease, nil
	}

	if s.isDraining() {
		return nil, router.Lease{}, xerr.New(xerr.Unavailable, "room service is draining")
	}

	// 没有内存 Cell 时从持久化恢复，支持 lease 过期后的新节点接管。
	roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
	if err != nil {
		return nil, router.Lease{}, err
	}

	if !exists {
		// 没有 meta 说明房间从未创建，不能凭请求临时造状态。
		return nil, router.Lease{}, xerr.New(xerr.NotFound, "room not found")
	}

	restoredState, err := s.recoverRoom(ctx, roomMeta)
	if err != nil {
		// 恢复失败时返回 unavailable，调用方应重试或等待节点修复，而不是返回 room not found。
		return nil, router.Lease{}, xerr.New(xerr.Unavailable, fmt.Sprintf("recover room failed: %v", err))
	}

	// 恢复完成后创建新的 Room Cell，并从恢复后的 GiftRank 重建 LocalRank 索引。
	roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit))
	s.installCell(ctx, roomID, roomCell, lease.LeaseToken)

	return roomCell, lease, nil
}

// recoverRoom 按“最新快照 + 之后的可回放命令”重建 Room Cell 状态。
func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.RoomState, error) {
	// 恢复优先从最新快照开始，再回放快照版本之后的 replayable command log。
	snapshotRecord, hasSnapshot, err := s.repository.GetLatestSnapshot(ctx, roomMeta.RoomID)
	if err != nil {
		return nil, err
	}

	var recovered *state.RoomState
	var fromVersion int64

	if hasSnapshot {
		// 快照 payload 是 roomv1.RoomSnapshot 的 protobuf 序列化结果。
		var snapshot roomv1.RoomSnapshot
		if err := proto.Unmarshal(snapshotRecord.Payload, &snapshot); err != nil {
			return nil, err
		}

		recovered = state.FromProto(&snapshot)
		recovered.VisibleRegionID = roomMeta.VisibleRegionID
		// 密码哈希不进入对外 protobuf 快照，最新快照恢复时从 rooms 元数据补齐。
		recovered.RoomPasswordHash = roomMeta.RoomPasswordHash
		fromVersion = snapshot.GetVersion()
	} else {
		// 没有快照时从 rooms meta 重建初始状态，再从版本 0 回放命令。
		recovered = state.NewRoomState(roomMeta.RoomID, roomMeta.OwnerUserID, roomMeta.SeatCount, roomMeta.Mode)
		recovered.Status = roomMeta.Status
		recovered.VisibleRegionID = roomMeta.VisibleRegionID
		recovered.RoomPasswordHash = roomMeta.RoomPasswordHash
		fromVersion = 0
	}

	records, err := s.repository.ListCommandsAfter(ctx, roomMeta.RoomID, fromVersion)
	if err != nil {
		return nil, err
	}

	for _, record := range records {
		if !record.Replayable {
			// 不可回放命令保留审计价值，但不参与状态重建。
			continue
		}

		cmd, err := command.Deserialize(record.CommandType, record.Payload)
		if err != nil {
			return nil, err
		}

		if err := replay(recovered, cmd, record.CreatedAtMS); err != nil {
			// replay 失败说明命令日志无法被当前状态机解释，不能继续装载错误 Cell。
			return nil, err
		}
		// room_version 是持久化事务提交后的权威版本。历史日志允许非回放命令占用版本，SendGift 也可能
		// 在同版本按自增 ID 收敛；仅依赖 replay 内部 Version++ 会在这些边界下产生空洞或超前。
		recovered.Version = record.RoomVersion
	}

	return recovered, nil
}

// replay 只重放房间内确定性状态变更，不重复执行钱包扣费或腾讯云 IM 投递。
func replay(current *state.RoomState, cmd command.Command, committedAtMS int64) error {
	// replay 只重建房间内状态，不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。
	switch typed := cmd.(type) {
	case *command.CreateRoom:
		current.VisibleRegionID = typed.VisibleRegionID
		// 无快照恢复时，创建命令负责把列表/详情展示资料还原到 RoomExt。
		applyRoomProfileExt(current, roomProfileInput{
			Name:        typed.RoomName,
			Avatar:      typed.RoomAvatar,
			Description: typed.RoomDescription,
			ShortID:     typed.RoomShortID,
		})
		applyRobotRoomExt(current, typed.RobotRoom, typed.RobotUserIDs)
		// 创建命令的 owner/mode 等基础 meta 已由 RoomMeta 或快照恢复，这里只保证版本进入初始提交态。
		current.Version = 1
	case *command.UpdateRoomProfile:
		// 房间资料和麦位总数都属于 Room Cell 状态；恢复时只重放已提交的确定性结果。
		applyProfilePatch(current, *typed)
		if _, err := applySeatCountPatch(current, typed.SeatCount); err != nil {
			return err
		}
		current.Version++
	case *command.SetRoomBackground:
		if current.RoomExt == nil {
			current.RoomExt = make(map[string]string)
		}
		backgroundURL := typed.BackgroundURL
		if typed.Background.BackgroundID > 0 && typed.Background.Media.URL != "" {
			// 新命令完整恢复媒体事实；旧命令没有 Background 时继续只恢复兼容 URL。
			background := typed.Background
			current.ActiveBackground = &background
			backgroundURL = background.Media.URL
		}
		current.RoomExt[roomExtBackgroundKey] = backgroundURL
		current.Version++
	case *command.ApplyRoomDecoration:
		decoration := typed.Resource
		switch typed.DecorationType {
		case roomDecorationTypeBorder:
			current.RoomBorder = decoration.Clone()
		case roomDecorationTypeColoredName:
			current.RoomNameStyle = decoration.Clone()
		default:
			return fmt.Errorf("apply_room_decoration replay unsupported type: %s", typed.DecorationType)
		}
		current.Version++
	case *command.ReconcileRoomDecoration:
		var currentDecoration **state.RoomDecoration
		switch typed.DecorationType {
		case roomDecorationTypeBorder:
			currentDecoration = &current.RoomBorder
		case roomDecorationTypeColoredName:
			currentDecoration = &current.RoomNameStyle
		default:
			return fmt.Errorf("reconcile_room_decoration replay unsupported type: %s", typed.DecorationType)
		}
		if typed.Active {
			decoration := typed.Resource
			*currentDecoration = decoration.Clone()
		} else {
			*currentDecoration = nil
		}
		current.Version++
	case *command.SendRoomImage:
		// 图片消息不进入房态字段，但它在原始提交中通过版本推进把 command log 与 outbox 原子绑定；
		// 恢复必须重现版本谱系，绝不能再次发送腾讯 IM。
		current.Version++
	case *command.JoinRoom:
		if current.Status == state.RoomStatusClosed && typed.ActorUserID() == current.OwnerUserID {
			// 房主进 closed 房会在 JoinRoom 提交里恢复 active；回放必须重建同一个生命周期状态。
			current.Status = state.RoomStatusActive
		}
		role := typed.Role
		if typed.ActorUserID() == current.OwnerUserID {
			role = "owner"
		}
		if existing, exists := current.OnlineUsers[typed.ActorUserID()]; exists {
			// 重复 JoinRoom 回放只刷新 last_seen，不能重置首次进房时间或降级 owner 角色。
			existing.LastSeenAtMS = typed.SentAtMS
		} else {
			// presence 使用命令发送时间恢复，真实 IM 连接由客户端重新登录腾讯云 IM。
			current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
				UserID:       typed.ActorUserID(),
				Role:         role,
				JoinedAtMS:   typed.SentAtMS,
				LastSeenAtMS: typed.SentAtMS,
			}
		}
		current.Version++
	case *command.RoomHeartbeat:
		existing, exists := current.OnlineUsers[typed.ActorUserID()]
		if !exists {
			// 线上心跳对丢失的 presence 会执行静默恢复后才提交命令日志；回放遇到
			// "用户不存在"的已提交心跳，就是当时恢复成功的事实，必须重现恢复
			// （含 Version++），否则恢复出的 OnlineUsers/版本会与快照谱系分叉。
			// 守卫（room active/ban/锁房）在线上提交前已把关，被拒的心跳不会落库，
			// 回放无需也不能重复判断——回放看到的 BanUsers 时点可能晚于命令时点。
			current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
				UserID:       typed.ActorUserID(),
				Role:         recoveredPresenceRole(current, typed.ActorUserID()),
				JoinedAtMS:   typed.SentAtMS,
				LastSeenAtMS: typed.SentAtMS,
			}
			current.Version++
			return nil
		}
		// 心跳只刷新 last_seen，不产生用户进房事件。
		existing.LastSeenAtMS = typed.SentAtMS
		current.Version++
	case *command.LeaveRoom:
		// 离房回放只删除业务 presence，不涉及腾讯云 IM 连接态。
		delete(current.OnlineUsers, typed.ActorUserID())
		if seat, exists := current.SeatByUser(typed.ActorUserID()); exists {
			// 离房恢复也必须释放麦位，避免 snapshot 落后时重建出幽灵占位。
			index := current.SeatIndex(seat.SeatNo)
			current.ClearMicSession(index)
		}
		current.Version++
	case *command.CloseRoom:
		current.Status = state.RoomStatusClosed
		current.OnlineUsers = map[int64]*state.RoomUserState{}
		for index := range current.MicSeats {
			current.ClearMicSession(index)
		}
		current.Version++
	case *command.AdminUpdateRoom:
		applyAdminRoomPatch(current, *typed)
		if typed.Status != nil && (*typed.Status == state.RoomStatusClosed || *typed.Status == state.RoomStatusDeleted) {
			current.OnlineUsers = map[int64]*state.RoomUserState{}
			for index := range current.MicSeats {
				current.ClearMicSession(index)
			}
		}
		current.Version++
	case *command.AdminBanRoom:
		current.Status = state.RoomStatusBanned
		current.OnlineUsers = map[int64]*state.RoomUserState{}
		for index := range current.MicSeats {
			current.ClearMicSession(index)
		}
		current.Version++
	case *command.AdminUnbanRoom:
		if current.Status != state.RoomStatusBanned {
			return fmt.Errorf("admin_unban_room replay requires banned state, got %s", current.Status)
		}
		current.Status = state.RoomStatusClosed
		current.Version++
	case *command.AdminDeleteRoom:
		current.Status = state.RoomStatusDeleted
		current.OnlineUsers = map[int64]*state.RoomUserState{}
		for index := range current.MicSeats {
			current.ClearMicSession(index)
		}
		current.Version++
	case *command.MicUp:
		index := current.SeatIndex(typed.SeatNo)
		if index < 0 {
			return fmt.Errorf("mic_up replay references missing seat: %d", typed.SeatNo)
		}
		current.Version++
		current.MicSeats[index].UserID = typed.ActorUserID()
		current.MicSeats[index].PublishState = state.MicPublishPending
		current.MicSeats[index].MicSessionID = typed.MicSessionID
		current.MicSeats[index].PublishDeadlineMS = typed.PublishDeadlineMS
		current.MicSeats[index].MicSessionRoomVersion = current.Version
		// pending_publish 还没有接受 RTC/客户端事件，恢复时保持 0，避免客户端时间慢于服务端命令时间时无法确认。
		current.MicSeats[index].LastPublishEventTimeMS = 0
	case *command.RobotMicUp:
		index := current.SeatIndex(typed.SeatNo)
		if index < 0 {
			return fmt.Errorf("robot_mic_up replay references missing seat: %d", typed.SeatNo)
		}
		current.Version++
		current.MicSeats[index].UserID = typed.TargetUserID
		current.MicSeats[index].PublishState = state.MicPublishPublishing
		current.MicSeats[index].MicSessionID = typed.MicSessionID
		current.MicSeats[index].PublishDeadlineMS = 0
		current.MicSeats[index].MicSessionRoomVersion = current.Version
		current.MicSeats[index].LastPublishEventTimeMS = typed.SentAtMS
		current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS
		current.MicSeats[index].MicMuted = true
	case *command.MicDown:
		seat, exists := current.SeatByUser(typed.TargetUserID)
		if !exists {
			// 历史日志里存在下麦/离房/超时清理重复到达后仍被标记 replayable 的旧下麦命令；当前状态已无麦位时恢复保持 no-op。
			return nil
		}
		if typed.MicSessionID != "" && seat.MicSessionID != typed.MicSessionID {
			// 超时下麦会携带 mic_session_id；如果用户已快速重上麦，旧命令不能释放新会话。
			return nil
		}
		index := current.SeatIndex(seat.SeatNo)
		current.ClearMicSession(index)
		current.Version++
	case *command.ConfirmMicPublishing:
		seat, exists := current.SeatByUser(typed.TargetUserID)
		if !exists {
			// 确认事件可能晚于下麦/离房落库；运行态对此按 no-op 处理，恢复也不能让旧 RTC/客户端事件卡死房间。
			return nil
		}
		if seat.MicSessionID != typed.MicSessionID || seat.PublishState != state.MicPublishPending {
			// 只允许当前 pending mic_session 被确认；旧会话、重复确认或已被替换的会话都按过期事件跳过。
			return nil
		}
		index := current.SeatIndex(seat.SeatNo)
		acceptedEventTimeMS := confirmMicPublishingAcceptedEventTimeMS(typed)
		current.MicSeats[index].PublishState = state.MicPublishPublishing
		current.MicSeats[index].LastPublishEventTimeMS = acceptedEventTimeMS
		current.MicSeats[index].MicHeartbeatAtMS = acceptedEventTimeMS
		current.Version++
	case *command.MicHeartbeat:
		seat, exists := current.SeatByUser(typed.TargetUserID)
		if !exists {
			// 麦上心跳只是当前会话的 liveness 刷新；旧心跳晚于离房/下麦落库时，不能重新占麦，也不能阻断房间恢复。
			return nil
		}
		if seat.MicSessionID != typed.MicSessionID || seat.PublishState != state.MicPublishPublishing {
			// 会话已被新一轮上麦或 pending 状态替换时，旧心跳不再代表当前麦位事实，恢复时按过期事件跳过。
			return nil
		}
		if existing, ok := current.OnlineUsers[typed.TargetUserID]; ok {
			// 麦上心跳回放要恢复同步刷新的 presence 时间，避免 snapshot 落后时重启后又被 stale worker 清理。
			existing.LastSeenAtMS = typed.SentAtMS
		}
		index := current.SeatIndex(seat.SeatNo)
		current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS
		current.Version++
	case *command.ApplyRTCEvent:
		switch typed.EventType {
		case rtcEventAudioStarted:
			seat, exists := current.SeatByUser(typed.TargetUserID)
			if !exists {
				return nil
			}
			if seat.PublishState != state.MicPublishPending || typed.EventTimeMS <= seat.LastPublishEventTimeMS || (seat.PublishDeadlineMS > 0 && typed.EventTimeMS > seat.PublishDeadlineMS) {
				// RTC 开始发布只确认当前 pending 会话；乱序、重复或超时事件恢复时保持 no-op。
				return nil
			}
			index := current.SeatIndex(seat.SeatNo)
			current.MicSeats[index].PublishState = state.MicPublishPublishing
			current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
			current.Version++
		case rtcEventAudioStopped:
			seat, exists := current.SeatByUser(typed.TargetUserID)
			if !exists {
				return nil
			}
			if seat.MicSessionID == "" || (seat.LastPublishEventTimeMS > 0 && typed.EventTimeMS < seat.LastPublishEventTimeMS) {
				// 停止发布只能关闭当前或更新的媒体会话；旧 stop 事件不能释放后续重上麦会话。
				return nil
			}
			index := current.SeatIndex(seat.SeatNo)
			current.ClearMicSession(index)
			current.Version++
		case rtcEventRoomExited:
			// room_exited 是 RTC 媒体频道退出事实，不是 App 业务离房事实。
			// 实时路径只释放麦位，业务 presence 只能由 LeaveRoom、KickUser、CloseRoom 或 stale worker 清理；
			// replay 必须保持相同语义，否则无最新 snapshot 的恢复会把仍在房间页的用户误删。
			seat, exists := current.SeatByUser(typed.TargetUserID)
			if !exists {
				return nil
			}
			index := current.SeatIndex(seat.SeatNo)
			current.ClearMicSession(index)
			current.Version++
		default:
			return fmt.Errorf("apply_rtc_event replay unsupported event type: %s", typed.EventType)
		}
	case *command.ChangeMicSeat:
		from, exists := current.SeatByUser(typed.TargetUserID)
		if !exists {
			// 换麦位属于麦上用户操作；历史过期命令若晚于下麦/离房恢复到达，当前状态已无源麦位时保持 no-op。
			return nil
		}
		fromIndex := current.SeatIndex(from.SeatNo)
		toIndex := current.SeatIndex(typed.SeatNo)
		if fromIndex < 0 || toIndex < 0 {
			return fmt.Errorf("change_mic_seat replay references missing seat: from=%d to=%d", from.SeatNo, typed.SeatNo)
		}
		current.MicSeats[fromIndex].UserID = 0
		movedSeat := current.MicSeats[fromIndex]
		current.MicSeats[fromIndex].PublishState = ""
		current.MicSeats[fromIndex].MicSessionID = ""
		current.MicSeats[fromIndex].PublishDeadlineMS = 0
		current.MicSeats[fromIndex].MicSessionRoomVersion = 0
		current.MicSeats[fromIndex].LastPublishEventTimeMS = 0
		current.MicSeats[fromIndex].MicMuted = false
		current.MicSeats[toIndex].UserID = typed.TargetUserID
		current.MicSeats[toIndex].PublishState = movedSeat.PublishState
		current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID
		current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
		current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
		current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
		current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted
		current.Version++
	case *command.SetMicMute:
		seat, exists := current.SeatByUser(typed.TargetUserID)
		if !exists {
			// 静音状态只附着在当前麦位会话上；用户已不在麦上时，旧静音命令不能阻断房间恢复。
			return nil
		}
		index := current.SeatIndex(seat.SeatNo)
		current.MicSeats[index].MicMuted = typed.Muted
		current.Version++
	case *command.SetMicSeatLock:
		index := current.SeatIndex(typed.SeatNo)
		if index < 0 {
			return fmt.Errorf("set_mic_seat_lock replay references missing seat: %d", typed.SeatNo)
		}
		// 锁麦恢复只还原锁状态，不隐式清空已占用麦位。
		current.MicSeats[index].Locked = typed.Locked
		current.Version++
	case *command.SetChatEnabled:
		// 公屏开关恢复后直接影响 CheckSpeakPermission 的 chat_disabled 判断。
		current.ChatEnabled = typed.Enabled
		current.Version++
	case *command.SetRoomPassword:
		// 锁房恢复只还原服务端哈希状态；明文密码从不进入 command log。
		if typed.Locked {
			current.RoomPasswordHash = typed.PasswordHash
		} else {
			current.RoomPasswordHash = ""
		}
		current.Version++
	case *command.SetRoomAdmin:
		if typed.TargetUserID == current.OwnerUserID {
			// owner 不是管理员；旧命令或异常数据回放时不把 owner 写入 admin 集合。
		} else if typed.Enabled {
			// 管理员集合是持久权限，恢复后用户重新进房即可继续具备 admin 身份。
			current.AdminUsers[typed.TargetUserID] = true
		} else {
			delete(current.AdminUsers, typed.TargetUserID)
		}
		current.Version++
	case *command.MuteUser:
		if typed.Muted {
			// 回放禁言集合及其 RTC 麦位投影；否则重启后快照会把仍被房间禁言的用户显示成可开麦。
			current.MuteUsers[typed.TargetUserID] = true
			if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
				index := current.SeatIndex(seat.SeatNo)
				current.MicSeats[index].MicMuted = true
			}
		} else {
			// 与在线命令一致，解除房间禁言不隐式打开用户麦克风。
			delete(current.MuteUsers, typed.TargetUserID)
		}
		current.Version++
	case *command.KickUser:
		if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
			// 被踢用户恢复后不能继续占麦位。
			index := current.SeatIndex(seat.SeatNo)
			current.ClearMicSession(index)
		}
		delete(current.OnlineUsers, typed.TargetUserID)
		delete(current.AdminUsers, typed.TargetUserID)
		createdAtMS := committedAtMS
		if createdAtMS <= 0 {
			createdAtMS = typed.SentAtMS
		}
		expiresAtMS := int64(0)
		if typed.DurationMS > 0 && createdAtMS > 0 {
			expiresAtMS = createdAtMS + typed.DurationMS
		}
		current.BanUsers[typed.TargetUserID] = state.UserModerationState{
			UserID:      typed.TargetUserID,
			ActorUserID: typed.ActorUserID(),
			CreatedAtMS: createdAtMS,
			ExpiresAtMS: expiresAtMS,
		}
		current.Version++
	case *command.UnbanUser:
		// Unban 只解除 ban，不恢复被 KickUser 删除的 presence、麦位或管理员身份。
		delete(current.BanUsers, typed.TargetUserID)
		current.Version++
	case *command.SendGift:
		// 送礼回放不能再次调用 wallet，只使用命令中记录的钱包结算热度重建展示态。
		total := typed.HeatValue
		current.Heat += total
		for targetUserID, giftValue := range sendGiftTargetGiftValues(typed) {
			if giftValue <= 0 {
				continue
			}
			if userState := current.OnlineUsers[targetUserID]; userState != nil {
				// 收礼用户热度只恢复当时仍在房间内的在线态；后续 Leave/Kick/Stale 命令会删除在线态并自然清零。
				userState.GiftValue += giftValue
			}
		}
		applied := false
		for index := range current.GiftRank {
			if current.GiftRank[index].UserID == typed.ActorUserID() {
				current.GiftRank[index].Score += total
				current.GiftRank[index].GiftValue += total
				current.GiftRank[index].UpdatedAtMS = typed.SentAtMS
				applied = true
				break
			}
		}

		if !applied {
			// 用户首次出现在榜单时追加榜单项，排序由 LocalRank/快照展示阶段处理。
			current.GiftRank = append(current.GiftRank, state.RankItem{
				UserID:      typed.ActorUserID(),
				Score:       total,
				GiftValue:   total,
				UpdatedAtMS: typed.SentAtMS,
			})
		}

		if typed.RocketTouched {
			current.Rocket = rocketStateFromSettledGift(typed)
		}

		current.Version++
	case *command.ApplyRoomRPSGift:
		// PK 账务已在 game/wallet owner 完成；恢复只应用首次提交时固化的火箭快照，绝不能再次扣费或补普通礼物热度。
		if typed.RocketTouched {
			current.Rocket = rocketStateFromRoomRPSGift(typed)
		}
		current.Version++
	case *command.LaunchRoomRocket:
		if current.Rocket == nil || !rocketPendingLaunchExists(current.Rocket.PendingLaunches, typed.RocketID, typed.Level) {
			// 发射命令必须对应待发射队列中的火箭；如果快照已包含移除后的状态，回放保持幂等。
			return nil
		}
		current.Rocket = rocketStateFromLaunchCommand(typed)
		current.Version++
	}

	return nil
}

func sendGiftTargetGiftValues(cmd *command.SendGift) map[int64]int64 {
	if cmd == nil {
		return nil
	}
	if len(cmd.TargetGiftValues) > 0 {
		return cmd.TargetGiftValues
	}
	if cmd.TargetUserID > 0 && len(cmd.TargetUserIDs) <= 1 && cmd.HeatValue > 0 {
		// 旧单目标命令没有 TargetGiftValues 时，HeatValue 同时就是该唯一收礼人的本次热度增量。
		return map[int64]int64{cmd.TargetUserID: cmd.HeatValue}
	}
	// 旧多目标命令无法从聚合 HeatValue 反推出每个收礼人的值，宁可不恢复也不能把总值错误扩散给每个目标。
	return nil
}

func rocketStateFromSettledGift(cmd *command.SendGift) *state.RocketState {
	return &state.RocketState{
		CurrentLevel:         cmd.RocketLevel,
		CurrentFuel:          cmd.RocketFuel,
		FuelThreshold:        cmd.RocketFuelThreshold,
		Status:               cmd.RocketStatus,
		IgnitedAtMS:          cmd.RocketIgnitedAtMS,
		LaunchAtMS:           cmd.RocketLaunchAtMS,
		ResetAtMS:            cmd.RocketResetAtMS,
		Top1UserID:           cmd.RocketTop1UserID,
		IgniterUserID:        cmd.RocketIgniterUserID,
		RocketID:             cmd.RocketID,
		ConfigVersion:        cmd.RocketConfigVersion,
		PendingLaunches:      rocketPendingLaunchesFromCommand(cmd.RocketPendingLaunches),
		CurrentContributions: rocketContributionsFromCommand(cmd.RocketCurrentContributions),
	}
}

func rocketStateFromRoomRPSGift(cmd *command.ApplyRoomRPSGift) *state.RocketState {
	return &state.RocketState{
		CurrentLevel:         cmd.RocketLevel,
		CurrentFuel:          cmd.RocketFuel,
		FuelThreshold:        cmd.RocketFuelThreshold,
		Status:               cmd.RocketStatus,
		IgnitedAtMS:          cmd.RocketIgnitedAtMS,
		LaunchAtMS:           cmd.RocketLaunchAtMS,
		ResetAtMS:            cmd.RocketResetAtMS,
		Top1UserID:           cmd.RocketTop1UserID,
		IgniterUserID:        cmd.RocketIgniterUserID,
		RocketID:             cmd.RocketID,
		ConfigVersion:        cmd.RocketConfigVersion,
		PendingLaunches:      rocketPendingLaunchesFromCommand(cmd.RocketPendingLaunches),
		CurrentContributions: rocketContributionsFromCommand(cmd.RocketCurrentContributions),
	}
}

func rocketStateFromLaunchCommand(cmd *command.LaunchRoomRocket) *state.RocketState {
	return &state.RocketState{
		CurrentLevel:         cmd.CurrentLevel,
		CurrentFuel:          cmd.CurrentFuel,
		FuelThreshold:        cmd.CurrentFuelThreshold,
		Status:               cmd.CurrentStatus,
		LaunchedAtMS:         cmd.LaunchedAtMS,
		ResetAtMS:            cmd.ResetAtMS,
		RocketID:             cmd.CurrentRocketID,
		ConfigVersion:        cmd.ConfigVersion,
		PendingLaunches:      rocketPendingLaunchesFromCommand(cmd.PendingLaunches),
		CurrentContributions: rocketContributionsFromCommand(cmd.CurrentContributions),
		LastRewards:          rocketRewardsFromCommand(cmd.Rewards),
	}
}

func rocketContributionsFromCommand(input []command.RocketContribution) []state.RocketContribution {
	contributions := make([]state.RocketContribution, 0, len(input))
	for _, contribution := range input {
		if contribution.UserID <= 0 || contribution.Fuel <= 0 {
			continue
		}
		contributions = append(contributions, state.RocketContribution{
			UserID:      contribution.UserID,
			Fuel:        contribution.Fuel,
			UpdatedAtMS: contribution.UpdatedAtMS,
		})
	}
	return contributions
}

func rocketPendingLaunchExists(input []state.RocketPendingLaunch, rocketID string, level int32) bool {
	for _, launch := range input {
		if launch.RocketID == rocketID && launch.Level == level {
			return true
		}
	}
	return false
}

func rocketPendingLaunchesFromCommand(input []command.RocketPendingLaunch) []state.RocketPendingLaunch {
	launches := make([]state.RocketPendingLaunch, 0, len(input))
	for _, launch := range input {
		launches = append(launches, state.RocketPendingLaunch{
			RocketID:      launch.RocketID,
			Level:         launch.Level,
			FuelThreshold: launch.FuelThreshold,
			IgnitedAtMS:   launch.IgnitedAtMS,
			LaunchAtMS:    launch.LaunchAtMS,
			ResetAtMS:     launch.ResetAtMS,
			Top1UserID:    launch.Top1UserID,
			IgniterUserID: launch.IgniterUserID,
			ConfigVersion: launch.ConfigVersion,
			CoverURL:      launch.CoverURL,
		})
	}
	return launches
}

func rocketRewardsFromCommand(input []command.RocketRewardGrant) []state.RocketRewardGrant {
	rewards := make([]state.RocketRewardGrant, 0, len(input))
	for _, reward := range input {
		rewards = append(rewards, state.RocketRewardGrant{
			RewardRole:      reward.RewardRole,
			UserID:          reward.UserID,
			RewardItemID:    reward.RewardItemID,
			ResourceGroupID: reward.ResourceGroupID,
			DisplayName:     reward.DisplayName,
			IconURL:         reward.IconURL,
			GrantID:         reward.GrantID,
			Status:          reward.Status,
		})
	}
	return rewards
}
