// Package state 定义 Room Cell 内部持有的房间强关联状态和 protobuf 快照转换。
package state

import (
	"maps"
	"slices"
	"time"

	roomv1 "hyapp.local/api/proto/room/v1"
)

const (
	// RoomStatusCreating 表示房间正在创建外部依赖，尚未对客户端开放。
	RoomStatusCreating = "creating"
	// RoomStatusActive 表示房间可进入、可管理、可发麦位和礼物命令。
	RoomStatusActive = "active"
	// RoomStatusClosing 表示房间正在收尾，不再接受新业务命令。
	RoomStatusClosing = "closing"
	// RoomStatusClosed 表示房间已经关闭，guard 和进房都必须拒绝。
	RoomStatusClosed = "closed"
	// RoomStatusBanned 表示后台人工封禁；只有专用解封命令能将其恢复为 closed。
	RoomStatusBanned = "banned"
	// RoomStatusDeleted 只兼容历史后台软删除数据；新的后台删除会物理清理房间恢复来源。
	RoomStatusDeleted = "deleted"

	// RocketStatusCharging 表示当前等级火箭还在积攒燃料。
	RocketStatusCharging = "charging"
	// RocketStatusIgnited 表示当前等级已经燃料满，等待后台 worker 到点发射。
	RocketStatusIgnited = "ignited"
	// RocketStatusCompleted 表示当天最高等级已经点火完成，UTC 重置前不再积攒新火箭。
	RocketStatusCompleted = "completed"

	// MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。
	MicPublishIdle = "idle"
	// MicPublishPending 表示业务上麦成功，但客户端/RTC 尚未确认音频发布成功。
	MicPublishPending = "pending_publish"
	// MicPublishPublishing 表示当前 mic_session 已确认在 RTC 侧发布音频。
	MicPublishPublishing = "publishing"

	// MicSeatStatusEmpty 表示麦位无人占用且未锁定。
	MicSeatStatusEmpty = "empty"
	// MicSeatStatusLocked 表示麦位锁定且不能上麦。
	MicSeatStatusLocked = "locked"
	// MicSeatStatusOccupied 表示用户已业务占麦，但还没有确认可听的上行音频。
	MicSeatStatusOccupied = "occupied"
	// MicSeatStatusPublishing 表示用户已占麦且当前 mic_session 已确认发流。
	MicSeatStatusPublishing = "publishing"
	// MicSeatStatusMuted 表示用户已占麦，但服务端可见麦克风处于静音态。
	MicSeatStatusMuted = "muted"
)

// MicSeat 表达房间内单个麦位的占用状态。
type MicSeat struct {
	// SeatNo 是房间内稳定麦位编号，不使用切片下标作为对外标识。
	SeatNo int32
	// UserID 为 0 表示麦位空闲，非 0 表示当前占用用户。
	UserID int64
	// Locked 表示麦位被锁定，锁定麦位不能上麦或换入。
	Locked bool
	// PublishState 区分业务占麦和 RTC 发流确认状态，避免把 MicUp 当作已发流。
	PublishState string
	// MicSessionID 标识单次上麦发流会话，确认和超时清理都必须匹配它。
	MicSessionID string
	// PublishDeadlineMS 是 pending_publish 自动释放麦位的截止时间。
	PublishDeadlineMS int64
	// MicSessionRoomVersion 是创建该会话的房间版本，用于丢弃旧版本 RTC 事件。
	MicSessionRoomVersion int64
	// LastPublishEventTimeMS 是已接受的最新 RTC/客户端发布事件时间。
	LastPublishEventTimeMS int64
	// MicHeartbeatAtMS 是当前 mic_session 最近一次服务端接受麦上心跳的时间；它不参与 RTC 事件新旧判断。
	MicHeartbeatAtMS int64
	// MicMuted 是服务端可见的麦克风静音状态，用于同步其他用户 UI。
	MicMuted bool
}

// SeatStatus 返回面向客户端展示的派生麦位状态。
//
// PublishState 继续表达 RTC 会话确认阶段，SeatStatus 只负责把“空/锁/占麦/发流/静音”
// 收敛成一个展示字段，避免客户端把 pending_publish 误当作可听发流。
func (seat MicSeat) SeatStatus() string {
	if seat.UserID <= 0 {
		if seat.Locked {
			return MicSeatStatusLocked
		}
		return MicSeatStatusEmpty
	}
	if seat.MicMuted {
		return MicSeatStatusMuted
	}
	if seat.PublishState == MicPublishPublishing {
		return MicSeatStatusPublishing
	}
	return MicSeatStatusOccupied
}

// RoomUserState 只保存 room-service 真正需要的轻量用户态。
type RoomUserState struct {
	// UserID 是房间 presence 的用户标识。
	UserID int64
	// Role 是房间内轻量角色，不承载完整用户资料。
	Role string
	// JoinedAtMS 是进入房间业务态的时间。
	JoinedAtMS int64
	// LastSeenAtMS 是最后一次房间业务操作时间，不等同于腾讯云 IM 心跳。
	LastSeenAtMS int64
	// GiftValue 是该用户本次在线停留期间在当前房间收到的礼物热度，离房删除 OnlineUsers 后自然清零。
	GiftValue int64
}

