package service

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"log/slog"
	"math"
	"strings"
	"time"

	roomv1 "hyapp.local/api/proto/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/logx"
	"hyapp/pkg/xerr"
)

const (
	roomListTabHot        = "hot"
	roomListTabNew        = "new"
	roomListTabVisited    = "visited"
	roomListTabFriend     = "friend"
	roomListTabFollowing  = "following"
	roomListTabFollowed   = "followed"
	defaultRoomListLimit  = 20
	maxRoomListLimit      = 50
	maxRoomListQueryRunes = 64
	maxRoomOwnerIDFilters = 500
	roomHotHeatWeight     = 650000
	roomHotOnlineWeight   = 300000
	roomHotMicWeight      = 50000
	roomHotOnlineCap      = 50
	roomHotMicCap         = 10
)

// roomListCursor 是服务端生成的不透明翻页游标。
// 客户端只能原样传回，不能依赖内部字段；tab 写入游标用于拒绝跨 tab 复用。
type roomListCursor struct {
	Tab           string `json:"tab"`
	Query         string `json:"query,omitempty"`
	CountryCode   string `json:"country_code,omitempty"`
	ViewerCountry string `json:"viewer_country,omitempty"`
	AllRegions    bool   `json:"all_regions,omitempty"`
	FilterRegion  int64  `json:"filter_region_id,omitempty"`
	SortScore     int64  `json:"sort_score,omitempty"`
	CreatedAtMS   int64  `json:"created_at_ms,omitempty"`
	UpdatedAtMS   int64  `json:"updated_at_ms,omitempty"`
	SubjectUserID int64  `json:"subject_user_id,omitempty"`
	PinListRank   int64  `json:"pin_list_rank,omitempty"`
	Pinned        bool   `json:"pinned,omitempty"`
	PinWeight     int64  `json:"pin_weight,omitempty"`
	PinnedUntilMS int64  `json:"pinned_until_ms,omitempty"`
	RoomID        string `json:"room_id"`
}

// ListRooms 查询公共房间发现列表读模型，并用服务端解析出的用户区域决定置顶和普通房排序桶。
func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	viewerUserID := req.GetViewerUserId()
	if viewerUserID <= 0 {
		// viewer_user_id 必须由 gateway 鉴权后填充，room-service 不接受匿名列表查询。
		return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
	}

	tab := normalizeRoomListTab(req.GetTab())
	if tab == "" {
		return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
	}

	limit := normalizeRoomListLimit(req.GetLimit())
	query, err := normalizeRoomListQuery(req.GetQuery())
	if err != nil {
		return nil, err
	}
	countryCode := normalizeRoomCountryCode(req.GetCountryCode())
	viewerCountryCode := normalizeRoomCountryCode(req.GetViewerCountryCode())
	allVisibleRegions := req.GetAllVisibleRegions()
	filterRegionID := req.GetFilterRegionId()
	if filterRegionID < 0 || (filterRegionID > 0 && (allVisibleRegions || countryCode != "")) {
		// 区域、国家和 All 是三种互斥的公共列表范围；拒绝混合语义避免游标跨范围漏数。
		return nil, xerr.New(xerr.InvalidArgument, "room list filter is invalid")
	}
	cursor, err := decodeRoomListCursor(tab, query, countryCode, viewerCountryCode, allVisibleRegions, filterRegionID, req.GetCursor())
	if err != nil {
		return nil, err
	}
	effectiveRegionID := normalizeVisibleRegionID(req.GetVisibleRegionId())
	if filterRegionID > 0 {
		// 缓存和 MySQL 读模型共用 VisibleRegionID 作为实际查询桶；游标仍单独保存 FilterRegionID 区分客户筛选。
		effectiveRegionID = filterRegionID
	}

	entries, err := s.listRoomListEntries(ctx, RoomListQuery{
		AppCode:             appcode.FromContext(ctx),
		VisibleRegionID:     effectiveRegionID,
		FilterRegionID:      filterRegionID,
		AllVisibleRegions:   allVisibleRegions,
		Tab:                 tab,
		Query:               query,
		CountryCode:         countryCode,
		ViewerCountryCode:   viewerCountryCode,
		Limit:               limit + 1,
		CursorSortScore:     cursor.SortScore,
		CursorCreatedAtMS:   cursor.CreatedAtMS,
		CursorRoomID:        cursor.RoomID,
		CursorPinListRank:   cursor.PinListRank,
		CursorPinned:        cursor.Pinned,
		CursorPinWeight:     cursor.PinWeight,
		CursorPinnedUntilMS: cursor.PinnedUntilMS,
		NowMS:               s.clock.Now().UnixMilli(),
	})
	if err != nil {
		return nil, err
	}

	return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, allVisibleRegions, filterRegionID, entries, limit), nil
}

