package service

import (
	"context"
	"log/slog"
	"strings"
	"time"

	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/logx"
	"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"
)

const joinRoomResponseModeLite = "lite"

// JoinRoom 把用户业务态接入房间。
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
	ctx = contextFromMeta(ctx, req.GetMeta())
	meta, err := s.canonicalRoomCommandMeta(ctx, req.GetMeta())
	if err != nil {
		return nil, err
	}
	password := strings.TrimSpace(req.GetPassword())
	// JoinRoom 只维护 room-service presence，不创建任何本地长连接订阅。
	cmd := command.JoinRoom{
		Base:                baseFromMeta(meta),
		Role:                req.GetRole(),
		ActorCountryID:      req.GetActorCountryId(),
		ActorRegionID:       req.GetActorRegionId(),
		ActorIsRobot:        req.GetActorIsRobot(),
		PresenceRecovery:    req.GetPresenceRecovery(),
		ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
		// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照，写入 command log 后可稳定重放。
		EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
	}

	if cmd.Role == "" {
		// 客户端不传角色时按普通观众进入房间。
		cmd.Role = "audience"
	}
	// 进房展示只查询一次 wallet effective state，并在响应、首次 outbox 与重复进房 direct IM 之间复用。
	// 查询失败不阻断核心 presence：VIP 入场表现可以降级，但房间可用性不能被纯展示依赖拖垮。
	joinVIP := effectiveVIPAccess{}
	if !cmd.ActorIsRobot && cmd.ActorUserID() > 0 && cmd.RoomID() != "" && cmd.ID() != "" {
		if loadedVIP, lookupErr := s.loadEffectiveVIPAccess(ctx, cmd.RequestID, cmd.ActorUserID()); lookupErr != nil {
			logx.Warn(ctx, "room_join_vip_lookup_failed",
				slog.String("room_id", cmd.RoomID()),
				slog.Int64("user_id", cmd.ActorUserID()),
				slog.String("error", lookupErr.Error()),
			)
		} else {
			joinVIP = loadedVIP
		}
	}
	reactivateClosedRoom, err := s.roomEntryPolicy(ctx, cmd.RoomID(), cmd.ActorUserID())
	if err != nil {
		return nil, err
	}

	result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		result := mutationResult{}
		if current.Status == state.RoomStatusClosed {
			if !reactivateClosedRoom || current.OwnerUserID != cmd.ActorUserID() {
				// 元数据和 Room Cell 任一层发现不是房主恢复 closed 房间，都必须继续拒绝进房。
				return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
			}
			current.Status = state.RoomStatusActive
			result.roomStatus = state.RoomStatusActive
			result.closeInfo = &RoomCloseInfo{ClearOnOpen: true}
		}
		if current.Status != state.RoomStatusActive {
			return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
		}
		if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
			if ban.ActiveAt(now.UnixMilli()) {
				// 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。
				return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
			}
			// 限时 ban 过期后由下一次进房命令清理，避免定时任务介入 Room Cell owner 状态。
			delete(current.BanUsers, cmd.ActorUserID())
		}

		if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() && !roomPasswordMatches(current.RoomPasswordHash, password) {
			// 锁房校验必须覆盖新进房和已有 presence 的重连；否则房主改密后，悬浮保活或 stale 未清理用户会绕过最新密码。
			return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
		}

		joinRole := cmd.Role
		if current.OwnerUserID == cmd.ActorUserID() {
			// 房主恢复 closed 房间时通常没有现存 presence，角色必须按房间 owner 事实收敛为 owner。
			joinRole = "owner"
		}
		if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
			existing.LastSeenAtMS = now.UnixMilli()
			current.Version++
			if cmd.PresenceRecovery {
				// 恢复请求与正常 heartbeat 可能并发；presence 已经存在时仍只刷新
				// last_seen，不能因竞态补发入场飘窗。
				snapshot := current.ToProto()
				result.snapshot = snapshot
				result.user = findProtoUser(snapshot, cmd.ActorUserID())
				return result, nil, nil
			}

			// 普通重复 JoinRoom 表示用户重新打开房间页或重连：业务 presence 只刷新
			// last_seen，但房间 UI 仍需要一条入房飘窗。该事件只进 direct IM，不进入 durable
			// outbox，避免统计服务把同一在线用户重复计为进房活跃。
			joinedDisplayEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, firstNonEmpty(existing.Role, cmd.Role), current.VisibleRegionID, joinVIP))
			if err != nil {
				return mutationResult{}, nil, err
			}
			snapshot := current.ToProto()
			result.snapshot = snapshot
			result.user = findProtoUser(snapshot, cmd.ActorUserID())
			result.directIMRecords = []outbox.Record{joinedDisplayEvent}
			return result, nil, nil
		}
		current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
			UserID:       cmd.ActorUserID(),
			Role:         joinRole,
			JoinedAtMS:   now.UnixMilli(),
			LastSeenAtMS: now.UnixMilli(),
		}
		current.Version++
		if cmd.PresenceRecovery {
			// stale worker 只清理了 room-service 的业务 presence，客户端 IM/TRTC 会话仍在。
			// 这里只补回 Room Cell 状态并返回快照；不创建 sync IM、direct IM 或 durable
			// outbox，否则全房会重复看到进房横幅/座驾，统计也会把恢复误当新的 RoomUserJoined。
			snapshot := current.ToProto()
			result.snapshot = snapshot
			result.user = findProtoUser(snapshot, cmd.ActorUserID())
			return result, nil, nil
		}

		// JoinRoom 成功需要 outbox 事件，腾讯云 IM 房间系统消息由 worker 异步投递。
		joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, joinRole, current.VisibleRegionID, joinVIP))
		if err != nil {
			return mutationResult{}, nil, err
		}

		snapshot := current.ToProto()

		result.snapshot = snapshot
		result.user = findProtoUser(snapshot, cmd.ActorUserID())
		result.syncEvent = &tencentim.RoomEvent{
			EventID:      joinedEvent.EventID,
			RoomID:       current.RoomID,
			EventType:    "room_user_joined",
			ActorUserID:  tencentim.FormatOptionalUserID(cmd.ActorUserID()),
			TargetUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
			RoomVersion:  current.Version,
			// syncEvent 与 outbox 事件保持同一份快照，避免低延迟路径和补偿路径 payload 不一致。
			EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
			Attributes:   roomUserJoinedIMAttributes(joinRole, cmd.ActorDisplayProfile, joinVIP),
		}
		return result, []outbox.Record{joinedEvent}, nil
	})
	if err != nil {
		return nil, err
	}
	if result.snapshot.GetOwnerUserId() == cmd.ActorUserID() {
		// 复用本次进房已经取得的 effective VIP；房主重连可修复历史列表投影且不增加任何查询。
		s.projectRoomOwnerVIPBestEffort(ctx, cmd.ActorUserID(), joinVIP)
	}
	s.projectRoomVisitBestEffort(ctx, cmd.ActorUserID(), cmd.RoomID())

	return &roomv1.JoinRoomResponse{
		Result:       commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
		User:         result.user,
		Room:         joinRoomResponseSnapshot(result.snapshot, req.GetResponseMode()),
		EffectiveVip: joinVIP.roomSnapshot(),
	}, nil
}