// UserModerationState 保存房间内 ban/mute 这类治理状态的时间边界。
type UserModerationState struct {
	// UserID 是被治理用户。
	UserID int64
	// ActorUserID 是触发治理命令的房间内操作者；系统治理入口使用 0。
	ActorUserID int64
	// CreatedAtMS 是治理状态写入 Room Cell 的 UTC epoch milliseconds。
	CreatedAtMS int64
	// ExpiresAtMS 为 0 表示永久有效；非 0 表示该 UTC 毫秒后自动失效。
	ExpiresAtMS int64
}

// RoomMedia 保存已经通过专用上传入口验证的媒体属性。
// 这些字段随 Room Cell 快照和 command log 恢复，客户端不需要也不允许按 URL 后缀重新猜类型。
type RoomMedia struct {
	URL          string `json:"url"`
	ObjectKey    string `json:"object_key"`
	ContentType  string `json:"content_type"`
	Format       string `json:"format"`
	SizeBytes    int64  `json:"size_bytes"`
	Width        int32  `json:"width"`
	Height       int32  `json:"height"`
	Animated     bool   `json:"animated"`
	FrameCount   int32  `json:"frame_count"`
	DurationMS   int64  `json:"duration_ms"`
	PosterURL    string `json:"poster_url,omitempty"`
	ThumbnailURL string `json:"thumbnail_url,omitempty"`
	SHA256       string `json:"sha256"`
	Status       string `json:"status"`
}

// RoomBackground 是当前生效背景的完整素材快照。
// 背景列表表仍是低频素材目录；一旦应用，恢复只依赖该快照而不反查可能被运营修改的素材行。
type RoomBackground struct {
	BackgroundID    int64     `json:"background_id"`
	RoomID          string    `json:"room_id"`
	CreatedByUserID int64     `json:"created_by_user_id"`
	CreatedAtMS     int64     `json:"created_at_ms"`
	UpdatedAtMS     int64     `json:"updated_at_ms"`
	CommandID       string    `json:"command_id"`
	Media           RoomMedia `json:"media"`
}

// RoomDecoration 是房主 entitlement 在应用瞬间固化的资源展示快照。
// 权限资格仍由 wallet 实时判断；Room Cell 只持有房间当前选择，不复制 entitlement 生命周期算法。
type RoomDecoration struct {
	ResourceID    int64     `json:"resource_id"`
	ResourceCode  string    `json:"resource_code"`
	ResourceType  string    `json:"resource_type"`
	Name          string    `json:"name"`
	AssetURL      string    `json:"asset_url"`
	PreviewURL    string    `json:"preview_url"`
	AnimationURL  string    `json:"animation_url"`
	Format        string    `json:"format"`
	MetadataJSON  string    `json:"metadata_json"`
	EntitlementID string    `json:"entitlement_id"`
	TextColors    []string  `json:"text_colors,omitempty"`
	Stops         []float64 `json:"stops,omitempty"`
	AngleDegrees  float64   `json:"angle_degrees,omitempty"`
	ExpiresAtMS   int64     `json:"expires_at_ms,omitempty"`
}

// Clone 深拷贝装扮内的颜色和渐变停靠点，避免失败命令通过共享切片污染当前房态。
func (d *RoomDecoration) Clone() *RoomDecoration {
	if d == nil {
		return nil
	}
	cloned := *d
	cloned.TextColors = append([]string(nil), d.TextColors...)
	cloned.Stops = append([]float64(nil), d.Stops...)
	return &cloned
}

// ActiveAt 判断治理状态在指定 UTC 毫秒是否仍然生效。
func (m UserModerationState) ActiveAt(nowMS int64) bool {
	return m.UserID > 0 && (m.ExpiresAtMS == 0 || m.ExpiresAtMS > nowMS)
}

// RankItem 表达房间内本地礼物榜的单个用户累计值。
type RankItem struct {
	// UserID 是榜单归属用户。
	UserID int64
	// Score 是展示排序分值，当前版本等于 GiftValue。
	Score int64
	// GiftValue 是房间内礼物价值累计。
	GiftValue int64
	// UpdatedAtMS 是榜单项最后更新时间，分数相同时用于排序。
	UpdatedAtMS int64
}

// RocketRewardGrant 记录一次火箭发射时给单个用户结算出的奖励。
type RocketRewardGrant struct {
	// RewardRole 区分 in_room、top1、igniter 三种奖励归因，客户端据此分组展示。
	RewardRole string
	// UserID 是奖励归属用户；top1 和 igniter 即使离房也必须用燃料满时锁定的用户。
	UserID int64
	// RewardItemID 是后台奖励池内的稳定项 ID，用于排障和客户端去重。
	RewardItemID string
	// ResourceGroupID 是 wallet-service 发放资源组 ID。
	ResourceGroupID int64
	// DisplayName/IconURL 是发射 IM 展示快照，避免客户端为历史消息回查后台配置。
	DisplayName string
	IconURL     string
	// GrantID 是 wallet-service 资源发放回执；空值表示该奖励未实际发放。
	GrantID string
	// Status 表达发放结果，当前成功路径写 succeeded。
	Status string
}