func (s *Service) ListRoomsByOwners(ctx context.Context, req *roomv1.ListRoomsByOwnersRequest) (*roomv1.ListRoomsResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	if req.GetViewerUserId() <= 0 {
		// owner 集合必须由 gateway 在鉴权后根据业务归属注入，room-service 不接受匿名组织页查询。
		return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
	}
	tab := normalizeRoomListTab(req.GetTab())
	if tab == "" {
		return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
	}
	ownerUserIDs := normalizeRoomOwnerFilters(req.GetOwnerUserIds())
	if len(ownerUserIDs) == 0 {
		return roomListResponseFromEntries(tab, "", "", "", false, 0, nil, normalizeRoomListLimit(req.GetLimit())), nil
	}
	limit := normalizeRoomListLimit(req.GetLimit())
	query, err := normalizeRoomListQuery(req.GetQuery())
	if err != nil {
		return nil, err
	}
	cursor, err := decodeRoomListCursor(tab, query, "", "", false, 0, req.GetCursor())
	if err != nil {
		return nil, err
	}
	entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
		AppCode:           appcode.FromContext(ctx),
		Tab:               tab,
		OwnerUserIDs:      ownerUserIDs,
		Query:             query,
		Limit:             limit + 1,
		CursorSortScore:   cursor.SortScore,
		CursorCreatedAtMS: cursor.CreatedAtMS,
		CursorRoomID:      cursor.RoomID,
	})
	if err != nil {
		return nil, err
	}
	return roomListResponseFromEntries(tab, query, "", "", false, 0, entries, limit), nil
}

func (s *Service) listRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error) {
	if s != nil && s.roomListCache != nil && roomListCacheEligible(query) {
		entries, ok, err := s.roomListCache.ListRoomListEntries(ctx, query)
		if err == nil && ok {
			return entries, nil
		}
		if err != nil {
			// 缓存只是展示读模型，Redis 抖动或扫描保护不能影响发现页可用性；记录原因后走 MySQL 权威投影。
			logx.Warn(ctx, "room_list_cache_fallback", slog.String("tab", query.Tab), slog.String("error", err.Error()))
		}
	}
	return s.repository.ListRoomListEntries(ctx, query)
}

func roomListCacheEligible(query RoomListQuery) bool {
	// 搜索和内部定向查询仍走 MySQL；All/region/country 则都有可重建的 Redis 桶，可以承载首页高频读。
	if strings.TrimSpace(query.Query) != "" || query.OwnerUserID > 0 || len(query.OwnerUserIDs) > 0 {
		return false
	}
	return query.Tab == roomListTabHot || query.Tab == roomListTabNew
}

// ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	viewerUserID := req.GetViewerUserId()
	if viewerUserID <= 0 {
		// feed 一定绑定当前登录用户，不能允许匿名或客户端自选用户。
		return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
	}

	tab := normalizeRoomFeedTab(req.GetTab())
	if tab == "" {
		return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
	}

	limit := normalizeRoomListLimit(req.GetLimit())
	query, err := normalizeRoomListQuery(req.GetQuery())
	if err != nil {
		return nil, err
	}
	cursor, err := decodeRoomListCursor(tab, query, "", "", false, 0, req.GetCursor())
	if err != nil {
		return nil, err
	}
	if roomFeedTabRequiresRelationCursor(tab) && strings.TrimSpace(req.GetCursor()) != "" && cursor.SubjectUserID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "cursor is invalid")
	}

	var entries []RoomListEntry
	switch tab {
	case roomListTabVisited:
		entries, err = s.repository.ListRoomUserFeedEntries(ctx, RoomUserFeedQuery{
			AppCode:           appcode.FromContext(ctx),
			UserID:            viewerUserID,
			FeedType:          tab,
			VisibleRegionID:   normalizeVisibleRegionID(req.GetVisibleRegionId()),
			Query:             query,
			Limit:             limit + 1,
			CursorUpdatedAtMS: cursor.UpdatedAtMS,
			CursorRoomID:      cursor.RoomID,
		})
	case roomListTabFollowed:
		entries, err = s.repository.ListRoomFollowEntries(ctx, RoomFollowQuery{
			AppCode:            appcode.FromContext(ctx),
			UserID:             viewerUserID,
			VisibleRegionID:    normalizeVisibleRegionID(req.GetVisibleRegionId()),
			Query:              query,
			Limit:              limit + 1,
			CursorFollowedAtMS: cursor.UpdatedAtMS,
			CursorRoomID:       cursor.RoomID,
		})
	default:
		// friend/following 的关系事实属于 user-service；room-service 只根据 gateway 传入的一页关系用户查询 active 房间卡片。
		entries, err = s.repository.ListRoomRelatedFeedEntries(ctx, RoomRelatedFeedQuery{
			AppCode:             appcode.FromContext(ctx),
			VisibleRegionID:     normalizeVisibleRegionID(req.GetVisibleRegionId()),
			RelatedUsers:        roomFeedRelatedUsersFromProto(req.GetRelatedUsers()),
			Query:               query,
			Limit:               limit + 1,
			CursorUpdatedAtMS:   cursor.UpdatedAtMS,
			CursorSubjectUserID: cursor.SubjectUserID,
			CursorRoomID:        cursor.RoomID,
		})
	}
	if err != nil {
		return nil, err
	}

	return roomListResponseFromEntries(tab, query, "", "", false, 0, entries, limit), nil
}

// GetMyRoom 查询当前用户自己创建的房间，不依赖 room_list_entries 投影命中。
func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
	ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
	ownerUserID := req.GetOwnerUserId()
	if ownerUserID <= 0 {
		// owner_user_id 必须由 gateway 鉴权后填充，Mine 卡片不能接受客户端任意查人。
		return nil, xerr.New(xerr.InvalidArgument, "owner_user_id is required")
	}

	serverTimeMS := s.clock.Now().UnixMilli()
	meta, exists, err := s.repository.GetRoomMetaByOwner(ctx, ownerUserID)
	if err != nil {
		return nil, err
	}
	if !exists {
		return &roomv1.GetMyRoomResponse{ServerTimeMs: serverTimeMS}, nil
	}

	snapshot, err := s.currentSnapshot(ctx, meta.RoomID)
	if err != nil {
		return nil, err
	}

	return &roomv1.GetMyRoomResponse{
		HasRoom:      true,
		Room:         roomListItemToProto(roomListEntryFromMetaAndSnapshot(meta, snapshot, serverTimeMS)),
		ServerTimeMs: serverTimeMS,
	}, nil
}