func joinRoomResponseSnapshot(snapshot *roomv1.RoomSnapshot, responseMode string) *roomv1.RoomSnapshot {
	if strings.ToLower(strings.TrimSpace(responseMode)) != joinRoomResponseModeLite || snapshot == nil {
		return snapshot
	}
	onlineCount := snapshot.GetOnlineCount()
	if onlineCount <= 0 && len(snapshot.GetOnlineUsers()) > 0 {
		onlineCount = int32(len(snapshot.GetOnlineUsers()))
	}
	// lite 只裁剪返回给 gateway 的快照；Room Cell 提交、持久化、projection、outbox 都已经使用完整 snapshot。
	return &roomv1.RoomSnapshot{
		RoomId:          snapshot.GetRoomId(),
		OwnerUserId:     snapshot.GetOwnerUserId(),
		Mode:            snapshot.GetMode(),
		Status:          snapshot.GetStatus(),
		ChatEnabled:     snapshot.GetChatEnabled(),
		MicSeats:        snapshot.GetMicSeats(),
		RoomExt:         snapshot.GetRoomExt(),
		Heat:            snapshot.GetHeat(),
		Version:         snapshot.GetVersion(),
		AppCode:         snapshot.GetAppCode(),
		RoomShortId:     snapshot.GetRoomShortId(),
		VisibleRegionId: snapshot.GetVisibleRegionId(),
		Locked:          snapshot.GetLocked(),
		Rocket:          snapshot.GetRocket(),
		OnlineCount:     onlineCount,
	}
}