// RocketPendingLaunch 保存单枚已点火火箭的发射快照。
type RocketPendingLaunch struct {
	// RocketID 是这枚火箭从点火到发奖的幂等标识。
	RocketID string
	// Level/FuelThreshold 是点火瞬间锁定的等级和阈值，后续配置变更不影响本枚火箭。
	Level         int32
	FuelThreshold int64
	// IgnitedAtMS/LaunchAtMS/ResetAtMS 确定延迟发射和 UTC 日界校验。
	IgnitedAtMS int64
	LaunchAtMS  int64
	ResetAtMS   int64
	// Top1UserID/IgniterUserID 在点火瞬间锁定，用户离房后仍按这个事实结算特殊奖励。
	Top1UserID    int64
	IgniterUserID int64
	// ConfigVersion 锁定本枚火箭的配置版本，CoverURL 给区域飘屏和客户端展示使用。
	ConfigVersion int64
	CoverURL      string
}

// RocketContribution 保存当前火箭等级里真正进入燃料条的用户贡献。
type RocketContribution struct {
	// UserID 是加燃料的送礼人；top1 只从这一批当前等级贡献里选。
	UserID int64
	// Fuel 是当前等级内实际写入进度条的燃料，溢出部分不算入下一等级。
	Fuel int64
	// UpdatedAtMS 用于贡献相同时按最近贡献排序，保持榜单和 UI 直觉一致。
	UpdatedAtMS int64
}

// RocketState 保存 Room Cell 内当前火箭进度和最近一次发射结果。
type RocketState struct {
	// CurrentLevel 是当前正在积攒的火箭等级，UTC 日重置后回到 1。
	CurrentLevel int32
	// CurrentFuel 是当前等级已累计燃料，满级后的溢出不跨等级继承。
	CurrentFuel int64
	// FuelThreshold 是当前等级阈值快照，便于客户端不依赖配置二次计算。
	FuelThreshold int64
	// Status 区分 charging/completed；已点火火箭在 PendingLaunches 中独立排队，不阻塞当前进度。
	Status string
	// IgnitedAtMS/LaunchAtMS 投影下一枚待发射火箭，完整多枚队列仍以 PendingLaunches 为准。
	IgnitedAtMS  int64
	LaunchAtMS   int64
	LaunchedAtMS int64
	// ResetAtMS 是下一次 UTC 自然日重置毫秒时间。
	ResetAtMS int64
	// Top1UserID 和 IgniterUserID 投影下一枚待发射火箭的锁定用户；发奖以 PendingLaunches 内快照为准。
	Top1UserID    int64
	IgniterUserID int64
	// RocketID 是单次火箭从积攒到发射的幂等标识，奖励抽取和 wallet command_id 都依赖它。
	RocketID string
	// ConfigVersion 记录本轮火箭使用的后台配置版本，发射时不被中途配置变更影响。
	ConfigVersion int64
	// PendingLaunches 是已经满阈值但还没到延迟发射时间的火箭队列。
	PendingLaunches []RocketPendingLaunch
	// CurrentContributions 只记录当前正在积攒等级的实际燃料贡献，点火进入下一等级后清空。
	CurrentContributions []RocketContribution
	// LastRewards 保存最近一次发射发奖结果，用于断线重连后补 UI。
	LastRewards []RocketRewardGrant
}

// Clone 深拷贝火箭状态，避免 Room Cell 失败命令污染当前状态。
func (t *RocketState) Clone() *RocketState {
	if t == nil {
		return nil
	}
	cloned := *t
	cloned.PendingLaunches = append([]RocketPendingLaunch(nil), t.PendingLaunches...)
	cloned.CurrentContributions = append([]RocketContribution(nil), t.CurrentContributions...)
	cloned.LastRewards = append([]RocketRewardGrant(nil), t.LastRewards...)
	return &cloned
}

// RoomState 是 Room Cell 的核心业务状态。
// 这里刻意不放钱包余额、完整用户资料和跨房活动状态。
type RoomState struct {
	// RoomID 是该状态所属房间。
	RoomID string
	// OwnerUserID 是房间所有者；owner 权限由专门字段派生，不写入 AdminUsers。
	OwnerUserID int64
	// Mode 是房间模式，当前只作为状态字段透出。
	Mode string
	// VisibleRegionID 是房间礼物、列表和区域可见性策略使用的区域桶。
	VisibleRegionID int64
	// Status 是房间生命周期状态。
	Status string
	// ChatEnabled 控制腾讯云 IM 公屏发言守卫。
	ChatEnabled bool
	// RoomPasswordHash 非空表示房间开启入房密码；只保存哈希，明文只存在于单次 JoinRoom 请求。
	RoomPasswordHash string
	// MicSeats 是麦位占用状态，所有麦位变更必须在 Room Cell 内完成。
	MicSeats []MicSeat
	// OnlineUsers 是 room-service 业务 presence，不保存真实 socket。
	OnlineUsers map[int64]*RoomUserState
	// AdminUsers 是房间管理权限集合。
	AdminUsers map[int64]bool
	// BanUsers 是被踢或封禁的房间用户状态，过期时间由 Room Cell 持久化和恢复。
	BanUsers map[int64]UserModerationState
	// MuteUsers 是被禁言用户集合。
	MuteUsers map[int64]bool
	// Heat 是房间热度，当前主要由送礼累计。
	Heat int64
	// GiftRank 是房间内本地礼物榜快照结果。
	GiftRank []RankItem
	// RoomExt 预留少量房间扩展字段，避免 v1 频繁改结构。
	RoomExt map[string]string
	// ActiveBackground 是当前生效背景的可信媒体快照；RoomExt.background_url 仅兼容旧客户端。
	ActiveBackground *RoomBackground
	// RoomBorder/RoomNameStyle 是房间级装扮最终值，必须随 Room Cell 串行提交和恢复。
	RoomBorder    *RoomDecoration
	RoomNameStyle *RoomDecoration
	// Rocket 是语音房火箭状态，送礼燃料和发射必须跟随 Room Cell 串行提交。
	Rocket *RocketState
	// Version 是房间状态版本，成功变更后递增，快照和 command log 都依赖它。
	Version int64
}