func roomListResponseFromEntries(tab string, query string, countryCode string, viewerCountryCode string, allRegions bool, filterRegionID int64, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse {
	nextCursor := ""
	if len(entries) > limit {
		// 多查一条用于判断是否存在下一页；返回数据只包含请求的 limit 条。
		last := entries[limit-1]
		nextCursor = encodeRoomListCursor(tab, query, countryCode, viewerCountryCode, allRegions, filterRegionID, last)
		entries = entries[:limit]
	}

	items := make([]*roomv1.RoomListItem, 0, len(entries))
	for _, entry := range entries {
		// response 只暴露列表卡片字段，不把 repository 的 cursor 辅助字段泄露给客户端。
		items = append(items, roomListItemToProto(entry))
	}

	return &roomv1.ListRoomsResponse{
		Rooms:      items,
		NextCursor: nextCursor,
	}
}

func roomFeedRelatedUsersFromProto(items []*roomv1.RoomFeedRelatedUser) []RoomFeedRelatedUser {
	// gateway 已做关系授权，room-service 这里只过滤无效项并保持输入顺序供 repository 排序使用。
	relatedUsers := make([]RoomFeedRelatedUser, 0, len(items))
	for _, item := range items {
		if item == nil || item.GetUserId() <= 0 || item.GetRelationUpdatedAtMs() <= 0 {
			continue
		}
		relatedUsers = append(relatedUsers, RoomFeedRelatedUser{
			UserID:              item.GetUserId(),
			RelationUpdatedAtMS: item.GetRelationUpdatedAtMs(),
		})
	}
	return relatedUsers
}

// projectRoomListBestEffort 根据最新快照刷新房间列表投影。
func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) {
	if snapshot == nil || snapshot.GetRoomId() == "" {
		return
	}

	meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
	if err != nil {
		// 投影不能回滚已提交 Room Cell 命令，先记录日志；后续可用 projection task 补偿。
		logx.Error(ctx, "room_list_load_meta_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
		return
	}
	if !exists {
		logx.Warn(ctx, "room_list_meta_missing", slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
		return
	}

	nowMS := s.clock.Now().UnixMilli()
	entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
	if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
		// 列表读模型是最终一致；失败不改变命令成功语义。
		logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID))
		return
	}
	s.projectRoomListCacheBestEffort(ctx, entry)
}

func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry RoomListEntry) {
	if s == nil || s.roomListCache == nil {
		return
	}
	var err error
	if strings.TrimSpace(entry.Status) == "active" {
		err = s.roomListCache.UpsertRoomListEntry(ctx, entry)
	} else {
		err = s.roomListCache.DeleteRoomListEntry(ctx, entry.AppCode, entry.RoomID)
	}
	if err != nil {
		// Redis 列表缓存可由 MySQL 扫描重建，写失败不能回滚已经提交的房间命令。
		logx.Warn(ctx, "room_list_cache_project_failed", slog.String("room_id", entry.RoomID), slog.String("status", entry.Status), slog.String("error", err.Error()))
	}
}

// updateRoomOwnerVIPProjection 更新 MySQL 权威列表投影后复用同一张卡片刷新 Redis。
// wallet 查询发生在创建、房主进房或 MQ 消费阶段；房间列表读取路径永远只读本地投影。
func (s *Service) updateRoomOwnerVIPProjection(ctx context.Context, ownerUserID int64, vip effectiveVIPAccess) error {
	store, ok := s.repository.(RoomOwnerVIPProjectionStore)
	if !ok || ownerUserID <= 0 {
		return nil
	}
	var (
		entry  RoomListEntry
		exists bool
		err    error
	)
	if scheduledStore, scheduled := s.repository.(RoomOwnerVIPScheduledProjectionStore); scheduled {
		entry, exists, err = scheduledStore.UpdateRoomOwnerVIPSchedule(
			ctx, ownerUserID, vip.level, vip.expiresAtMS, vip.fallbackLevel, vip.fallbackExpiresAtMS,
		)
	} else {
		entry, exists, err = store.UpdateRoomOwnerVIP(ctx, ownerUserID, vip.level, vip.expiresAtMS)
	}
	if err != nil || !exists {
		return err
	}
	s.projectRoomListCacheBestEffort(ctx, entry)
	return nil
}