func userDisplayProfileFromProto(item *roomv1.RoomUserDisplayProfile) command.UserDisplayProfile {
	if item == nil || item.GetUserId() <= 0 {
		return command.UserDisplayProfile{}
	}
	// 只信任 gateway 固化的展示快照；room-service 不在进房高频链路反查用户服务。
	return command.UserDisplayProfile{
		UserID:              item.GetUserId(),
		Nickname:            strings.TrimSpace(item.GetNickname()),
		Avatar:              strings.TrimSpace(item.GetAvatar()),
		DisplayUserID:       strings.TrimSpace(item.GetDisplayUserId()),
		PrettyDisplayUserID: strings.TrimSpace(item.GetPrettyDisplayUserId()),
	}
}

func roomUserJoinedEventFromCommand(cmd command.JoinRoom, role string, visibleRegionID int64, vip effectiveVIPAccess) *roomeventsv1.RoomUserJoined {
	profile := cmd.ActorDisplayProfile
	// 事件 body 是 direct IM 和 outbox 补偿的共同来源，展示字段必须和统计字段一起固定在命令提交时刻。
	return &roomeventsv1.RoomUserJoined{
		UserId:                 cmd.ActorUserID(),
		Role:                   role,
		VisibleRegionId:        visibleRegionID,
		CountryId:              cmd.ActorCountryID,
		RegionId:               firstNonZeroInt64(cmd.ActorRegionID, visibleRegionID),
		IsRobot:                cmd.ActorIsRobot,
		EntryVehicle:           eventEntryVehicleFromCommand(cmd.EntryVehicle),
		Nickname:               strings.TrimSpace(profile.Nickname),
		Avatar:                 strings.TrimSpace(profile.Avatar),
		DisplayUserId:          strings.TrimSpace(profile.DisplayUserID),
		PrettyDisplayUserId:    strings.TrimSpace(profile.PrettyDisplayUserID),
		VipProgramType:         vip.programType,
		EffectiveVipLevel:      vip.level,
		EffectiveVipName:       vip.name,
		RoomEntryNoticeEnabled: vip.roomEntryNoticeEnabled(),
	}
}

func roomUserJoinedIMAttributes(role string, profile command.UserDisplayProfile, vip effectiveVIPAccess) map[string]string {
	values := map[string]string{
		"role":                      strings.TrimSpace(role),
		"nickname":                  strings.TrimSpace(profile.Nickname),
		"avatar":                    strings.TrimSpace(profile.Avatar),
		"display_user_id":           strings.TrimSpace(profile.DisplayUserID),
		"pretty_display_user_id":    strings.TrimSpace(profile.PrettyDisplayUserID),
		"vip_program_type":          vip.programType,
		"effective_vip_level":       int32String(vip.level),
		"effective_vip_name":        vip.name,
		"room_entry_notice_enabled": boolString(vip.roomEntryNoticeEnabled()),
	}
	// 资料空字段不进 IM payload；VIP level/notice 即使为零值也必须保留，客户端据此明确关闭 VIP 入场表现。
	for key, value := range values {
		if value == "" && key != "effective_vip_name" {
			delete(values, key)
		}
	}
	return values
}