// NewRoomState 初始化一个空房间状态。
func NewRoomState(roomID string, ownerUserID int64, seatCount int32, mode string) *RoomState {
	// 麦位编号从 1 开始，避免对外暴露 0 号麦位和切片下标。
	seats := make([]MicSeat, 0, seatCount)

	for seatNo := int32(1); seatNo <= seatCount; seatNo++ {
		seats = append(seats, MicSeat{SeatNo: seatNo})
	}

	return &RoomState{
		RoomID:      roomID,
		OwnerUserID: ownerUserID,
		Mode:        mode,
		Status:      RoomStatusActive,
		ChatEnabled: true,
		MicSeats:    seats,
		OnlineUsers: make(map[int64]*RoomUserState),
		AdminUsers:  make(map[int64]bool),
		BanUsers:    make(map[int64]UserModerationState),
		MuteUsers:   make(map[int64]bool),
		RoomExt:     make(map[string]string),
	}
}

// Clone 深拷贝当前房间状态，供命令流水线在副本上计算新状态。
func (s *RoomState) Clone() *RoomState {
	if s == nil {
		// nil 输入保持 nil，便于防御性快照调用。
		return nil
	}

	// 所有 map 和 slice 都重新分配，避免 mutate 失败时污染当前 Cell 状态。
	cloned := &RoomState{
		RoomID:           s.RoomID,
		OwnerUserID:      s.OwnerUserID,
		Mode:             s.Mode,
		VisibleRegionID:  s.VisibleRegionID,
		Status:           s.Status,
		ChatEnabled:      s.ChatEnabled,
		RoomPasswordHash: s.RoomPasswordHash,
		MicSeats:         append([]MicSeat(nil), s.MicSeats...),
		OnlineUsers:      make(map[int64]*RoomUserState, len(s.OnlineUsers)),
		AdminUsers:       make(map[int64]bool, len(s.AdminUsers)),
		BanUsers:         make(map[int64]UserModerationState, len(s.BanUsers)),
		MuteUsers:        make(map[int64]bool, len(s.MuteUsers)),
		Heat:             s.Heat,
		GiftRank:         append([]RankItem(nil), s.GiftRank...),
		RoomExt:          make(map[string]string, len(s.RoomExt)),
		ActiveBackground: cloneRoomBackground(s.ActiveBackground),
		RoomBorder:       s.RoomBorder.Clone(),
		RoomNameStyle:    s.RoomNameStyle.Clone(),
		Rocket:           cloneRocketState(s.Rocket),
		Version:          s.Version,
	}

	for userID, userState := range s.OnlineUsers {
		// 用户态用指针保存，必须逐项复制结构体。
		copied := *userState
		cloned.OnlineUsers[userID] = &copied
	}

	maps.Copy(cloned.AdminUsers, s.AdminUsers)

	maps.Copy(cloned.BanUsers, s.BanUsers)

	maps.Copy(cloned.MuteUsers, s.MuteUsers)

	maps.Copy(cloned.RoomExt, s.RoomExt)

	return cloned
}

// ReplaceWith 用新状态整体替换当前状态。
// 这里用整对象替换，是为了避免部分字段更新时残留旧引用。
func (s *RoomState) ReplaceWith(next *RoomState) {
	// Clone 后再整体赋值，避免调用方把 next 的 map/slice 引用留在当前状态里。
	*s = *next.Clone()
}

// SeatByUser 返回用户当前所在麦位。
func (s *RoomState) SeatByUser(userID int64) (MicSeat, bool) {
	// 麦位数量较小，线性扫描比维护额外索引更简单可靠。
	for _, seat := range s.MicSeats {
		if seat.UserID == userID {
			return seat, true
		}
	}

	return MicSeat{}, false
}

// ClearMicSession 清空麦位占用和 RTC 发流会话字段。
func (s *RoomState) ClearMicSession(index int) {
	if index < 0 || index >= len(s.MicSeats) {
		return
	}

	seatNo := s.MicSeats[index].SeatNo
	locked := s.MicSeats[index].Locked
	s.MicSeats[index] = MicSeat{
		SeatNo: seatNo,
		Locked: locked,
	}
}

// SeatIndex 返回指定麦位在切片中的下标。
func (s *RoomState) SeatIndex(seatNo int32) int {
	// 返回 -1 表示外部传入的麦位编号不存在。
	for index, seat := range s.MicSeats {
		if seat.SeatNo == seatNo {
			return index
		}
	}

	return -1
}

// ToProto 把当前内部房间态投影成对外使用的 protobuf 快照。
func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
	return s.ToProtoAt(time.Now().UTC().UnixMilli())
}