// projectRoomOwnerVIPBestEffort 用于已经成功的房间命令；展示投影失败不能回滚 Room Cell 状态。
func (s *Service) projectRoomOwnerVIPBestEffort(ctx context.Context, ownerUserID int64, vip effectiveVIPAccess) {
	if err := s.updateRoomOwnerVIPProjection(ctx, ownerUserID, vip); err != nil {
		logx.Warn(ctx, "room_owner_vip_project_failed",
			slog.Int64("owner_user_id", ownerUserID),
			slog.String("error", err.Error()),
		)
	}
}

// refreshRoomOwnerVIPProjection 只在低频触发点回查 wallet，并把 paid/trial 合并算法留在 wallet owner。
func (s *Service) refreshRoomOwnerVIPProjection(ctx context.Context, requestID string, ownerUserID int64) error {
	vip, err := s.loadEffectiveVIPAccess(ctx, requestID, ownerUserID)
	if err != nil {
		return err
	}
	return s.updateRoomOwnerVIPProjection(ctx, ownerUserID, vip)
}

func (s *Service) refreshRoomOwnerVIPProjectionBestEffort(ctx context.Context, requestID string, ownerUserID int64) {
	if err := s.refreshRoomOwnerVIPProjection(ctx, requestID, ownerUserID); err != nil {
		logx.Warn(ctx, "room_owner_vip_refresh_failed",
			slog.Int64("owner_user_id", ownerUserID),
			slog.String("error", err.Error()),
		)
	}
}

func (s *Service) deleteRoomListCacheBestEffort(ctx context.Context, roomID string) {
	if s == nil || s.roomListCache == nil {
		return
	}
	if err := s.roomListCache.DeleteRoomListEntry(ctx, appcode.FromContext(ctx), roomID); err != nil {
		// MySQL 删除已经是权威事实；缓存删除失败只能记录，后续全量重建会收敛。
		logx.Warn(ctx, "room_list_cache_delete_failed", slog.String("room_id", roomID), slog.String("error", err.Error()))
	}
}

// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
// 这里刻意只提取列表需要的字段，避免列表表结构反向绑定 RoomState 全量细节。
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {
	onlineCount := int32(len(snapshot.GetOnlineUsers()))
	seatCount := int32(len(snapshot.GetMicSeats()))
	occupiedSeatCount := int32(0)
	for _, seat := range snapshot.GetMicSeats() {
		if seat.GetUserId() != 0 {
			// 列表只需要占麦数量，不暴露具体麦位用户。
			occupiedSeatCount++
		}
	}

	// 展示资料仍放在 RoomExt，列表投影只在刷新时复制需要的字段。
	title := snapshot.GetRoomExt()["title"]
	coverURL := snapshot.GetRoomExt()["cover_url"]
	roomShortID := snapshot.GetRoomShortId()
	if roomShortID == "" {
		roomShortID = snapshot.GetRoomExt()["room_short_id"]
	}

	return RoomListEntry{
		AppCode:              snapshot.GetAppCode(),
		RoomID:               snapshot.GetRoomId(),
		RoomShortID:          roomShortID,
		VisibleRegionID:      normalizeVisibleRegionID(visibleRegionID),
		OwnerCountryCode:     normalizeRoomCountryCode(snapshot.GetRoomExt()[roomExtOwnerCountryKey]),
		OwnerUserID:          snapshot.GetOwnerUserId(),
		Title:                title,
		CoverURL:             coverURL,
		Mode:                 snapshot.GetMode(),
		Status:               snapshot.GetStatus(),
		Locked:               snapshot.GetLocked(),
		Heat:                 snapshot.GetHeat(),
		OnlineCount:          onlineCount,
		SeatCount:            seatCount,
		OccupiedSeatCount:    occupiedSeatCount,
		SortScore:            roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
		RocketNearCompletion: roomRocketNearCompletion(snapshot.GetRocket()),
		RoomBorderJSON:       marshalRoomListDecoration(snapshot.GetRoomBorder()),
		RoomNameStyleJSON:    marshalRoomListDecoration(snapshot.GetRoomNameStyle()),
		CreatedAtMS:          nowMS,
		UpdatedAtMS:          nowMS,
	}
}