func entryVehicleSnapshotFromProto(item *roomv1.RoomEntryVehicleSnapshot) command.EntryVehicleSnapshot {
	if item == nil || item.GetResourceId() <= 0 {
		return command.EntryVehicleSnapshot{}
	}
	// 只裁剪字符串，不做有效期二次判断；wallet batch RPC 已经负责过期佩戴过滤。
	return command.EntryVehicleSnapshot{
		ResourceID:    item.GetResourceId(),
		ResourceCode:  strings.TrimSpace(item.GetResourceCode()),
		Name:          strings.TrimSpace(item.GetName()),
		AssetURL:      strings.TrimSpace(item.GetAssetUrl()),
		PreviewURL:    strings.TrimSpace(item.GetPreviewUrl()),
		AnimationURL:  strings.TrimSpace(item.GetAnimationUrl()),
		MetadataJSON:  strings.TrimSpace(item.GetMetadataJson()),
		EntitlementID: strings.TrimSpace(item.GetEntitlementId()),
		ExpiresAtMS:   item.GetExpiresAtMs(),
	}
}

func eventEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *roomeventsv1.RoomEntryVehicleSnapshot {
	if item.ResourceID <= 0 {
		return nil
	}
	// 事件 proto 是补偿投递、统计消费的稳定事实，字段不依赖后续资源表变更。
	return &roomeventsv1.RoomEntryVehicleSnapshot{
		ResourceId:    item.ResourceID,
		ResourceCode:  item.ResourceCode,
		Name:          item.Name,
		AssetUrl:      item.AssetURL,
		PreviewUrl:    item.PreviewURL,
		AnimationUrl:  item.AnimationURL,
		MetadataJson:  item.MetadataJSON,
		EntitlementId: item.EntitlementID,
		ExpiresAtMs:   item.ExpiresAtMS,
	}
}

func imEntryVehicleFromCommand(item command.EntryVehicleSnapshot) *tencentim.RoomEntryVehicleSnapshot {
	if item.ResourceID <= 0 {
		return nil
	}
	// Tencent IM payload 使用客户端展示字段，保持和 /appearance 的资源字段命名一致。
	return &tencentim.RoomEntryVehicleSnapshot{
		ResourceID:    item.ResourceID,
		ResourceCode:  item.ResourceCode,
		Name:          item.Name,
		AssetURL:      item.AssetURL,
		PreviewURL:    item.PreviewURL,
		AnimationURL:  item.AnimationURL,
		MetadataJSON:  item.MetadataJSON,
		EntitlementID: item.EntitlementID,
		ExpiresAtMS:   item.ExpiresAtMS,
	}
}

// projectRoomVisitBestEffort 记录 Mine/Visited tab 的房间访问索引。
// 这是列表读模型，不参与 Room Cell 提交；失败只记录日志，不能影响已经成功的 JoinRoom。
func (s *Service) projectRoomVisitBestEffort(ctx context.Context, userID int64, roomID string) {
	if userID <= 0 || roomID == "" {
		return
	}
	nowMS := s.clock.Now().UnixMilli()
	if err := s.repository.UpsertRoomUserFeedEntry(ctx, RoomUserFeedEntry{
		AppCode:     appcode.FromContext(ctx),
		UserID:      userID,
		FeedType:    roomListTabVisited,
		RoomID:      roomID,
		CreatedAtMS: nowMS,
		UpdatedAtMS: nowMS,
	}); err != nil {
		logx.Error(ctx, "room_visit_feed_upsert_failed", err,
			slog.String("component", "room_user_feed_projector"),
			slog.String("room_id", roomID),
			slog.Int64("user_id", userID),
		)
	}
}