// ToProtoAt 以响应的 server_time_ms 过滤已到期装扮。过期资源仍留在 Room Cell 和 command log
// 供审计/恢复，但绝不能继续出现在 room detail、snapshot 或 feed 的客户端投影中。
func (s *RoomState) ToProtoAt(nowMS int64) *roomv1.RoomSnapshot {
	if s == nil {
		// nil 状态没有可投影快照，调用方按房间不存在处理。
		return nil
	}

	// OnlineUsers 是 map，先排序 user_id，保证快照输出稳定，便于测试和排障。
	users := make([]*roomv1.RoomUser, 0, len(s.OnlineUsers))
	userIDs := make([]int64, 0, len(s.OnlineUsers))

	for userID := range s.OnlineUsers {
		userIDs = append(userIDs, userID)
	}

	slices.Sort(userIDs)

	for _, userID := range userIDs {
		// protobuf 快照只暴露轻量 room user，不包含 user-service 主资料。
		userState := s.OnlineUsers[userID]
		users = append(users, &roomv1.RoomUser{
			UserId:       userState.UserID,
			Role:         userState.Role,
			JoinedAtMs:   userState.JoinedAtMS,
			LastSeenAtMs: userState.LastSeenAtMS,
			GiftValue:    userState.GiftValue,
		})
	}

	seats := make([]*roomv1.SeatState, 0, len(s.MicSeats))

	for _, seat := range s.MicSeats {
		seatGiftValue := int64(0)
		if seat.UserID > 0 {
			if userState, exists := s.OnlineUsers[seat.UserID]; exists && userState != nil {
				// 麦位展示值从在线用户态派生，切麦只移动 UserID，不会重置已收到的礼物热度。
				seatGiftValue = userState.GiftValue
			}
		}
		// 麦位顺序沿用内部切片顺序，即初始化的 SeatNo 升序和后续原位修改。
		seats = append(seats, &roomv1.SeatState{
			SeatNo:                 seat.SeatNo,
			UserId:                 seat.UserID,
			Locked:                 seat.Locked,
			PublishState:           seat.PublishState,
			MicSessionId:           seat.MicSessionID,
			PublishDeadlineMs:      seat.PublishDeadlineMS,
			MicSessionRoomVersion:  seat.MicSessionRoomVersion,
			LastPublishEventTimeMs: seat.LastPublishEventTimeMS,
			MicMuted:               seat.MicMuted,
			SeatStatus:             seat.SeatStatus(),
			MicHeartbeatAtMs:       seat.MicHeartbeatAtMS,
			GiftValue:              seatGiftValue,
		})
	}

	rankItems := make([]*roomv1.RankItem, 0, len(s.GiftRank))

	for _, item := range s.GiftRank {
		// GiftRank 已由 LocalRank 排好序，这里只做结构转换。
		rankItems = append(rankItems, &roomv1.RankItem{
			UserId:      item.UserID,
			Score:       item.Score,
			GiftValue:   item.GiftValue,
			UpdatedAtMs: item.UpdatedAtMS,
		})
	}

	roomBorder := roomDecorationToProtoAt(s.RoomBorder, nowMS)
	return &roomv1.RoomSnapshot{
		RoomId:           s.RoomID,
		OwnerUserId:      s.OwnerUserID,
		Mode:             s.Mode,
		VisibleRegionId:  s.VisibleRegionID,
		Status:           s.Status,
		ChatEnabled:      s.ChatEnabled,
		MicSeats:         seats,
		OnlineUsers:      users,
		AdminUserIds:     sortedSetIDsExcept(s.AdminUsers, s.OwnerUserID),
		BanUserIds:       sortedModerationIDs(s.BanUsers),
		MuteUserIds:      sortedSetIDs(s.MuteUsers),
		GiftRank:         rankItems,
		RoomExt:          cloneStringMap(s.RoomExt),
		Heat:             s.Heat,
		Version:          s.Version,
		RoomShortId:      s.RoomExt["room_short_id"],
		Locked:           s.RoomPasswordHash != "",
		Rocket:           rocketStateToProto(s.Rocket),
		BanStates:        sortedModerationStates(s.BanUsers),
		OnlineCount:      int32(len(users)),
		RoomBorder:       roomBorder,
		RoomBorderUrl:    roomBorder.GetAssetUrl(),
		RoomNameStyle:    roomDecorationToProtoAt(s.RoomNameStyle, nowMS),
		ActiveBackground: roomBackgroundToProto(s.ActiveBackground),
	}
}