func roomListSortScore(heat int64, onlineCount int32, occupiedSeatCount int32) int64 {
	// 热门分只属于列表读模型，不进入 RoomState；贡献值先做 log 压缩，避免历史大房间永久碾压当前有人房间。
	if heat < 0 {
		heat = 0
	}
	online := int64(onlineCount)
	if online < 0 {
		online = 0
	}
	if online > roomHotOnlineCap {
		online = roomHotOnlineCap
	}
	occupied := int64(occupiedSeatCount)
	if occupied < 0 {
		occupied = 0
	}
	if occupied > roomHotMicCap {
		occupied = roomHotMicCap
	}

	heatScore := math.Log2(float64(heat) + 1)
	onlineScore := math.Log2(float64(online) + 1)
	micScore := float64(occupied)
	return int64(heatScore*roomHotHeatWeight + onlineScore*roomHotOnlineWeight + micScore*roomHotMicWeight)
}

// normalizeRoomListTab 只支持公共发现列表 tab，Mine 关系流走 ListRoomFeeds。
func normalizeRoomListTab(tab string) string {
	switch strings.ToLower(strings.TrimSpace(tab)) {
	case "", roomListTabHot:
		return roomListTabHot
	case roomListTabNew:
		return roomListTabNew
	default:
		return ""
	}
}

func normalizeRoomFeedTab(tab string) string {
	// Mine 页房间流只允许明确 tab，避免空 tab 被误解成公共 hot 列表。
	switch strings.ToLower(strings.TrimSpace(tab)) {
	case roomListTabVisited:
		return roomListTabVisited
	case roomListTabFriend:
		return roomListTabFriend
	case roomListTabFollowing:
		return roomListTabFollowing
	case roomListTabFollowed:
		return roomListTabFollowed
	default:
		return ""
	}
}

func roomFeedTabRequiresRelationCursor(tab string) bool {
	// friend/following 的排序边界包含关系用户 ID，visited/followed 只需要房间 ID 和时间。
	return tab == roomListTabFriend || tab == roomListTabFollowing
}

func normalizeRoomListQuery(query string) (string, error) {
	query = strings.TrimSpace(query)
	if query == "" {
		return "", nil
	}
	if len([]rune(query)) > maxRoomListQueryRunes {
		// 搜索词进入 LIKE 查询，长度必须在入口收敛，避免大字符串放大数据库扫描成本。
		return "", xerr.New(xerr.InvalidArgument, "query is too long")
	}

	return query, nil
}

// normalizeRoomListLimit 统一列表分页上限，防止客户端用大 limit 打穿 MySQL 读模型。
func normalizeRoomListLimit(limit int32) int {
	if limit <= 0 {
		return defaultRoomListLimit
	}
	if limit > maxRoomListLimit {
		return maxRoomListLimit
	}
	return int(limit)
}

func normalizeRoomOwnerFilters(ownerUserIDs []int64) []int64 {
	seen := make(map[int64]struct{}, len(ownerUserIDs))
	ids := make([]int64, 0, len(ownerUserIDs))
	for _, userID := range ownerUserIDs {
		if userID <= 0 {
			continue
		}
		if _, exists := seen[userID]; exists {
			continue
		}
		seen[userID] = struct{}{}
		ids = append(ids, userID)
		if len(ids) >= maxRoomOwnerIDFilters {
			break
		}
	}
	return ids
}