// RoomHeartbeat 显式刷新已在房间内用户的业务 presence。
func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
	ctx = contextFromMeta(ctx, req.GetMeta())
	cmd := command.RoomHeartbeat{
		Base: baseFromMeta(req.GetMeta()),
	}

	result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		if err := requireActiveRoom(current); err != nil {
			return mutationResult{}, nil, err
		}
		if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
			if ban.ActiveAt(now.UnixMilli()) {
				// ban 用户不能通过心跳续住业务 presence；KickUser 已经清理 presence，这里是防御脏状态。
				return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "user is banned")
			}
			// 过期 ban 不再阻断心跳；保守清理后继续按 presence 是否存在判断。
			delete(current.BanUsers, cmd.ActorUserID())
		}

		existing, exists := current.OnlineUsers[cmd.ActorUserID()]
		if !exists {
			// presence 已丢失但客户端仍在按房间节奏心跳，说明发生过 stale 清理或 owner
			// 迁移这类服务端单方面清理；不恢复的话该用户会陷入 MicUp 404、RTC token 403
			// 的粘性失败，只能退房重进。这里按"心跳即在房"原地恢复。
			// 安全边界与 JoinRoom 对齐：room active 已在上方校验，活跃 ban（踢人/封禁）
			// 已在上方拦截；锁房进入需要密码而心跳不携带密码，非房主不得借心跳绕过锁房，
			// 维持原 NotFound 契约让客户端走完整 JoinRoom 补交密码。
			if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() {
				return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
			}
			// 与 JoinRoom presence_recovery 相同的静默语义：只补 Room Cell 状态，不发
			// sync IM/direct IM/durable outbox，避免重复入房横幅与统计重复计数。
			// LeaveRoom 后仍在途的迟到心跳会短暂恢复 presence，由 stale worker 在下一个
			// 清理窗口自然移除，期间同样不产生任何对外事件。
			current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
				UserID:       cmd.ActorUserID(),
				Role:         recoveredPresenceRole(current, cmd.ActorUserID()),
				JoinedAtMS:   now.UnixMilli(),
				LastSeenAtMS: now.UnixMilli(),
			}
			current.Version++
			snapshot := current.ToProto()
			return mutationResult{
				snapshot: snapshot,
				user:     findProtoUser(snapshot, cmd.ActorUserID()),
			}, nil, nil
		}

		existing.LastSeenAtMS = now.UnixMilli()
		current.Version++

		snapshot := current.ToProto()
		return mutationResult{
			snapshot: snapshot,
			user:     findProtoUser(snapshot, cmd.ActorUserID()),
		}, nil, nil
	})
	if err != nil {
		return nil, err
	}

	return &roomv1.RoomHeartbeatResponse{
		Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
		User:   result.user,
		Room:   result.snapshot,
	}, nil
}

// recoveredPresenceRole 推导心跳恢复 presence 时的展示角色。房间权限判定始终以
// OwnerUserID/AdminUsers 权威字段为准（见 actorRole），这里只保证在线列表的房主/
// 管理员徽标不因恢复而丢失；无法从权威字段证明的身份一律回落 audience，不存在提权。
func recoveredPresenceRole(current *state.RoomState, userID int64) string {
	switch {
	case current.OwnerUserID == userID:
		return "owner"
	case current.AdminUsers[userID]:
		return "admin"
	default:
		return "audience"
	}
}

// LeaveRoom 把用户从房间业务态移出。
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
	ctx = contextFromMeta(ctx, req.GetMeta())
	// LeaveRoom 只移除 room-service 业务 presence，真实 IM 连接态由腾讯云 IM SDK 处理。
	cmd := command.LeaveRoom{
		Base: baseFromMeta(req.GetMeta()),
	}

	result, err := s.leaveRoom(ctx, cmd, "")
	if err != nil {
		return nil, err
	}

	return &roomv1.LeaveRoomResponse{
		Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
		Room:   result.snapshot,
	}, nil
}

func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason string) (mutationResult, error) {
	return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
		_, inRoom := current.OnlineUsers[cmd.ActorUserID()]
		seat, onSeat := current.SeatByUser(cmd.ActorUserID())
		if !inRoom && !onSeat {
			// 离房幂等：没有业务 presence 且不占麦时不写命令日志和 outbox。
			return mutationResult{snapshot: current.ToProto()}, nil, nil
		}

		delete(current.OnlineUsers, cmd.ActorUserID())
		var micSessionID string
		if onSeat {
			// 离房必须同步释放麦位；同时写 RoomMicChanged/down，给时长聚合一个稳定闭合事件。
			index := current.SeatIndex(seat.SeatNo)
			if index >= 0 {
				micSessionID = current.MicSeats[index].MicSessionID
				current.ClearMicSession(index)
			}
		}
		current.Version++

		records := make([]outbox.Record, 0, 2)
		if onSeat && micSessionID != "" {
			micReason := reason
			if micReason == "" {
				micReason = "leave"
			}
			micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.ActorUserID(), seat.SeatNo, micSessionID, micReason)
			if err != nil {
				return mutationResult{}, nil, err
			}
			records = append(records, micEvent)
		}

		// 离房事件进入 outbox；stale 清理会通过 attributes 标记原因，腾讯云 IM 由 worker 异步投递。
		leftEvent, err := outbox.Build(current.RoomID, "RoomUserLeft", current.Version, now, &roomeventsv1.RoomUserLeft{
			UserId: cmd.ActorUserID(),
		})
		if err != nil {
			return mutationResult{}, nil, err
		}
		records = append(records, leftEvent)

		attributes := map[string]string{}
		if reason != "" {
			attributes["reason"] = reason
		}

		return mutationResult{
			snapshot: current.ToProto(),
			syncEvent: &tencentim.RoomEvent{
				EventID:      leftEvent.EventID,
				RoomID:       current.RoomID,
				EventType:    "room_user_left",
				ActorUserID:  tencentim.FormatOptionalUserID(cmd.ActorUserID()),
				TargetUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
				RoomVersion:  current.Version,
				Attributes:   attributes,
			},
		}, records, nil
	})
}