// FromProto 把持久化快照恢复回内部房间态。
func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
	if snapshot == nil {
		// 没有快照时由调用方基于 RoomMeta 新建初始状态。
		return nil
	}

	// 所有集合重新分配，恢复后的状态可以直接交给 Room Cell 独占使用。
	restored := &RoomState{
		RoomID:           snapshot.GetRoomId(),
		OwnerUserID:      snapshot.GetOwnerUserId(),
		Mode:             snapshot.GetMode(),
		VisibleRegionID:  snapshot.GetVisibleRegionId(),
		Status:           snapshot.GetStatus(),
		ChatEnabled:      snapshot.GetChatEnabled(),
		MicSeats:         make([]MicSeat, 0, len(snapshot.GetMicSeats())),
		OnlineUsers:      make(map[int64]*RoomUserState, len(snapshot.GetOnlineUsers())),
		AdminUsers:       make(map[int64]bool, len(snapshot.GetAdminUserIds())),
		BanUsers:         make(map[int64]UserModerationState, len(snapshot.GetBanStates())+len(snapshot.GetBanUserIds())),
		MuteUsers:        make(map[int64]bool, len(snapshot.GetMuteUserIds())),
		GiftRank:         make([]RankItem, 0, len(snapshot.GetGiftRank())),
		RoomExt:          cloneStringMap(snapshot.GetRoomExt()),
		ActiveBackground: roomBackgroundFromProto(snapshot.GetActiveBackground()),
		RoomBorder:       roomDecorationFromProto(snapshot.GetRoomBorder()),
		RoomNameStyle:    roomDecorationFromProto(snapshot.GetRoomNameStyle()),
		Rocket:           rocketStateFromProto(snapshot.GetRocket()),
		Heat:             snapshot.GetHeat(),
		Version:          snapshot.GetVersion(),
	}

	for _, seat := range snapshot.GetMicSeats() {
		// protobuf 空字段会映射成零值，UserID=0 仍表示空麦位。
		restored.MicSeats = append(restored.MicSeats, MicSeat{
			SeatNo:                 seat.GetSeatNo(),
			UserID:                 seat.GetUserId(),
			Locked:                 seat.GetLocked(),
			PublishState:           seat.GetPublishState(),
			MicSessionID:           seat.GetMicSessionId(),
			PublishDeadlineMS:      seat.GetPublishDeadlineMs(),
			MicSessionRoomVersion:  seat.GetMicSessionRoomVersion(),
			LastPublishEventTimeMS: seat.GetLastPublishEventTimeMs(),
			MicHeartbeatAtMS:       seat.GetMicHeartbeatAtMs(),
			MicMuted:               seat.GetMicMuted(),
		})
	}

	for _, user := range snapshot.GetOnlineUsers() {
		// 快照中的 online users 是业务 presence，重启后腾讯云 IM 连接由客户端重新登录进群。
		restored.OnlineUsers[user.GetUserId()] = &RoomUserState{
			UserID:       user.GetUserId(),
			Role:         user.GetRole(),
			JoinedAtMS:   user.GetJoinedAtMs(),
			LastSeenAtMS: user.GetLastSeenAtMs(),
			GiftValue:    user.GetGiftValue(),
		}
	}

	for _, userID := range snapshot.GetAdminUserIds() {
		// set 在 protobuf 中用数组表达，恢复时重新转为 map。
		if userID == restored.OwnerUserID {
			// owner 不是管理员；旧快照里如果残留 owner，恢复时过滤掉。
			continue
		}
		restored.AdminUsers[userID] = true
	}

	for _, ban := range snapshot.GetBanStates() {
		if ban.GetUserId() <= 0 {
			continue
		}
		restored.BanUsers[ban.GetUserId()] = UserModerationState{
			UserID:      ban.GetUserId(),
			ActorUserID: ban.GetActorUserId(),
			CreatedAtMS: ban.GetCreatedAtMs(),
			ExpiresAtMS: ban.GetExpiresAtMs(),
		}
	}

	for _, userID := range snapshot.GetBanUserIds() {
		if userID <= 0 {
			continue
		}
		if _, exists := restored.BanUsers[userID]; !exists {
			// 旧快照只有 ban_user_ids 时按永久 ban 恢复，避免恢复后放宽权限。
			restored.BanUsers[userID] = UserModerationState{UserID: userID}
		}
	}

	for _, userID := range snapshot.GetMuteUserIds() {
		restored.MuteUsers[userID] = true
	}

	for _, item := range snapshot.GetGiftRank() {
		restored.GiftRank = append(restored.GiftRank, RankItem{
			UserID:      item.GetUserId(),
			Score:       item.GetScore(),
			GiftValue:   item.GetGiftValue(),
			UpdatedAtMS: item.GetUpdatedAtMs(),
		})
	}

	return restored
}

func cloneRoomBackground(input *RoomBackground) *RoomBackground {
	if input == nil {
		return nil
	}
	cloned := *input
	return &cloned
}

func roomMediaToProto(media RoomMedia) *roomv1.RoomMedia {
	return &roomv1.RoomMedia{
		Url:          media.URL,
		ObjectKey:    media.ObjectKey,
		ContentType:  media.ContentType,
		Format:       media.Format,
		SizeBytes:    media.SizeBytes,
		Width:        media.Width,
		Height:       media.Height,
		Animated:     media.Animated,
		FrameCount:   media.FrameCount,
		DurationMs:   media.DurationMS,
		PosterUrl:    media.PosterURL,
		ThumbnailUrl: media.ThumbnailURL,
		Sha256:       media.SHA256,
		Status:       media.Status,
	}
}

func roomMediaFromProto(media *roomv1.RoomMedia) RoomMedia {
	if media == nil {
		return RoomMedia{}
	}
	return RoomMedia{
		URL:          media.GetUrl(),
		ObjectKey:    media.GetObjectKey(),
		ContentType:  media.GetContentType(),
		Format:       media.GetFormat(),
		SizeBytes:    media.GetSizeBytes(),
		Width:        media.GetWidth(),
		Height:       media.GetHeight(),
		Animated:     media.GetAnimated(),
		FrameCount:   media.GetFrameCount(),
		DurationMS:   media.GetDurationMs(),
		PosterURL:    media.GetPosterUrl(),
		ThumbnailURL: media.GetThumbnailUrl(),
		SHA256:       media.GetSha256(),
		Status:       media.GetStatus(),
	}
}