// normalizeVisibleRegionID 把负数区域收敛到 GLOBAL 桶，避免非法区域影响 SQL 查询边界。
func normalizeVisibleRegionID(regionID int64) int64 {
	if regionID < 0 {
		return 0
	}
	return regionID
}

// normalizeRoomCountryCode 把国家码收敛为大写字母；非法值按空值处理，避免污染 SQL 精确筛选。
func normalizeRoomCountryCode(countryCode string) string {
	countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
	if len(countryCode) < 2 || len(countryCode) > 3 {
		return ""
	}
	for _, ch := range countryCode {
		if ch < 'A' || ch > 'Z' {
			return ""
		}
	}
	return countryCode
}

// decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致，禁止客户端跨排序维度复用游标。
func decodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, allRegions bool, filterRegionID int64, encoded string) (roomListCursor, error) {
	encoded = strings.TrimSpace(encoded)
	if encoded == "" {
		return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode, ViewerCountry: viewerCountryCode, AllRegions: allRegions, FilterRegion: filterRegionID}, nil
	}

	payload, err := base64.RawURLEncoding.DecodeString(encoded)
	if err != nil {
		return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
	}

	var cursor roomListCursor
	if err := json.Unmarshal(payload, &cursor); err != nil {
		return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
	}
	if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.ViewerCountry != viewerCountryCode || cursor.AllRegions != allRegions || cursor.FilterRegion != filterRegionID || cursor.RoomID == "" {
		return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
	}

	return cursor, nil
}

// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
// cursor 不暴露契约语义，后续调整排序字段时可以只兼容服务端解析逻辑。
func encodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, allRegions bool, filterRegionID int64, entry RoomListEntry) string {
	cursor := roomListCursor{
		Tab:           tab,
		Query:         query,
		CountryCode:   countryCode,
		ViewerCountry: viewerCountryCode,
		AllRegions:    allRegions,
		FilterRegion:  filterRegionID,
		SortScore:     entry.SortScore,
		CreatedAtMS:   entry.CreatedAtMS,
		UpdatedAtMS:   entry.UpdatedAtMS,
		SubjectUserID: entry.FeedSubjectUserID,
		PinListRank:   entry.PinListRank,
		Pinned:        entry.IsPinned,
		PinWeight:     entry.PinWeight,
		PinnedUntilMS: entry.PinnedUntilMS,
		RoomID:        entry.RoomID,
	}

	payload, err := json.Marshal(cursor)
	if err != nil {
		// 固定结构理论上不会 marshal 失败；失败时返回空游标让客户端停止翻页。
		return ""
	}

	return base64.RawURLEncoding.EncodeToString(payload)
}

// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
	nowMS := time.Now().UTC().UnixMilli()
	roomBorder := unmarshalRoomListDecoration(entry.RoomBorderJSON, nowMS)
	ownerVIPLevel := entry.OwnerVIPLevel
	if ownerVIPLevel <= 0 || entry.OwnerVIPExpiresAtMS <= nowMS {
		// 体验卡自然到期没有写事务事件；优先使用同一次 wallet 快照固化的 paid 回落，
		// 否则归零。列表仍然只读本地投影，不增加 wallet RPC 或逐房查询。
		ownerVIPLevel = entry.OwnerVIPFallbackLevel
		if ownerVIPLevel <= 0 || entry.OwnerVIPFallbackExpiresAtMS <= nowMS {
			ownerVIPLevel = 0
		}
	}
	return &roomv1.RoomListItem{
		AppCode:                    entry.AppCode,
		RoomId:                     entry.RoomID,
		RoomShortId:                entry.RoomShortID,
		OwnerUserId:                entry.OwnerUserID,
		Title:                      entry.Title,
		CoverUrl:                   entry.CoverURL,
		Mode:                       entry.Mode,
		Status:                     entry.Status,
		Locked:                     entry.Locked,
		Heat:                       entry.Heat,
		OnlineCount:                entry.OnlineCount,
		SeatCount:                  entry.SeatCount,
		OccupiedSeatCount:          entry.OccupiedSeatCount,
		VisibleRegionId:            entry.VisibleRegionID,
		CountryCode:                normalizeRoomCountryCode(entry.OwnerCountryCode),
		RoomBorder:                 roomBorder,
		RoomBorderUrl:              roomBorder.GetAssetUrl(),
		RoomNameStyle:              unmarshalRoomListDecoration(entry.RoomNameStyleJSON, nowMS),
		OwnerVipLevel:              ownerVIPLevel,
		RocketNearCompletion:       entry.RocketNearCompletion,
		HasActiveRedPacket:         entry.HasActiveRedPacket && entry.ActiveRedPacketExpiresAtMS > nowMS,
		ActiveRedPacketExpiresAtMs: entry.ActiveRedPacketExpiresAtMS,
	}
}