// RunPresenceStaleWorker 周期性清理本节点已装载房间中的过期业务 presence。
// worker 只调用 room-service 命令链路，不触碰腾讯云 IM 连接态，客户端重连后仍需重新 JoinRoom。
func (s *Service) RunPresenceStaleWorker(ctx context.Context, interval time.Duration) {
	if interval <= 0 || s.presenceStaleAfter <= 0 {
		// 非正数表示显式关闭 worker，测试或特殊环境可用。
		return
	}

	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			_ = s.SweepStalePresence(ctx)
		}
	}
}

// SweepStalePresence 扫描并清理已经超过 last_seen 阈值的业务 presence。
// 该方法暴露给测试和后台 worker 复用，所有清理都复用 LeaveRoom 的持久化、快照和 outbox 语义。
func (s *Service) SweepStalePresence(ctx context.Context) error {
	if s.presenceStaleAfter <= 0 {
		return nil
	}

	now := s.clock.Now()
	cutoffMs := now.Add(-s.presenceStaleAfter).UnixMilli()
	for _, roomRef := range s.loadedRoomRefs() {
		roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
		lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
		if err != nil {
			return err
		}
		if !owned {
			continue
		}

		roomCell := s.loadCellForLease(roomCtx, roomRef.RoomID, lease)
		if roomCell == nil {
			continue
		}

		currentState, _, err := roomCell.Snapshot(roomCtx)
		if err != nil {
			return err
		}
		owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
		if err != nil {
			return err
		}
		if !owned {
			continue
		}

		for _, userID := range stalePresenceUserIDs(currentState, cutoffMs) {
			owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
			if err != nil {
				return err
			}
			if !owned {
				break
			}

			cmd := command.LeaveRoom{
				Base: command.Base{
					RequestID:     idgen.New("req_presence_stale"),
					CommandID:     idgen.New("cmd_presence_stale"),
					ActorID:       userID,
					Room:          roomRef.RoomID,
					GatewayNodeID: s.nodeID,
					SessionID:     "presence-stale",
					SentAtMS:      now.UnixMilli(),
				},
			}
			if _, err := s.leaveRoom(roomCtx, cmd, "stale_timeout"); err != nil {
				return err
			}
		}
	}

	return nil
}

func stalePresenceUserIDs(current *state.RoomState, cutoffMs int64) []int64 {
	if current == nil {
		return nil
	}

	robotUserIDs := map[int64]bool(nil)
	if current.RoomExt[roomExtRobotRoomKey] == "true" {
		// 机器人房的内部机器人没有客户端心跳；它们的生命周期由机器人房配置和运行时管理，
		// 不能被普通用户 stale worker 清掉，否则送礼循环会因为 sender/target 不在房间而失败。
		robotUserIDs = parseInt64CSV(current.RoomExt[roomExtRobotUserIDsKey])
	}

	userIDs := make([]int64, 0)
	for userID, userState := range current.OnlineUsers {
		if robotUserIDs[userID] {
			continue
		}
		if userState != nil && userState.LastSeenAtMS <= cutoffMs {
			userIDs = append(userIDs, userID)
		}
	}

	return userIDs
}