func roomBackgroundToProto(background *RoomBackground) *roomv1.RoomBackgroundImage {
	if background == nil || background.BackgroundID <= 0 {
		return nil
	}
	return &roomv1.RoomBackgroundImage{
		BackgroundId:    background.BackgroundID,
		RoomId:          background.RoomID,
		ImageUrl:        background.Media.URL,
		CreatedByUserId: background.CreatedByUserID,
		CreatedAtMs:     background.CreatedAtMS,
		UpdatedAtMs:     background.UpdatedAtMS,
		Media:           roomMediaToProto(background.Media),
		CommandId:       background.CommandID,
	}
}

func roomBackgroundFromProto(background *roomv1.RoomBackgroundImage) *RoomBackground {
	if background == nil || background.GetBackgroundId() <= 0 {
		return nil
	}
	media := roomMediaFromProto(background.GetMedia())
	if media.URL == "" {
		// 旧快照只有 image_url；恢复时保留兼容 URL，但不会把缺少完整媒体事实的旧素材用于新的保存入口。
		media.URL = background.GetImageUrl()
	}
	return &RoomBackground{
		BackgroundID:    background.GetBackgroundId(),
		RoomID:          background.GetRoomId(),
		CreatedByUserID: background.GetCreatedByUserId(),
		CreatedAtMS:     background.GetCreatedAtMs(),
		UpdatedAtMS:     background.GetUpdatedAtMs(),
		CommandID:       background.GetCommandId(),
		Media:           media,
	}
}

func roomDecorationToProto(decoration *RoomDecoration) *roomv1.RoomDecorationResource {
	return roomDecorationToProtoAt(decoration, time.Now().UTC().UnixMilli())
}

func roomDecorationToProtoAt(decoration *RoomDecoration, nowMS int64) *roomv1.RoomDecorationResource {
	if decoration == nil || decoration.ResourceID <= 0 {
		return nil
	}
	if decoration.ExpiresAtMS > 0 && nowMS >= decoration.ExpiresAtMS {
		return nil
	}
	return &roomv1.RoomDecorationResource{
		ResourceId:    decoration.ResourceID,
		ResourceCode:  decoration.ResourceCode,
		ResourceType:  decoration.ResourceType,
		Name:          decoration.Name,
		AssetUrl:      decoration.AssetURL,
		PreviewUrl:    decoration.PreviewURL,
		AnimationUrl:  decoration.AnimationURL,
		Format:        decoration.Format,
		MetadataJson:  decoration.MetadataJSON,
		EntitlementId: decoration.EntitlementID,
		TextColors:    append([]string(nil), decoration.TextColors...),
		Stops:         append([]float64(nil), decoration.Stops...),
		AngleDegrees:  decoration.AngleDegrees,
		ExpiresAtMs:   decoration.ExpiresAtMS,
	}
}

func roomDecorationFromProto(decoration *roomv1.RoomDecorationResource) *RoomDecoration {
	if decoration == nil || decoration.GetResourceId() <= 0 {
		return nil
	}
	return &RoomDecoration{
		ResourceID:    decoration.GetResourceId(),
		ResourceCode:  decoration.GetResourceCode(),
		ResourceType:  decoration.GetResourceType(),
		Name:          decoration.GetName(),
		AssetURL:      decoration.GetAssetUrl(),
		PreviewURL:    decoration.GetPreviewUrl(),
		AnimationURL:  decoration.GetAnimationUrl(),
		Format:        decoration.GetFormat(),
		MetadataJSON:  decoration.GetMetadataJson(),
		EntitlementID: decoration.GetEntitlementId(),
		TextColors:    append([]string(nil), decoration.GetTextColors()...),
		Stops:         append([]float64(nil), decoration.GetStops()...),
		AngleDegrees:  decoration.GetAngleDegrees(),
		ExpiresAtMS:   decoration.GetExpiresAtMs(),
	}
}

func sortedSetIDs(values map[int64]bool) []int64 {
	// map 转数组时只导出 value=true 的成员，false 等价于不存在。
	ids := make([]int64, 0, len(values))

	for userID, enabled := range values {
		if enabled {
			ids = append(ids, userID)
		}
	}

	slices.Sort(ids)

	return ids
}

func sortedModerationIDs(values map[int64]UserModerationState) []int64 {
	ids := make([]int64, 0, len(values))
	for userID, moderation := range values {
		if moderation.UserID > 0 {
			ids = append(ids, userID)
		}
	}
	slices.Sort(ids)
	return ids
}

func sortedModerationStates(values map[int64]UserModerationState) []*roomv1.RoomBanState {
	ids := sortedModerationIDs(values)
	states := make([]*roomv1.RoomBanState, 0, len(ids))
	for _, userID := range ids {
		moderation := values[userID]
		states = append(states, &roomv1.RoomBanState{
			UserId:      moderation.UserID,
			ActorUserId: moderation.ActorUserID,
			CreatedAtMs: moderation.CreatedAtMS,
			ExpiresAtMs: moderation.ExpiresAtMS,
		})
	}
	return states
}

func sortedSetIDsExcept(values map[int64]bool, excluded int64) []int64 {
	ids := make([]int64, 0, len(values))
	for userID, enabled := range values {
		if enabled && userID != excluded {
			ids = append(ids, userID)
		}
	}
	slices.Sort(ids)
	return ids
}

func cloneStringMap(input map[string]string) map[string]string {
	if input == nil {
		// protobuf map 缺省按空 map 处理，调用方可以安全写入。
		return map[string]string{}
	}

	// RoomExt 是扩展字段集合，转换时必须复制，避免共享 map 引用。
	cloned := make(map[string]string, len(input))

	maps.Copy(cloned, input)

	return cloned
}