// roomRocketNearCompletion 只展示仍可积攒燃料的当前档；点火/完成态即使数值满格也不再显示冲刺图标。
// 用整数剩余量和 floor(threshold/5) 比较，等价于 remaining/threshold <= 20%，且避免乘法溢出。
func roomRocketNearCompletion(rocket *roomv1.RoomRocketState) bool {
	if rocket == nil || rocket.GetStatus() != "charging" || rocket.GetFuelThreshold() <= 0 {
		return false
	}
	currentFuel := rocket.GetCurrentFuel()
	threshold := rocket.GetFuelThreshold()
	if currentFuel < 0 || currentFuel > threshold {
		return false
	}
	return threshold-currentFuel <= threshold/5
}

func marshalRoomListDecoration(resource *roomv1.RoomDecorationResource) string {
	if resource == nil || resource.GetResourceId() <= 0 {
		return ""
	}
	payload, err := json.Marshal(resource)
	if err != nil {
		return ""
	}
	return string(payload)
}

func unmarshalRoomListDecoration(raw string, nowMS int64) *roomv1.RoomDecorationResource {
	if strings.TrimSpace(raw) == "" {
		return nil
	}
	var resource roomv1.RoomDecorationResource
	if err := json.Unmarshal([]byte(raw), &resource); err != nil || resource.GetResourceId() <= 0 ||
		(resource.GetExpiresAtMs() > 0 && nowMS >= resource.GetExpiresAtMs()) {
		return nil
	}
	return &resource
}

func roomListEntryFromMetaAndSnapshot(meta RoomMeta, snapshot *roomv1.RoomSnapshot, nowMS int64) RoomListEntry {
	if snapshot == nil || snapshot.GetRoomId() == "" {
		// GetMyRoom 兜底路径：即使快照暂时不可用，也能用 rooms 元数据返回“有房间”的最小卡片。
		return RoomListEntry{
			AppCode:          meta.AppCode,
			RoomID:           meta.RoomID,
			RoomShortID:      meta.RoomShortID,
			VisibleRegionID:  normalizeVisibleRegionID(meta.VisibleRegionID),
			OwnerCountryCode: "",
			OwnerUserID:      meta.OwnerUserID,
			Mode:             meta.Mode,
			Status:           meta.Status,
			Locked:           meta.RoomPasswordHash != "",
			SeatCount:        meta.SeatCount,
			CreatedAtMS:      nowMS,
			UpdatedAtMS:      nowMS,
		}
	}

	entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
	if entry.RoomShortID == "" {
		// 老快照可能没有 room_short_id，rooms meta 是短号的持久兜底来源。
		entry.RoomShortID = meta.RoomShortID
	}
	if entry.Mode == "" {
		// mode/status/owner 都从 meta 兜底，避免快照扩展字段缺失导致 Mine 卡片不可用。
		entry.Mode = meta.Mode
	}
	if entry.Status == "" {
		entry.Status = meta.Status
	}
	if entry.OwnerUserID <= 0 {
		entry.OwnerUserID = meta.OwnerUserID
	}
	return entry
}