func cloneRocketState(input *RocketState) *RocketState {
	if input == nil {
		return nil
	}
	cloned := *input
	cloned.PendingLaunches = append([]RocketPendingLaunch(nil), input.PendingLaunches...)
	cloned.CurrentContributions = append([]RocketContribution(nil), input.CurrentContributions...)
	cloned.LastRewards = append([]RocketRewardGrant(nil), input.LastRewards...)
	return &cloned
}

func rocketStateToProto(input *RocketState) *roomv1.RoomRocketState {
	if input == nil {
		return nil
	}
	rewards := make([]*roomv1.RoomRocketRewardGrant, 0, len(input.LastRewards))
	for _, reward := range input.LastRewards {
		rewards = append(rewards, &roomv1.RoomRocketRewardGrant{
			RewardRole:      reward.RewardRole,
			UserId:          reward.UserID,
			RewardItemId:    reward.RewardItemID,
			ResourceGroupId: reward.ResourceGroupID,
			DisplayName:     reward.DisplayName,
			IconUrl:         reward.IconURL,
			GrantId:         reward.GrantID,
			Status:          reward.Status,
		})
	}
	pending := make([]*roomv1.RoomRocketPendingLaunch, 0, len(input.PendingLaunches))
	for _, launch := range input.PendingLaunches {
		pending = append(pending, &roomv1.RoomRocketPendingLaunch{
			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,
		})
	}
	contributions := make([]*roomv1.RoomRocketContribution, 0, len(input.CurrentContributions))
	for _, contribution := range input.CurrentContributions {
		contributions = append(contributions, &roomv1.RoomRocketContribution{
			UserId:      contribution.UserID,
			Fuel:        contribution.Fuel,
			UpdatedAtMs: contribution.UpdatedAtMS,
		})
	}
	return &roomv1.RoomRocketState{
		CurrentLevel:         input.CurrentLevel,
		CurrentFuel:          input.CurrentFuel,
		FuelThreshold:        input.FuelThreshold,
		Status:               input.Status,
		IgnitedAtMs:          input.IgnitedAtMS,
		LaunchAtMs:           input.LaunchAtMS,
		LaunchedAtMs:         input.LaunchedAtMS,
		ResetAtMs:            input.ResetAtMS,
		Top1UserId:           input.Top1UserID,
		IgniterUserId:        input.IgniterUserID,
		RocketId:             input.RocketID,
		ConfigVersion:        input.ConfigVersion,
		LastRewards:          rewards,
		PendingLaunches:      pending,
		CurrentContributions: contributions,
	}
}

func rocketStateFromProto(input *roomv1.RoomRocketState) *RocketState {
	if input == nil {
		return nil
	}
	rewards := make([]RocketRewardGrant, 0, len(input.GetLastRewards()))
	for _, reward := range input.GetLastRewards() {
		rewards = append(rewards, RocketRewardGrant{
			RewardRole:      reward.GetRewardRole(),
			UserID:          reward.GetUserId(),
			RewardItemID:    reward.GetRewardItemId(),
			ResourceGroupID: reward.GetResourceGroupId(),
			DisplayName:     reward.GetDisplayName(),
			IconURL:         reward.GetIconUrl(),
			GrantID:         reward.GetGrantId(),
			Status:          reward.GetStatus(),
		})
	}
	pending := make([]RocketPendingLaunch, 0, len(input.GetPendingLaunches()))
	for _, launch := range input.GetPendingLaunches() {
		pending = append(pending, RocketPendingLaunch{
			RocketID:      launch.GetRocketId(),
			Level:         launch.GetLevel(),
			FuelThreshold: launch.GetFuelThreshold(),
			IgnitedAtMS:   launch.GetIgnitedAtMs(),
			LaunchAtMS:    launch.GetLaunchAtMs(),
			ResetAtMS:     launch.GetResetAtMs(),
			Top1UserID:    launch.GetTop1UserId(),
			IgniterUserID: launch.GetIgniterUserId(),
			ConfigVersion: launch.GetConfigVersion(),
			CoverURL:      launch.GetCoverUrl(),
		})
	}
	contributions := make([]RocketContribution, 0, len(input.GetCurrentContributions()))
	for _, contribution := range input.GetCurrentContributions() {
		contributions = append(contributions, RocketContribution{
			UserID:      contribution.GetUserId(),
			Fuel:        contribution.GetFuel(),
			UpdatedAtMS: contribution.GetUpdatedAtMs(),
		})
	}
	return &RocketState{
		CurrentLevel:         input.GetCurrentLevel(),
		CurrentFuel:          input.GetCurrentFuel(),
		FuelThreshold:        input.GetFuelThreshold(),
		Status:               input.GetStatus(),
		IgnitedAtMS:          input.GetIgnitedAtMs(),
		LaunchAtMS:           input.GetLaunchAtMs(),
		LaunchedAtMS:         input.GetLaunchedAtMs(),
		ResetAtMS:            input.GetResetAtMs(),
		Top1UserID:           input.GetTop1UserId(),
		IgniterUserID:        input.GetIgniterUserId(),
		RocketID:             input.GetRocketId(),
		ConfigVersion:        input.GetConfigVersion(),
		PendingLaunches:      pending,
		CurrentContributions: contributions,
		LastRewards:          rewards,
	}
}
