package roomapi

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"hyapp/services/gateway-service/internal/transport/http/httpkit"
	"net/http"
	"strconv"
	"strings"
	"time"

	"hyapp/pkg/appcode"
	"hyapp/pkg/roomid"
	"hyapp/pkg/xerr"
	"hyapp/services/gateway-service/internal/auth"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	activityv1 "hyapp.local/api/proto/activity/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
)

const (
	defaultRoomFeedRelationPageSize = 100
	maxRoomFeedRelationScanCount    = 500

	// 房间展示资料只读取客户端能在首屏、公屏或资料卡直接渲染的当前佩戴资源。
	roomAppearanceResourceAvatarFrame      = "avatar_frame"
	roomAppearanceResourceProfileCard      = "profile_card"
	roomAppearanceResourceVehicle          = "vehicle"
	roomAppearanceResourceBadge            = "badge"
	roomAppearanceResourceMicSeatAnimation = "mic_seat_animation"

	// 机器人房标记来自 room-service RoomExt；gateway 只用它决定首屏资料是否补机器人头像昵称。
	roomExtRobotRoomKey    = "robot_room"
	roomExtRobotUserIDsKey = "robot_user_ids"
)

type flexibleInt64 int64

func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
	raw := strings.TrimSpace(string(data))
	if raw == "" || raw == "null" {
		*v = 0
		return nil
	}
	if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
		var text string
		if err := json.Unmarshal(data, &text); err != nil {
			return err
		}
		raw = strings.TrimSpace(text)
	}
	if raw == "" {
		*v = 0
		return nil
	}
	parsed, err := strconv.ParseInt(raw, 10, 64)
	if err != nil {
		return err
	}
	*v = flexibleInt64(parsed)
	return nil
}

type roomFeedRelationCursor struct {
	Tab           string `json:"tab"`
	Query         string `json:"query,omitempty"`
	UpdatedAtMS   int64  `json:"updated_at_ms,omitempty"`
	SubjectUserID int64  `json:"subject_user_id,omitempty"`
	RoomID        string `json:"room_id"`
}

// listRooms 按当前登录用户的服务端 region_id 查询房间发现列表。
func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
	limit, ok := parseRoomListLimit(request.URL.Query().Get("limit"))
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	tab, ok := parsePublicRoomListTab(request.URL.Query().Get("tab"))
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	if h.userProfileClient == nil || h.roomQueryClient == nil || h.userRegionClient == nil || h.userCountryClient == nil {
		// 列表入口必须同时依赖 user-service 用户区域、区域国家、国家主数据和 room-service 读模型。
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}

	viewerUserID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: viewerUserID,
	})
	if err != nil {
		// user-service 是 region_id 的唯一 owner，查询失败不能用 IP 国家兜底。
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	countries, err := h.roomListCountriesForUser(request, userResp.GetUser())
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	countryCode, ok := roomListCountryQuery(request, countries)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	allVisibleRegions := h.roomRegionWhitelistAllows(request, viewerUserID)

	resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{
		Meta:              httpkit.RoomMeta(request, "", ""),
		ViewerUserId:      viewerUserID,
		VisibleRegionId:   userResp.GetUser().GetRegionId(),
		ViewerCountryCode: userResp.GetUser().GetCountry(),
		AllVisibleRegions: allVisibleRegions,
		Tab:               tab,
		Cursor:            request.URL.Query().Get("cursor"),
		Limit:             limit,
		Query:             roomListQuery(request),
		CountryCode:       countryCode,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	data := roomListDataFromProto(resp)
	data.Countries = countries
	httpkit.WriteOK(writer, request, data)
}

// listRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。
func (h *Handler) listRoomFeeds(writer http.ResponseWriter, request *http.Request) {
	if h.userProfileClient == nil || h.roomQueryClient == nil {
		// feed 同样需要 user-service 区域归属，避免跨区域露出用户关系流房间。
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}

	limit, ok := parseRoomListLimit(request.URL.Query().Get("limit"))
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	tab, ok := parseRoomFeedTab(request.URL.Query().Get("tab"))
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	viewerUserID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: viewerUserID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}

	relatedUsers, ok, err := h.roomFeedRelatedUsers(request, tab, roomListQuery(request), request.URL.Query().Get("cursor"))
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}

	resp, err := h.roomQueryClient.ListRoomFeeds(request.Context(), &roomv1.ListRoomFeedsRequest{
		Meta:            httpkit.RoomMeta(request, "", ""),
		ViewerUserId:    viewerUserID,
		VisibleRegionId: userResp.GetUser().GetRegionId(),
		Tab:             tab,
		Cursor:          request.URL.Query().Get("cursor"),
		Limit:           limit,
		Query:           roomListQuery(request),
		RelatedUsers:    relatedUsers,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, roomListDataFromProto(resp))
}

func (h *Handler) roomRegionWhitelistAllows(request *http.Request, viewerUserID int64) bool {
	if h.appConfigReader == nil || viewerUserID <= 0 {
		return false
	}
	allowed, err := h.appConfigReader.RoomRegionWhitelistAllows(request.Context(), appcode.FromContext(request.Context()), viewerUserID)
	if err != nil {
		// 白名单读取失败时必须 fail-closed，避免后台配置或缓存异常放大成跨区域房间泄露。
		return false
	}
	return allowed
}

func (h *Handler) roomFeedRelatedUsers(request *http.Request, tab string, query string, rawCursor string) ([]*roomv1.RoomFeedRelatedUser, bool, error) {
	if tab == "visited" || tab == "followed" {
		return nil, true, nil
	}
	if h.userSocialClient == nil {
		// friend/following 必须以 user-service 关系事实为准，不能退回旧 room feed 投影。
		return nil, true, xerr.New(xerr.Unavailable, "user social client is not configured")
	}

	cursor, ok := decodeRoomFeedRelationCursor(tab, query, rawCursor)
	if !ok {
		return nil, false, nil
	}
	viewerUserID := auth.UserIDFromContext(request.Context())
	relatedUsers := make([]*roomv1.RoomFeedRelatedUser, 0, defaultRoomFeedRelationPageSize)
	for len(relatedUsers) < maxRoomFeedRelationScanCount {
		batch, nextCursor, err := h.roomFeedRelatedUserBatch(request, tab, viewerUserID, cursor)
		if err != nil {
			return nil, true, err
		}
		relatedUsers = append(relatedUsers, batch...)
		if len(batch) < defaultRoomFeedRelationPageSize {
			break
		}
		cursor = nextCursor
	}
	return relatedUsers, true, nil
}

func (h *Handler) roomFeedRelatedUserBatch(request *http.Request, tab string, viewerUserID int64, cursor roomFeedRelationCursor) ([]*roomv1.RoomFeedRelatedUser, roomFeedRelationCursor, error) {
	switch tab {
	case "following":
		resp, err := h.userSocialClient.ListFollowing(request.Context(), &userv1.ListFollowingRequest{
			Meta:              httpkit.UserMeta(request, ""),
			UserId:            viewerUserID,
			Page:              1,
			PageSize:          defaultRoomFeedRelationPageSize,
			CursorUpdatedAtMs: cursor.UpdatedAtMS,
			CursorUserId:      cursor.SubjectUserID,
		})
		if err != nil {
			return nil, cursor, err
		}
		relatedUsers := make([]*roomv1.RoomFeedRelatedUser, 0, len(resp.GetRecords()))
		for _, record := range resp.GetRecords() {
			relatedUsers = append(relatedUsers, &roomv1.RoomFeedRelatedUser{
				UserId:              record.GetFolloweeUserId(),
				RelationUpdatedAtMs: record.GetFollowedAtMs(),
			})
			cursor.UpdatedAtMS = record.GetFollowedAtMs()
			cursor.SubjectUserID = record.GetFolloweeUserId()
		}
		return relatedUsers, cursor, nil
	case "friend":
		resp, err := h.userSocialClient.ListFriends(request.Context(), &userv1.ListFriendsRequest{
			Meta:              httpkit.UserMeta(request, ""),
			UserId:            viewerUserID,
			Page:              1,
			PageSize:          defaultRoomFeedRelationPageSize,
			CursorUpdatedAtMs: cursor.UpdatedAtMS,
			CursorUserId:      cursor.SubjectUserID,
		})
		if err != nil {
			return nil, cursor, err
		}
		relatedUsers := make([]*roomv1.RoomFeedRelatedUser, 0, len(resp.GetRecords()))
		for _, record := range resp.GetRecords() {
			relatedUsers = append(relatedUsers, &roomv1.RoomFeedRelatedUser{
				UserId:              record.GetFriendUserId(),
				RelationUpdatedAtMs: record.GetFriendedAtMs(),
			})
			cursor.UpdatedAtMS = record.GetFriendedAtMs()
			cursor.SubjectUserID = record.GetFriendUserId()
		}
		return relatedUsers, cursor, nil
	default:
		return nil, cursor, xerr.New(xerr.InvalidArgument, "tab is invalid")
	}
}

func decodeRoomFeedRelationCursor(tab string, query string, raw string) (roomFeedRelationCursor, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return roomFeedRelationCursor{Tab: tab, Query: query}, true
	}
	payload, err := base64.RawURLEncoding.DecodeString(raw)
	if err != nil {
		return roomFeedRelationCursor{}, false
	}
	var cursor roomFeedRelationCursor
	if err := json.Unmarshal(payload, &cursor); err != nil {
		return roomFeedRelationCursor{}, false
	}
	if cursor.Tab != tab || cursor.Query != query || cursor.RoomID == "" || cursor.UpdatedAtMS <= 0 || cursor.SubjectUserID <= 0 {
		return roomFeedRelationCursor{}, false
	}
	return cursor, true
}

func parsePublicRoomListTab(raw string) (string, bool) {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "", "hot":
		return "hot", true
	case "new":
		return "new", true
	default:
		return "", false
	}
}

func parseRoomFeedTab(raw string) (string, bool) {
	switch strings.ToLower(strings.TrimSpace(raw)) {
	case "visited":
		return "visited", true
	case "friend":
		return "friend", true
	case "following":
		return "following", true
	case "followed":
		return "followed", true
	default:
		return "", false
	}
}

func roomListQuery(request *http.Request) string {
	if query := strings.TrimSpace(request.URL.Query().Get("q")); query != "" {
		return query
	}

	return strings.TrimSpace(request.URL.Query().Get("query"))
}

func (h *Handler) roomListCountriesForUser(request *http.Request, user *userv1.User) ([]roomListCountryData, error) {
	if user == nil || user.GetRegionId() <= 0 {
		// 当前用户区域来自 user-service 主数据；缺失时不能猜测区域国家列表。
		return nil, xerr.New(xerr.InvalidArgument, "user region is required")
	}
	regionResp, err := h.userRegionClient.GetRegion(request.Context(), &userv1.GetRegionRequest{
		Meta:     httpkit.UserMeta(request, ""),
		RegionId: user.GetRegionId(),
	})
	if err != nil {
		return nil, err
	}
	countryResp, err := h.userCountryClient.ListRegistrationCountries(request.Context(), &userv1.ListRegistrationCountriesRequest{
		Meta: httpkit.UserMeta(request, ""),
	})
	if err != nil {
		return nil, err
	}
	return roomListCountriesFromProto(user, regionResp.GetRegion(), countryResp.GetCountries()), nil
}

func roomListCountriesFromProto(user *userv1.User, region *userv1.Region, countries []*userv1.Country) []roomListCountryData {
	countriesByCode := make(map[string]*userv1.Country, len(countries))
	for _, country := range countries {
		code := normalizeRoomListCountryCode(country.GetCountryCode())
		if code == "" {
			continue
		}
		countriesByCode[code] = country
	}

	seen := make(map[string]struct{})
	regionCodes := make([]string, 0, len(region.GetCountries()))
	regionCodeSet := make(map[string]struct{}, len(region.GetCountries()))
	if region != nil {
		for _, rawCode := range region.GetCountries() {
			code := normalizeRoomListCountryCode(rawCode)
			if code == "" {
				continue
			}
			regionCodes = append(regionCodes, code)
			regionCodeSet[code] = struct{}{}
		}
	}
	result := make([]roomListCountryData, 0, len(countriesByCode))
	add := func(code string) {
		code = normalizeRoomListCountryCode(code)
		if code == "" {
			return
		}
		if _, exists := seen[code]; exists {
			return
		}
		seen[code] = struct{}{}
		result = append(result, roomListCountryDataFromProto(code, countriesByCode[code]))
	}

	// 国家列表严格来自当前区域；用户国家命中区域配置时提前到首位，其余国家保持 user-service 区域顺序。
	if user != nil {
		if _, exists := regionCodeSet[normalizeRoomListCountryCode(user.GetCountry())]; exists {
			add(user.GetCountry())
		}
	}
	for _, code := range regionCodes {
		add(code)
	}
	return result
}

func roomListCountryDataFromProto(code string, country *userv1.Country) roomListCountryData {
	item := roomListCountryData{
		CountryCode: code,
		CountryFlag: roomProfileCountryFlag(code),
	}
	if country == nil {
		return item
	}
	item.CountryID = country.GetCountryId()
	item.CountryName = strings.TrimSpace(country.GetCountryName())
	item.CountryDisplayName = strings.TrimSpace(country.GetCountryDisplayName())
	if flag := strings.TrimSpace(country.GetFlag()); flag != "" {
		item.CountryFlag = flag
	}
	item.SortOrder = country.GetSortOrder()
	return item
}

func roomListCountryQuery(request *http.Request, countries []roomListCountryData) (string, bool) {
	raw := strings.TrimSpace(request.URL.Query().Get("country"))
	if raw == "" {
		raw = strings.TrimSpace(request.URL.Query().Get("country_code"))
	}
	code := normalizeRoomListCountryCode(raw)
	if code == "" {
		return "", raw == ""
	}
	for _, country := range countries {
		if normalizeRoomListCountryCode(country.CountryCode) == code {
			return code, true
		}
	}
	return "", false
}

func normalizeRoomListCountryCode(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
}

// parseRoomListLimit 只做 HTTP 字符串到数字的传输层解析。
// 默认值和最大值仍由 room-service 统一裁剪，避免 gateway 和查询服务出现双份策略。
func parseRoomListLimit(raw string) (int32, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return 0, true
	}

	value, err := strconv.Atoi(raw)
	if err != nil {
		return 0, false
	}

	return int32(value), true
}

// getMyRoom 返回 Mine 顶部“我的房间”卡片。
// 这个入口直接查 room-service 的 owner 权威数据，不依赖发现页 room_list_entries 投影是否命中。
func (h *Handler) getMyRoom(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}

	resp, err := h.roomQueryClient.GetMyRoom(request.Context(), &roomv1.GetMyRoomRequest{
		Meta:        httpkit.RoomMeta(request, "", ""),
		OwnerUserId: auth.UserIDFromContext(request.Context()),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, myRoomDataFromProto(resp))
}

// getCurrentRoom 查询当前登录用户是否仍有可恢复房间。
// gateway 只传鉴权 user_id；presence 和麦位状态由 room-service 用读模型和 Room Cell 快照判断。
func (h *Handler) getCurrentRoom(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}

	resp, err := h.roomQueryClient.GetCurrentRoom(request.Context(), &roomv1.GetCurrentRoomRequest{
		Meta:   httpkit.RoomMeta(request, "", ""),
		UserId: auth.UserIDFromContext(request.Context()),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, currentRoomDataFromProto(resp))
}

// currentRoomData 是 gateway 的外部 JSON 契约，显式保留 false/0 字段。
// 不能直接把 protobuf response 写出去，否则 proto3 的 omitempty 会让 has_current_room=false 消失。
type currentRoomData struct {
	HasCurrentRoom  bool   `json:"has_current_room"`
	RoomID          string `json:"room_id"`
	RoomVersion     int64  `json:"room_version"`
	Role            string `json:"role"`
	MicSessionID    string `json:"mic_session_id"`
	PublishState    string `json:"publish_state"`
	NeedJoinIMGroup bool   `json:"need_join_im_group"`
	NeedRTCToken    bool   `json:"need_rtc_token"`
	ServerTimeMS    int64  `json:"server_time_ms"`
}

func currentRoomDataFromProto(resp *roomv1.GetCurrentRoomResponse) currentRoomData {
	if resp == nil {
		return currentRoomData{}
	}

	return currentRoomData{
		HasCurrentRoom:  resp.GetHasCurrentRoom(),
		RoomID:          resp.GetRoomId(),
		RoomVersion:     resp.GetRoomVersion(),
		Role:            resp.GetRole(),
		MicSessionID:    resp.GetMicSessionId(),
		PublishState:    resp.GetPublishState(),
		NeedJoinIMGroup: resp.GetNeedJoinImGroup(),
		NeedRTCToken:    resp.GetNeedRtcToken(),
		ServerTimeMS:    resp.GetServerTimeMs(),
	}
}

// getRoomSnapshot 主动拉取房间页完整快照。
// 该接口只读 Room Cell/snapshot，不刷新 heartbeat，也不隐式 JoinRoom。
func (h *Handler) getRoomSnapshot(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.URL.Query().Get("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: auth.UserIDFromContext(request.Context()),
	})
	httpkit.Write(writer, request, roomSnapshotDataFromProto(resp), err)
}

// getRoomDetail 返回语音房详情页首屏可直接渲染的数据。
// 它是 GetRoomSnapshot 的 App 聚合版：补 viewer 权限、IM/RTC 入房参数和首屏展示资料。
func (h *Handler) getRoomDetail(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	viewerUserID := auth.UserIDFromContext(request.Context())
	resp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: viewerUserID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	profiles := h.roomDisplayProfiles(request, roomInitialProfileUserIDs(resp.GetRoom(), viewerUserID, joinRoomContributionRankLimit))
	httpkit.WriteOK(writer, request, roomDetailDataFromSnapshot(resp, viewerUserID, h.joinRoomRTCData(roomID, viewerUserID), profiles))
}

// listRoomOnlineUsers 分页返回右上角在线人数弹窗需要的用户列表。
func (h *Handler) listRoomOnlineUsers(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	page, ok := httpkit.PositiveInt32Query(request, "page", 1)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 50)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomQueryClient.ListRoomOnlineUsers(request.Context(), &roomv1.ListRoomOnlineUsersRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: auth.UserIDFromContext(request.Context()),
		Page:         page,
		PageSize:     pageSize,
		Sort:         strings.TrimSpace(request.URL.Query().Get("sort")),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	onlineItems := resp.GetItems()
	userIDs := make([]int64, 0, len(onlineItems)+len(resp.GetUsers()))
	for _, user := range onlineItems {
		userIDs = append(userIDs, user.GetUserId())
	}
	if len(onlineItems) == 0 {
		for _, user := range resp.GetUsers() {
			userIDs = append(userIDs, user.GetUserId())
		}
	}
	httpkit.WriteOK(writer, request, roomOnlineUserDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs)))
}

// listRoomBannedUsers 分页返回房间治理面板使用的当前有效黑名单。
func (h *Handler) listRoomBannedUsers(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	page, ok := httpkit.PositiveInt32Query(request, "page", 1)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomQueryClient.ListRoomBannedUsers(request.Context(), &roomv1.ListRoomBannedUsersRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: auth.UserIDFromContext(request.Context()),
		Page:         page,
		PageSize:     pageSize,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	userIDs := make([]int64, 0, len(resp.GetItems()))
	for _, item := range resp.GetItems() {
		userIDs = append(userIDs, item.GetUserId())
	}
	httpkit.WriteOK(writer, request, roomBannedUsersDataFromProto(roomID, resp, h.roomDisplayProfileMap(request, userIDs)))
}

// getRoomGiftPanel 返回房间送礼面板初始化数据。
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil || h.walletClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	viewerUserID := auth.UserIDFromContext(request.Context())
	snapshotResp, err := h.roomQueryClient.GetRoomSnapshot(request.Context(), &roomv1.GetRoomSnapshotRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: viewerUserID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	snapshot := snapshotResp.GetRoom()
	balances, err := h.walletClient.GetBalances(request.Context(), &walletv1.GetBalancesRequest{
		RequestId:  httpkit.RequestIDFromContext(request.Context()),
		UserId:     viewerUserID,
		AssetTypes: []string{"COIN"},
		AppCode:    appcode.FromContext(request.Context()),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	panelConfig, err := h.roomGiftPanelBaseConfig(request, snapshot.GetVisibleRegionId())
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	giftTypes := panelConfig.GiftTypes
	gifts := panelConfig.Gifts
	bagGifts, err := h.roomGiftBagGifts(request, viewerUserID, gifts)
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	panelGifts := append([]giftConfigData{}, gifts...)
	panelGifts = append(panelGifts, bagGifts...)
	recipientProfiles := h.roomDisplayProfileMap(request, roomGiftRecipientUserIDs(snapshot))
	httpkit.WriteOK(writer, request, roomGiftPanelData{
		CoinBalance:     coinBalanceFromProto(balances),
		Recipients:      roomGiftRecipients(snapshot, recipientProfiles),
		Tabs:            roomGiftTabs(panelGifts, giftTypes),
		Gifts:           panelGifts,
		QuantityPresets: []int32{1, 9, 99, 999},
	})
}

func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
	app := appcode.FromContext(request.Context())
	if h.giftPanelCache != nil {
		if cached, ok := h.giftPanelCache.Get(app, regionID, time.Now()); ok {
			return cached, nil
		}
	}
	giftTypeResp, err := h.walletClient.ListGiftTypeConfigs(request.Context(), &walletv1.ListGiftTypeConfigsRequest{
		RequestId:  httpkit.RequestIDFromContext(request.Context()),
		AppCode:    app,
		ActiveOnly: true,
	})
	if err != nil {
		return roomGiftPanelConfig{}, err
	}
	giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
	for _, item := range giftTypeResp.GetGiftTypes() {
		giftTypes = append(giftTypes, giftTypeFromProto(item))
	}
	giftResp, err := h.walletClient.ListGiftConfigs(request.Context(), &walletv1.ListGiftConfigsRequest{
		RequestId:    httpkit.RequestIDFromContext(request.Context()),
		AppCode:      app,
		Page:         1,
		PageSize:     500,
		ActiveOnly:   true,
		RegionId:     regionID,
		FilterRegion: true,
	})
	if err != nil {
		return roomGiftPanelConfig{}, err
	}
	gifts := make([]giftConfigData, 0, len(giftResp.GetGifts()))
	for _, gift := range giftResp.GetGifts() {
		gifts = append(gifts, giftFromProto(gift))
	}
	config := roomGiftPanelConfig{
		GiftTypes: giftTypes,
		// 礼物类型禁用后不应出现在房间面板；缓存保存已过滤的稳定物料，背包礼物仍按用户每次实时拼接。
		Gifts: filterGiftPanelGifts(gifts, giftTypes),
	}
	if h.giftPanelCache != nil {
		h.giftPanelCache.Set(app, regionID, config, time.Now())
	}
	return config, nil
}

func (h *Handler) roomGiftBagGifts(request *http.Request, viewerUserID int64, gifts []giftConfigData) ([]giftConfigData, error) {
	if viewerUserID <= 0 || h.walletClient == nil || len(gifts) == 0 {
		return nil, nil
	}
	resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
		RequestId:    httpkit.RequestIDFromContext(request.Context()),
		AppCode:      appcode.FromContext(request.Context()),
		UserId:       viewerUserID,
		ResourceType: "gift",
		ActiveOnly:   true,
	})
	if err != nil {
		return nil, err
	}
	giftByResourceID := make(map[int64]giftConfigData, len(gifts))
	for _, gift := range gifts {
		if gift.ResourceID > 0 {
			giftByResourceID[gift.ResourceID] = gift
		}
	}
	bagGifts := make([]giftConfigData, 0, len(resp.GetResources()))
	for _, item := range resp.GetResources() {
		remaining := item.GetRemainingQuantity()
		if remaining <= 0 {
			remaining = item.GetQuantity()
		}
		if remaining <= 0 {
			continue
		}
		gift, ok := giftByResourceID[item.GetResourceId()]
		if !ok {
			continue
		}
		// Bag tab 只承载用户实际拥有的 gift 权益；仍复用 gift 配置的价格/动效，避免背包资源和房间送礼协议出现两套展示口径。
		gift.GiftTypeCode = "bag"
		gift.Source = "bag"
		gift.EntitlementID = item.GetEntitlementId()
		gift.OwnedQuantity = item.GetQuantity()
		gift.RemainingQuantity = remaining
		bagGifts = append(bagGifts, gift)
	}
	return bagGifts, nil
}

// getRoomRocket 返回房间火箭物料配置和当前进度。
func (h *Handler) getRoomRocket(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	resp, err := h.roomQueryClient.GetRoomRocket(request.Context(), &roomv1.GetRoomRocketRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: auth.UserIDFromContext(request.Context()),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	resourceGroups, ok := h.resolveRoomRocketResourceGroups(writer, request, resp)
	if !ok {
		return
	}
	httpkit.WriteOK(writer, request, roomRocketDataFromProto(resp, resourceGroups))
}

// resolveRoomRocketResourceGroups 展开奖励池和最近中奖结果引用的资源组。
// room-service 只保存房间状态和奖励资源组 ID；gateway 在 HTTP 出口补齐素材和钱包资产明细，避免 Room Cell 持有展示快照。
func (h *Handler) resolveRoomRocketResourceGroups(writer http.ResponseWriter, request *http.Request, resp *roomv1.GetRoomRocketResponse) (map[int64]*roomRocketResourceGroupData, bool) {
	ids := roomRocketResourceGroupIDs(resp)
	groups := make(map[int64]*roomRocketResourceGroupData, len(ids))
	if len(ids) == 0 {
		return groups, true
	}
	if h.walletClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return nil, false
	}
	for _, id := range ids {
		groupResp, err := h.walletClient.GetResourceGroup(request.Context(), &walletv1.GetResourceGroupRequest{
			RequestId: httpkit.RequestIDFromContext(request.Context()),
			AppCode:   appcode.FromContext(request.Context()),
			GroupId:   id,
		})
		if err != nil {
			httpkit.WriteRPCError(writer, request, err)
			return nil, false
		}
		group := roomRocketResourceGroupFromProto(groupResp.GetGroup())
		if group != nil && group.GroupID > 0 {
			groups[group.GroupID] = group
		}
	}
	return groups, true
}

// handleRoomFollow 建立或取消当前用户对房间的关注关系。
// 关注关系由 room-service 保存，gateway 只负责鉴权用户和 path room_id 的协议转换。
func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) {
	if h.roomClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	viewerUserID := auth.UserIDFromContext(request.Context())
	switch request.Method {
	case http.MethodPost:
		resp, err := h.roomClient.FollowRoom(request.Context(), &roomv1.FollowRoomRequest{
			Meta:   httpkit.RoomMeta(request, roomID, ""),
			RoomId: roomID,
			UserId: viewerUserID,
		})
		if err != nil {
			httpkit.WriteRPCError(writer, request, err)
			return
		}
		httpkit.WriteOK(writer, request, roomFollowDataFromProto(resp))
	case http.MethodDelete:
		resp, err := h.roomClient.UnfollowRoom(request.Context(), &roomv1.UnfollowRoomRequest{
			Meta:   httpkit.RoomMeta(request, roomID, ""),
			RoomId: roomID,
			UserId: viewerUserID,
		})
		if err != nil {
			httpkit.WriteRPCError(writer, request, err)
			return
		}
		httpkit.WriteOK(writer, request, roomUnfollowDataFromProto(resp))
	default:
		httpkit.WriteError(writer, request, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "invalid argument")
	}
}

// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		CommandID       string `json:"command_id"`
		SeatCount       int32  `json:"seat_count"`
		Mode            string `json:"mode"`
		RoomName        string `json:"room_name"`
		RoomAvatar      string `json:"room_avatar"`
		RoomDescription string `json:"room_description"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.CommandID = strings.TrimSpace(body.CommandID)
	body.Mode = strings.TrimSpace(body.Mode)
	body.RoomName = strings.TrimSpace(body.RoomName)
	body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
	body.RoomDescription = strings.TrimSpace(body.RoomDescription)
	if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" || body.RoomAvatar == "" {
		// command_id 必须来自客户端用户动作，seat_count 可省略走后台默认值；mode、room_name 和 room_avatar 是创建页最小必填信息。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	if h.userProfileClient == nil {
		// CreateRoom 的 visible_region_id 必须来自 user-service，不能让客户端传入或 gateway 猜测。
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: auth.UserIDFromContext(request.Context()),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}

	roomID := appcode.NewScopedID(appcode.FromContext(request.Context()))
	roomShortID := userResp.GetUser().GetDisplayUserId()
	resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{
		Meta:             httpkit.RoomMeta(request, roomID, body.CommandID),
		SeatCount:        body.SeatCount,
		Mode:             body.Mode,
		VisibleRegionId:  userResp.GetUser().GetRegionId(),
		OwnerCountryCode: userResp.GetUser().GetCountry(),
		RoomName:         body.RoomName,
		RoomAvatar:       body.RoomAvatar,
		RoomDescription:  body.RoomDescription,
		RoomShortId:      roomShortID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, createRoomDataFromProto(resp))
}

// updateRoomProfile 把 App 房间资料编辑请求转换为 room-service UpdateRoomProfile 命令。
func (h *Handler) updateRoomProfile(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID          string  `json:"room_id"`
		CommandID       string  `json:"command_id"`
		RoomName        *string `json:"room_name"`
		RoomAvatar      *string `json:"room_avatar"`
		RoomDescription *string `json:"room_description"`
		SeatCount       *int32  `json:"seat_count"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.RoomID = strings.TrimSpace(body.RoomID)
	body.CommandID = strings.TrimSpace(body.CommandID)
	trimOptionalString(body.RoomName)
	trimOptionalString(body.RoomAvatar)
	trimOptionalString(body.RoomDescription)

	resp, err := h.roomClient.UpdateRoomProfile(request.Context(), &roomv1.UpdateRoomProfileRequest{
		Meta:            httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		RoomName:        body.RoomName,
		RoomAvatar:      body.RoomAvatar,
		RoomDescription: body.RoomDescription,
		SeatCount:       body.SeatCount,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, updateRoomProfileDataFromProto(resp))
}

// saveRoomBackground 保存房主上传后的背景图 URL，素材归 room-service 按房间维度管理。
func (h *Handler) saveRoomBackground(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID   string `json:"room_id"`
		ImageURL string `json:"image_url"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.RoomID = strings.TrimSpace(body.RoomID)
	body.ImageURL = strings.TrimSpace(body.ImageURL)
	if !roomid.ValidStringID(body.RoomID) || body.ImageURL == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomClient.SaveRoomBackground(request.Context(), &roomv1.SaveRoomBackgroundRequest{
		Meta:     httpkit.RoomMeta(request, body.RoomID, ""),
		RoomId:   body.RoomID,
		ImageUrl: body.ImageURL,
	})
	httpkit.Write(writer, request, saveRoomBackgroundDataFromProto(resp), err)
}

// listRoomBackgrounds 返回房主保存过的背景图素材，以及 Room Cell 快照里的当前生效背景。
func (h *Handler) listRoomBackgrounds(writer http.ResponseWriter, request *http.Request) {
	if h.roomQueryClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if !roomid.ValidStringID(roomID) {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomQueryClient.ListRoomBackgrounds(request.Context(), &roomv1.ListRoomBackgroundsRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		ViewerUserId: auth.UserIDFromContext(request.Context()),
	})
	httpkit.Write(writer, request, listRoomBackgroundsDataFromProto(resp), err)
}

// setRoomBackground 把已保存素材设为当前房间背景；权限和状态写入仍由 Room Cell 串行裁决。
func (h *Handler) setRoomBackground(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string        `json:"room_id"`
		CommandID    string        `json:"command_id"`
		BackgroundID flexibleInt64 `json:"background_id"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.RoomID = strings.TrimSpace(body.RoomID)
	body.CommandID = strings.TrimSpace(body.CommandID)
	if !roomid.ValidStringID(body.RoomID) || body.CommandID == "" || int64(body.BackgroundID) <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomClient.SetRoomBackground(request.Context(), &roomv1.SetRoomBackgroundRequest{
		Meta:         httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		BackgroundId: int64(body.BackgroundID),
	})
	httpkit.Write(writer, request, setRoomBackgroundDataFromProto(resp), err)
}

func trimOptionalString(value *string) {
	if value == nil {
		return
	}
	*value = strings.TrimSpace(*value)
}

// joinRoom 把进房 HTTP JSON 请求转换为 room-service JoinRoom 命令，并组装房间首屏初始化包。
func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
		Role      string `json:"role"`
		Password  string `json:"password"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	actorSnapshot, err := h.resolveRoomActorJoinSnapshot(request)
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}

	resp, err := h.roomClient.JoinRoom(request.Context(), &roomv1.JoinRoomRequest{
		Meta:                httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		Role:                body.Role,
		Password:            body.Password,
		ActorCountryId:      actorSnapshot.countryID,
		ActorRegionId:       actorSnapshot.regionID,
		ActorDisplayProfile: actorSnapshot.displayProfile,
		// 座驾快照在进房命令提交前生成；room-service 只保存快照，不回查 wallet。
		EntryVehicle: h.joinRoomEntryVehicleSnapshot(request),
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
}

func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request) *roomv1.RoomEntryVehicleSnapshot {
	if h.walletClient == nil {
		// 座驾只影响进房展示，不应该因为 wallet 暂不可用阻断 JoinRoom。
		return nil
	}
	userID := auth.UserIDFromContext(request.Context())
	if userID <= 0 {
		return nil
	}
	resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
		RequestId:     httpkit.RequestIDFromContext(request.Context()),
		AppCode:       appcode.FromContext(request.Context()),
		UserIds:       []int64{userID},
		ResourceTypes: []string{"vehicle"},
	})
	if err != nil {
		// 佩戴态增强失败时降级为无座驾进房；房间 presence 仍由 room-service 提交。
		return nil
	}
	for _, user := range resp.GetUsers() {
		if user.GetUserId() != userID {
			// batch 响应按用户分组；忽略非当前用户，避免串用户座驾。
			continue
		}
		for _, item := range user.GetResources() {
			if item.GetResource().GetResourceType() != "vehicle" {
				continue
			}
			resource := item.GetResource()
			if resource.GetResourceId() <= 0 {
				continue
			}
			// 这里复制的是资源素材和 entitlement 有效期，不把 wallet 的内部权益结构透传给 room-service。
			return &roomv1.RoomEntryVehicleSnapshot{
				ResourceId:    resource.GetResourceId(),
				ResourceCode:  resource.GetResourceCode(),
				Name:          resource.GetName(),
				AssetUrl:      resource.GetAssetUrl(),
				PreviewUrl:    resource.GetPreviewUrl(),
				AnimationUrl:  resource.GetAnimationUrl(),
				MetadataJson:  resource.GetMetadataJson(),
				EntitlementId: item.GetEntitlementId(),
				ExpiresAtMs:   item.GetExpiresAtMs(),
			}
		}
	}
	return nil
}

func (h *Handler) joinRoomInitialData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomData {
	if resp == nil {
		return joinRoomData{}
	}
	viewerUserID := auth.UserIDFromContext(request.Context())
	snapshot := resp.GetRoom()
	roomID := snapshot.GetRoomId()
	if roomID == "" {
		// 老测试桩或异常上游可能只返回 result；用 JoinRoom meta 兜底，避免 HTTP DTO 生成崩溃。
		roomID = strings.TrimSpace(requestedRoomID)
	}

	return joinRoomData{
		Result:           commandResultDataFromProto(resp.GetResult()),
		Room:             roomInitialRoomDataFromSnapshot(snapshot, roomID),
		Viewer:           roomViewerDataFromProto(resp.GetUser(), viewerUserID),
		Seats:            roomSeatDataFromSnapshot(snapshot),
		ContributionRank: roomRankDataFromSnapshot(snapshot, joinRoomContributionRankLimit),
		Profiles:         h.roomInitialProfiles(request, snapshot, viewerUserID),
		IM:               roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
		RTC:              h.joinRoomRTCData(roomID, viewerUserID),
		ServerTimeMS:     resp.GetResult().GetServerTimeMs(),
	}
}

func (h *Handler) joinRoomRTCData(roomID string, viewerUserID int64) roomRTCData {
	rtc := roomRTCData{NeedToken: true}
	token, err := h.generateTencentRTCToken(viewerUserID, roomID)
	if err != nil {
		// RTC 票据是进房后的音频能力，不应该回滚已经成功的业务 presence。
		rtc.Available = false
		rtc.Reason = "rtc_token_unavailable"
		return rtc
	}
	rtc.Available = true
	rtc.Token = &token
	return rtc
}

func (h *Handler) roomInitialProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomDisplayProfileData {
	userIDs := roomInitialProfileUserIDs(snapshot, viewerUserID, joinRoomContributionRankLimit)
	// 进房首屏资料和 /users/room-display-profiles:batch 使用同一份聚合逻辑，
	// 避免头像框、资料卡、徽章等展示资源在不同房间入口字段不一致。
	return h.roomDisplayProfiles(request, userIDs)
}

// roomHeartbeat 把客户端显式心跳转换为 room-service presence refresh 命令。
func (h *Handler) roomHeartbeat(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.RoomHeartbeat(request.Context(), &roomv1.RoomHeartbeatRequest{
		Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
	})
	httpkit.Write(writer, request, resp, err)
}

// leaveRoom 把退房 HTTP JSON 请求转换为 room-service LeaveRoom 命令。
func (h *Handler) leaveRoom(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.LeaveRoom(request.Context(), &roomv1.LeaveRoomRequest{
		Meta: httpkit.RoomMeta(request, body.RoomID, body.CommandID),
	})
	httpkit.Write(writer, request, resp, err)
}

// closeRoom 是设计稿右上角电源按钮入口：这里只退出当前用户，不关闭房间生命周期。
func (h *Handler) closeRoom(writer http.ResponseWriter, request *http.Request) {
	h.leaveRoom(writer, request)
}

// micUp 把上麦 HTTP JSON 请求转换为 room-service MicUp 命令。
func (h *Handler) micUp(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
		SeatNo    int32  `json:"seat_no"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.MicUp(request.Context(), &roomv1.MicUpRequest{
		Meta:                 httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		SeatNo:               body.SeatNo,
		TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, auth.UserIDFromContext(request.Context())),
	})
	httpkit.Write(writer, request, resp, err)
}

// micDown 把下麦 HTTP JSON 请求转换为 room-service MicDown 命令。
func (h *Handler) micDown(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		Reason       string `json:"reason"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	targetUserID := body.TargetUserID
	if targetUserID <= 0 {
		targetUserID = auth.UserIDFromContext(request.Context())
	}
	resp, err := h.roomClient.MicDown(request.Context(), &roomv1.MicDownRequest{
		Meta:                 httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId:         body.TargetUserID,
		Reason:               body.Reason,
		TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, targetUserID),
	})
	httpkit.Write(writer, request, resp, err)
}

// changeMicSeat 把换麦位 HTTP JSON 请求转换为 room-service ChangeMicSeat 命令。
func (h *Handler) changeMicSeat(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		SeatNo       int32  `json:"seat_no"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.ChangeMicSeat(request.Context(), &roomv1.ChangeMicSeatRequest{
		Meta:                 httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId:         body.TargetUserID,
		SeatNo:               body.SeatNo,
		TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, body.TargetUserID),
	})
	httpkit.Write(writer, request, resp, err)
}

// confirmMicPublishing 把客户端 SDK 发流成功回调转换为 room-service 发流确认命令。
func (h *Handler) confirmMicPublishing(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		MicSessionID string `json:"mic_session_id"`
		RoomVersion  int64  `json:"room_version"`
		EventTimeMS  int64  `json:"event_time_ms"`
		Source       string `json:"source"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	targetUserID := body.TargetUserID
	if targetUserID <= 0 {
		targetUserID = auth.UserIDFromContext(request.Context())
	}
	resp, err := h.roomClient.ConfirmMicPublishing(request.Context(), &roomv1.ConfirmMicPublishingRequest{
		Meta:                 httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId:         body.TargetUserID,
		MicSessionId:         body.MicSessionID,
		RoomVersion:          body.RoomVersion,
		EventTimeMs:          body.EventTimeMS,
		Source:               body.Source,
		TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, targetUserID),
	})
	httpkit.Write(writer, request, resp, err)
}

// micHeartbeat 把客户端麦上心跳转换为 room-service 当前 mic_session 刷新命令。
func (h *Handler) micHeartbeat(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		MicSessionID string `json:"mic_session_id"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.MicHeartbeat(request.Context(), &roomv1.MicHeartbeatRequest{
		Meta:         httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId: body.TargetUserID,
		MicSessionId: body.MicSessionID,
	})
	httpkit.Write(writer, request, resp, err)
}

// setMicMute 把麦克风静音 HTTP 请求转换为 room-service SetMicMute 命令。
func (h *Handler) setMicMute(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		Muted        bool   `json:"muted"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	targetUserID := body.TargetUserID
	if targetUserID <= 0 {
		targetUserID = auth.UserIDFromContext(request.Context())
	}
	resp, err := h.roomClient.SetMicMute(request.Context(), &roomv1.SetMicMuteRequest{
		Meta:                 httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId:         body.TargetUserID,
		Muted:                body.Muted,
		TargetDisplayProfile: h.resolveRoomUserDisplayProfileBestEffort(request, targetUserID),
	})
	httpkit.Write(writer, request, resp, err)
}

// setMicSeatLock 把锁麦 HTTP JSON 请求转换为 room-service SetMicSeatLock 命令。
func (h *Handler) setMicSeatLock(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
		SeatNo    int32  `json:"seat_no"`
		Locked    bool   `json:"locked"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.SetMicSeatLock(request.Context(), &roomv1.SetMicSeatLockRequest{
		Meta:   httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		SeatNo: body.SeatNo,
		Locked: body.Locked,
	})
	httpkit.Write(writer, request, resp, err)
}

// setChatEnabled 把公屏开关 HTTP JSON 请求转换为 room-service SetChatEnabled 命令。
func (h *Handler) setChatEnabled(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
		Enabled   bool   `json:"enabled"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.SetChatEnabled(request.Context(), &roomv1.SetChatEnabledRequest{
		Meta:    httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		Enabled: body.Enabled,
	})
	httpkit.Write(writer, request, resp, err)
}

// setRoomPassword 把房主锁房 HTTP JSON 请求转换为 room-service SetRoomPassword 命令。
func (h *Handler) setRoomPassword(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID    string `json:"room_id"`
		CommandID string `json:"command_id"`
		Locked    bool   `json:"locked"`
		Password  string `json:"password"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.RoomID = strings.TrimSpace(body.RoomID)
	body.CommandID = strings.TrimSpace(body.CommandID)
	body.Password = strings.TrimSpace(body.Password)

	resp, err := h.roomClient.SetRoomPassword(request.Context(), &roomv1.SetRoomPasswordRequest{
		Meta:     httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		Locked:   body.Locked,
		Password: body.Password,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	httpkit.WriteOK(writer, request, updateRoomProfileDataFromProto(&roomv1.UpdateRoomProfileResponse{
		Result: resp.GetResult(),
		Room:   resp.GetRoom(),
	}))
}

// setRoomAdmin 把管理员增删 HTTP JSON 请求转换为 room-service SetRoomAdmin 命令。
func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string        `json:"room_id"`
		CommandID    string        `json:"command_id"`
		TargetUserID flexibleInt64 `json:"target_user_id"`
		Enabled      bool          `json:"enabled"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.SetRoomAdmin(request.Context(), &roomv1.SetRoomAdminRequest{
		Meta:         httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId: int64(body.TargetUserID),
		Enabled:      body.Enabled,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	roomRole := "normal"
	if body.Enabled {
		roomRole = "admin"
	}
	httpkit.WriteOK(writer, request, setRoomAdminData{
		Result:       commandResultDataFromProto(resp.GetResult()),
		TargetUserID: formatOptionalUserID(int64(body.TargetUserID)),
		RoomRole:     roomRole,
		ServerTimeMS: resp.GetResult().GetServerTimeMs(),
	})
}

// muteUser 把禁言 HTTP JSON 请求转换为 room-service MuteUser 命令。
func (h *Handler) muteUser(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		Muted        bool   `json:"muted"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.MuteUser(request.Context(), &roomv1.MuteUserRequest{
		Meta:         httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId: body.TargetUserID,
		Muted:        body.Muted,
	})
	httpkit.Write(writer, request, resp, err)
}

// kickUser 把踢人 HTTP JSON 请求转换为 room-service KickUser 命令。
func (h *Handler) kickUser(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
		DurationMS   int64  `json:"duration_ms"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.KickUser(request.Context(), &roomv1.KickUserRequest{
		Meta:         httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId: body.TargetUserID,
		DurationMs:   body.DurationMS,
	})
	httpkit.Write(writer, request, resp, err)
}

// unbanUser 把解封 HTTP JSON 请求转换为 room-service UnbanUser 命令。
func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
	var body struct {
		RoomID       string `json:"room_id"`
		CommandID    string `json:"command_id"`
		TargetUserID int64  `json:"target_user_id"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}

	resp, err := h.roomClient.UnbanUser(request.Context(), &roomv1.UnbanUserRequest{
		Meta:         httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetUserId: body.TargetUserID,
	})
	httpkit.Write(writer, request, resp, err)
}

// sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。
// 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。
func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
	h.handleSendGift(writer, request, "")
}

// sendGiftBatch 是新 Flutter 的多人送礼入口。
// 它不改变账务和房间事实，只通过 display_mode=batch 要求 room-service 合并房间展示 IM。
func (h *Handler) sendGiftBatch(writer http.ResponseWriter, request *http.Request) {
	h.handleSendGift(writer, request, "batch")
}

func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Request, displayMode string) {
	var body struct {
		RoomID        string  `json:"room_id"`
		CommandID     string  `json:"command_id"`
		TargetType    string  `json:"target_type"`
		TargetUserID  int64   `json:"target_user_id"`
		TargetUserIDs []int64 `json:"target_user_ids"`
		GiftID        string  `json:"gift_id"`
		GiftCount     int32   `json:"gift_count"`
		PoolID        string  `json:"pool_id"`
		EntitlementID string  `json:"entitlement_id"`
		Source        string  `json:"source"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	targetType := strings.TrimSpace(body.TargetType)
	if targetType == "" {
		targetType = "user"
	}
	targetUserIDs := normalizeGiftTargetUserIDs(body.TargetUserID, body.TargetUserIDs)
	if targetType == "user" && len(targetUserIDs) == 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userSnapshots, err := h.resolveGiftUserSnapshots(request, targetUserIDs)
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	targetHostScopes, err := h.resolveGiftTargetHostScopes(request, targetUserIDs)
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	firstTargetScope := firstGiftTargetHostScope(targetHostScopes, firstUserID(targetUserIDs))

	resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
		Meta:                    httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		TargetType:              targetType,
		TargetUserIds:           targetUserIDs,
		TargetUserId:            firstUserID(targetUserIDs),
		GiftId:                  body.GiftID,
		GiftCount:               body.GiftCount,
		PoolId:                  body.PoolID,
		SenderCountryId:         userSnapshots.senderCountryID,
		SenderRegionId:          userSnapshots.senderRegionID,
		TargetIsHost:            firstTargetScope.GetTargetIsHost(),
		TargetHostRegionId:      firstTargetScope.GetTargetHostRegionId(),
		TargetAgencyOwnerUserId: firstTargetScope.GetTargetAgencyOwnerUserId(),
		TargetHostScopes:        targetHostScopes,
		EntitlementId:           strings.TrimSpace(body.EntitlementID),
		Source:                  strings.TrimSpace(body.Source),
		SenderDisplayProfile:    userSnapshots.senderDisplayProfile,
		TargetDisplayProfiles:   userSnapshots.targetDisplayProfiles,
		DisplayMode:             strings.TrimSpace(displayMode),
	})
	httpkit.Write(writer, request, resp, err)
}

type roomActorJoinSnapshot struct {
	countryID      int64
	regionID       int64
	displayProfile *roomv1.RoomUserDisplayProfile
}

func (h *Handler) resolveRoomActorJoinSnapshot(request *http.Request) (roomActorJoinSnapshot, error) {
	if h.userProfileClient == nil {
		return roomActorJoinSnapshot{}, xerr.New(xerr.Unavailable, "user service is not configured")
	}
	resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: auth.UserIDFromContext(request.Context()),
	})
	if err != nil {
		return roomActorJoinSnapshot{}, err
	}
	if resp.GetUser() == nil {
		return roomActorJoinSnapshot{}, xerr.New(xerr.NotFound, "user not found")
	}
	if resp.GetUser().GetCountryId() <= 0 || resp.GetUser().GetRegionId() <= 0 {
		return roomActorJoinSnapshot{}, xerr.New(xerr.InvalidArgument, "user country and region are required")
	}
	// room-service 事件必须携带用户真实国家/区域和展示快照；房间 visible_region_id 只表达房间列表归属。
	return roomActorJoinSnapshot{
		countryID:      resp.GetUser().GetCountryId(),
		regionID:       resp.GetUser().GetRegionId(),
		displayProfile: roomUserDisplayProfileFromUser(resp.GetUser()),
	}, nil
}

func (h *Handler) resolveRoomUserDisplayProfileBestEffort(request *http.Request, userID int64) *roomv1.RoomUserDisplayProfile {
	if h.userProfileClient == nil || userID <= 0 {
		return nil
	}
	resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		// 麦位状态提交不能被展示资料查询拖垮；缺快照时客户端仍会异步刷新在线成员资料。
		return nil
	}
	return roomUserDisplayProfileFromUser(resp.GetUser())
}

type giftUserSnapshots struct {
	senderCountryID       int64
	senderRegionID        int64
	senderDisplayProfile  *roomv1.SendGiftDisplayProfile
	targetDisplayProfiles []*roomv1.SendGiftDisplayProfile
}

func (h *Handler) resolveGiftUserSnapshots(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, error) {
	if h.userProfileClient == nil {
		return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "user service is not configured"))
	}
	senderUserID := auth.UserIDFromContext(request.Context())
	userIDs := uniquePositiveUserIDs(append([]int64{senderUserID}, targetUserIDs...))
	resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
		Meta:    httpkit.UserMeta(request, ""),
		UserIds: userIDs,
	})
	if err != nil {
		return giftUserSnapshots{}, err
	}
	sender := resp.GetUsers()[senderUserID]
	if sender == nil {
		return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.NotFound, "user not found"))
	}
	if sender.GetCountryId() <= 0 || sender.GetRegionId() <= 0 {
		return giftUserSnapshots{}, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "user country and region are required"))
	}

	targets := make([]*roomv1.SendGiftDisplayProfile, 0, len(targetUserIDs))
	for _, targetUserID := range targetUserIDs {
		if profile := giftDisplayProfileFromUser(resp.GetUsers()[targetUserID]); profile != nil {
			// 目标展示快照不是结算前置条件；缺失 target 资料时只让客户端走最终昵称头像兜底。
			targets = append(targets, profile)
		}
	}
	return giftUserSnapshots{
		senderCountryID:       sender.GetCountryId(),
		senderRegionID:        sender.GetRegionId(),
		senderDisplayProfile:  giftDisplayProfileFromUser(sender),
		targetDisplayProfiles: targets,
	}, nil
}

func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
	if targetUserID <= 0 || h.userHostClient == nil {
		// 未装配 host client 时送礼仍可继续，但工资周期钻石 fail-closed，避免客户端伪造主播入账。
		return false, 0, 0, nil
	}
	resp, err := h.userHostClient.GetHostProfile(request.Context(), &userv1.GetHostProfileRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: targetUserID,
	})
	if err != nil {
		if xerr.ReasonFromGRPC(err) == xerr.NotFound {
			return false, 0, 0, nil
		}
		return false, 0, 0, err
	}
	profile := resp.GetHostProfile()
	if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
		// 只有 active host 且带区域才能进入工资政策链路；disabled/缺区域按非主播处理，不影响正常送礼。
		return false, 0, 0, nil
	}
	return true, profile.GetRegionId(), profile.GetCurrentAgencyOwnerUserId(), nil
}

func (h *Handler) resolveGiftTargetHostScopes(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
	if len(targetUserIDs) == 0 {
		return []*roomv1.SendGiftTargetHostScope{}, nil
	}
	if h.userHostClient == nil {
		scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
		for _, targetUserID := range targetUserIDs {
			// 未装配 host client 时送礼仍可继续，但所有 target 都 fail-closed 为非主播。
			scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, nil))
		}
		return scopes, nil
	}
	resp, err := h.userHostClient.BatchGetHostProfiles(request.Context(), &userv1.BatchGetHostProfilesRequest{
		Meta:    httpkit.UserMeta(request, ""),
		UserIds: targetUserIDs,
	})
	if err != nil {
		if status.Code(err) == codes.Unimplemented {
			// 滚动发布期间旧 user-service 还没有 batch RPC 时，临时退回单查；其他错误必须阻断送礼。
			return h.resolveGiftTargetHostScopesBySingleLookup(request, targetUserIDs)
		}
		return nil, err
	}
	profiles := resp.GetHostProfiles()
	scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
	for _, targetUserID := range targetUserIDs {
		// 批量送礼必须按接收方分别固化主播快照；缺失 profile 表示该 target 当前不是主播。
		scopes = append(scopes, giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID]))
	}
	return scopes, nil
}

func (h *Handler) resolveGiftTargetHostScopesBySingleLookup(request *http.Request, targetUserIDs []int64) ([]*roomv1.SendGiftTargetHostScope, error) {
	scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
	for _, targetUserID := range targetUserIDs {
		targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, targetUserID)
		if err != nil {
			return nil, err
		}
		// 批量送礼必须按接收方分别固化主播快照；只解析第一个 target 会让后续主播丢失工资钻石入账。
		scopes = append(scopes, &roomv1.SendGiftTargetHostScope{
			TargetUserId:            targetUserID,
			TargetIsHost:            targetIsHost,
			TargetHostRegionId:      targetHostRegionID,
			TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
		})
	}
	return scopes, nil
}

func giftTargetHostScopeFromProfile(targetUserID int64, profile *userv1.HostProfile) *roomv1.SendGiftTargetHostScope {
	scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
	if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") || profile.GetRegionId() <= 0 {
		// 只有 active host 且带区域才能进入工资政策链路；disabled/缺区域按非主播处理，不影响正常送礼。
		return scope
	}
	scope.TargetIsHost = true
	scope.TargetHostRegionId = profile.GetRegionId()
	scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId()
	return scope
}

func giftDisplayProfileFromUser(user *userv1.User) *roomv1.SendGiftDisplayProfile {
	if user == nil || user.GetUserId() <= 0 {
		return nil
	}
	// 这里不生成 User<ID> 兜底；客户端已有最终兜底，后端只传真实用户资料快照。
	return &roomv1.SendGiftDisplayProfile{
		UserId:              user.GetUserId(),
		Username:            strings.TrimSpace(user.GetUsername()),
		Avatar:              strings.TrimSpace(user.GetAvatar()),
		DisplayUserId:       strings.TrimSpace(user.GetDisplayUserId()),
		PrettyDisplayUserId: strings.TrimSpace(user.GetPrettyDisplayUserId()),
	}
}

func roomUserDisplayProfileFromUser(user *userv1.User) *roomv1.RoomUserDisplayProfile {
	if user == nil || user.GetUserId() <= 0 {
		return nil
	}
	// 这里不生成 User<ID> 兜底；真实昵称/头像缺失时让客户端走 display_user_id 或最终 user_id 兜底。
	return &roomv1.RoomUserDisplayProfile{
		UserId:              user.GetUserId(),
		Nickname:            strings.TrimSpace(user.GetUsername()),
		Avatar:              strings.TrimSpace(user.GetAvatar()),
		DisplayUserId:       strings.TrimSpace(user.GetDisplayUserId()),
		PrettyDisplayUserId: strings.TrimSpace(user.GetPrettyDisplayUserId()),
	}
}

func firstGiftTargetHostScope(scopes []*roomv1.SendGiftTargetHostScope, targetUserID int64) *roomv1.SendGiftTargetHostScope {
	for _, scope := range scopes {
		if scope.GetTargetUserId() == targetUserID {
			return scope
		}
	}
	return &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
}

// batchRoomDisplayProfiles 返回房间和公屏专用展示资料。
func (h *Handler) batchRoomDisplayProfiles(writer http.ResponseWriter, request *http.Request) {
	if h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	userIDs, ok := parseBatchUserIDs(request)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	httpkit.WriteOK(writer, request, map[string]any{"profiles": h.roomDisplayProfiles(request, userIDs)})
}

func parseBatchUserIDs(request *http.Request) ([]int64, bool) {
	rawValues := append([]string(nil), request.URL.Query()["user_ids"]...)
	rawValues = append(rawValues, request.URL.Query()["user_id"]...)
	seen := make(map[int64]bool)
	result := make([]int64, 0, len(rawValues))
	for _, raw := range rawValues {
		for piece := range strings.SplitSeq(raw, ",") {
			piece = strings.TrimSpace(piece)
			if piece == "" {
				continue
			}
			userID, err := strconv.ParseInt(piece, 10, 64)
			if err != nil || userID <= 0 {
				return nil, false
			}
			if seen[userID] {
				continue
			}
			seen[userID] = true
			result = append(result, userID)
		}
	}
	return result, len(result) > 0
}

func (h *Handler) roomDisplayProfiles(request *http.Request, userIDs []int64) []roomDisplayProfileData {
	profileMap := h.roomDisplayProfileMap(request, userIDs)
	profiles := make([]roomDisplayProfileData, 0, len(userIDs))
	for _, userID := range userIDs {
		if profile, ok := profileMap[userID]; ok {
			profiles = append(profiles, profile)
		}
	}
	return profiles
}

func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomDisplayProfileData {
	if h.userProfileClient == nil {
		return nil
	}
	userIDs = uniquePositiveUserIDs(userIDs)
	if len(userIDs) == 0 {
		return nil
	}
	resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
		Meta:    httpkit.UserMeta(request, ""),
		UserIds: userIDs,
	})
	if err != nil {
		// 房间资料是展示增强，失败时保留主房间数据，让客户端后续重试 batch 接口。
		return nil
	}
	// 房间资料高频读取，头像框/资料卡/座驾/当前佩戴徽章用 wallet 批量查；
	// activity 的 badge 投影再补展示槽位，两个来源合并后客户端才能同时看到长短徽章。
	appearanceMap := h.roomAppearanceResourceMap(request, userIDs)
	badgeMap := h.roomBadgeDisplayMap(request, userIDs)
	levelMap := h.roomLevelDisplayProfileMap(request, userIDs)
	profiles := make(map[int64]roomDisplayProfileData, len(resp.GetUsers()))
	for _, userID := range userIDs {
		user := resp.GetUsers()[userID]
		if user == nil {
			continue
		}
		appearance := appearanceMap[userID]
		levels := levelMap[userID]
		profiles[userID] = roomDisplayProfileData{
			UserID:              formatOptionalUserID(user.GetUserId()),
			Username:            user.GetUsername(),
			Avatar:              user.GetAvatar(),
			DisplayUserID:       user.GetDisplayUserId(),
			PrettyID:            user.GetPrettyId(),
			PrettyDisplayUserID: user.GetPrettyDisplayUserId(),
			Gender:              strings.TrimSpace(user.GetGender()),
			Age:                 roomProfileAgeFromBirth(user.GetBirth(), time.Now().UTC()),
			Country:             strings.ToUpper(strings.TrimSpace(user.GetCountry())),
			CountryName:         strings.TrimSpace(user.GetCountryName()),
			CountryDisplayName:  strings.TrimSpace(user.GetCountryDisplayName()),
			CountryFlag:         roomProfileCountryFlag(user.GetCountry()),
			VIP:                 map[string]any{},
			Level:               levels.Levels,
			LevelBadges:         levels.Badges,
			Badges:              roomMergeBadgeItems(badgeMap[userID], appearance.Badges),
			AvatarFrame:         appearance.AvatarFrame,
			ProfileCard:         appearance.ProfileCard,
			Vehicle:             appearance.Vehicle,
			MicSeatAnimation:    appearance.MicSeatAnimation,
			Charm:               0,
		}
		// 显式补空对象/空数组，避免客户端在房间资料中同时处理 null 和空值。
		if profiles[userID].Badges == nil {
			profile := profiles[userID]
			profile.Badges = []map[string]any{}
			profiles[userID] = profile
		}
		if profiles[userID].AvatarFrame == nil {
			profile := profiles[userID]
			profile.AvatarFrame = map[string]any{}
			profiles[userID] = profile
		}
		if profiles[userID].Level == nil {
			profile := profiles[userID]
			profile.Level = roomDefaultLevelDisplay()
			profiles[userID] = profile
		}
		if profiles[userID].LevelBadges == nil {
			profile := profiles[userID]
			profile.LevelBadges = map[string]any{}
			profiles[userID] = profile
		}
		if profiles[userID].Vehicle == nil {
			profile := profiles[userID]
			profile.Vehicle = map[string]any{}
			profiles[userID] = profile
		}
		if profiles[userID].MicSeatAnimation == nil {
			profile := profiles[userID]
			profile.MicSeatAnimation = map[string]any{}
			profiles[userID] = profile
		}
		if profiles[userID].ProfileCard == nil {
			profile := profiles[userID]
			profile.ProfileCard = map[string]any{}
			profiles[userID] = profile
		}
	}
	return profiles
}

type roomAppearanceResources struct {
	AvatarFrame      map[string]any
	ProfileCard      map[string]any
	Vehicle          map[string]any
	MicSeatAnimation map[string]any
	Badges           []map[string]any
}

func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int64) map[int64]roomAppearanceResources {
	result := make(map[int64]roomAppearanceResources, len(userIDs))
	if h.walletClient == nil || len(userIDs) == 0 {
		return result
	}
	// wallet 在该 RPC 内做过期佩戴过滤，所以 roomDisplayProfileMap 不需要重复判断 expires_at_ms。
	resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
		RequestId:     httpkit.RequestIDFromContext(request.Context()),
		AppCode:       appcode.FromContext(request.Context()),
		UserIds:       userIDs,
		ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatAnimation},
	})
	if err != nil {
		// 装扮是展示增强，失败不影响房间主数据返回。
		return result
	}
	for _, user := range resp.GetUsers() {
		if user.GetUserId() <= 0 {
			continue
		}
		current := result[user.GetUserId()]
		for _, item := range user.GetResources() {
			// 同一资源类型只保留当前佩戴的一个权益；wallet equipment 主键保证唯一性。
			resource := roomAppearanceResourceFromEntitlement(item)
			switch item.GetResource().GetResourceType() {
			case roomAppearanceResourceAvatarFrame:
				current.AvatarFrame = resource
			case roomAppearanceResourceProfileCard:
				current.ProfileCard = resource
			case roomAppearanceResourceVehicle:
				current.Vehicle = resource
			case roomAppearanceResourceBadge:
				current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource))
			case roomAppearanceResourceMicSeatAnimation:
				current.MicSeatAnimation = resource
			}
		}
		result[user.GetUserId()] = current
	}
	return result
}

func roomBadgeItemFromEquippedResource(resource map[string]any) map[string]any {
	if len(resource) == 0 {
		return map[string]any{}
	}
	return map[string]any{
		"slot":           "equipped",
		"position":       0,
		"badge_form":     strings.TrimSpace(fmt.Sprint(resource["badge_form"])),
		"resource_id":    resource["resource_id"],
		"entitlement_id": strings.TrimSpace(fmt.Sprint(resource["entitlement_id"])),
		"source_type":    "wallet_equipment",
		"source_id":      strings.TrimSpace(fmt.Sprint(resource["resource_code"])),
		"pin_mode":       "equipped",
		"updated_at_ms":  int64(0),
		"resource":       resource,
	}
}

func roomMergeBadgeItems(displayBadges []map[string]any, equippedBadges []map[string]any) []map[string]any {
	if len(displayBadges) == 0 && len(equippedBadges) == 0 {
		return []map[string]any{}
	}
	result := make([]map[string]any, 0, len(displayBadges)+len(equippedBadges))
	seen := make(map[string]struct{}, len(displayBadges)+len(equippedBadges))
	add := func(item map[string]any) {
		if len(item) == 0 {
			return
		}
		key := roomBadgeDedupeKey(item)
		if key != "" {
			if _, exists := seen[key]; exists {
				return
			}
			seen[key] = struct{}{}
		}
		result = append(result, item)
	}
	for _, item := range displayBadges {
		add(item)
	}
	for _, item := range equippedBadges {
		add(item)
	}
	return result
}

func roomBadgeDedupeKey(item map[string]any) string {
	resourceID := strings.TrimSpace(fmt.Sprint(item["resource_id"]))
	entitlementID := strings.TrimSpace(fmt.Sprint(item["entitlement_id"]))
	if resourceID == "" || resourceID == "0" {
		if resource, ok := item["resource"].(map[string]any); ok {
			resourceID = strings.TrimSpace(fmt.Sprint(resource["resource_id"]))
		}
	}
	if entitlementID != "" {
		return "entitlement:" + entitlementID
	}
	if resourceID != "" && resourceID != "0" {
		return "resource:" + resourceID
	}
	return ""
}

type roomLevelDisplayResources struct {
	Levels map[string]any
	Badges map[string]any
}

func (h *Handler) roomLevelDisplayProfileMap(request *http.Request, userIDs []int64) map[int64]roomLevelDisplayResources {
	result := make(map[int64]roomLevelDisplayResources, len(userIDs))
	for _, userID := range userIDs {
		result[userID] = roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}}
	}
	if h.growthLevelClient != nil && len(userIDs) > 0 {
		resp, err := h.growthLevelClient.BatchGetUserLevelDisplayProfiles(request.Context(), &activityv1.BatchGetUserLevelDisplayProfilesRequest{
			Meta:    httpkit.ActivityMeta(request),
			UserIds: userIDs,
		})
		if err == nil {
			resourceIDs := make([]int64, 0, len(resp.GetProfiles())*3)
			for _, profile := range resp.GetProfiles() {
				resourceIDs = append(resourceIDs, profile.GetWealth().GetBadgeResourceId())
				resourceIDs = append(resourceIDs, profile.GetGame().GetBadgeResourceId())
				resourceIDs = append(resourceIDs, profile.GetCharm().GetBadgeResourceId())
			}
			resources := h.roomResourcesByID(request, resourceIDs)
			for _, profile := range resp.GetProfiles() {
				if profile.GetUserId() <= 0 {
					continue
				}
				current := roomLevelDisplayResources{Levels: roomDefaultLevelDisplay(), Badges: map[string]any{}}
				roomApplyLevelDisplayTrack(current, profile.GetWealth(), resources)
				roomApplyLevelDisplayTrack(current, profile.GetGame(), resources)
				roomApplyLevelDisplayTrack(current, profile.GetCharm(), resources)
				result[profile.GetUserId()] = current
			}
		}
	}
	h.fillRoomLevelBadgeEntitlementFallback(request, userIDs, result)
	return result
}

func (h *Handler) fillRoomLevelBadgeEntitlementFallback(request *http.Request, userIDs []int64, result map[int64]roomLevelDisplayResources) {
	if h.walletClient == nil || len(userIDs) == 0 {
		return
	}
	for _, userID := range userIDs {
		current := result[userID]
		if !roomLevelBadgesNeedFallback(current.Badges) {
			continue
		}
		// wallet 暂无按多用户批量查拥有资源 RPC；这里仅在 activity 投影缺轨道时逐用户读取 active badge 权益。
		// 已有投影仍优先返回，避免注册 1 级权益覆盖用户后续升级后的等级徽章。
		resp, err := h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
			RequestId:    httpkit.RequestIDFromContext(request.Context()),
			AppCode:      appcode.FromContext(request.Context()),
			UserId:       userID,
			ResourceType: "badge",
			ActiveOnly:   true,
		})
		if err != nil {
			continue
		}
		for _, item := range resp.GetResources() {
			roomApplyLevelBadgeEntitlement(current, item)
			if !roomLevelBadgesNeedFallback(current.Badges) {
				break
			}
		}
		result[userID] = current
	}
}

func roomLevelBadgesNeedFallback(levelBadges map[string]any) bool {
	if levelBadges == nil {
		return true
	}
	for _, track := range []string{"wealth", "game", "charm"} {
		if _, ok := levelBadges[track]; !ok {
			return true
		}
	}
	return false
}

func roomApplyLevelBadgeEntitlement(current roomLevelDisplayResources, item *walletv1.UserResourceEntitlement) {
	if item == nil || current.Badges == nil {
		return
	}
	resource := roomAppearanceResourceFromEntitlement(item)
	track := strings.TrimSpace(fmt.Sprint(resource["level_track"]))
	if track == "" || fmt.Sprint(resource["badge_kind"]) != "level" || fmt.Sprint(resource["badge_form"]) != "strip" {
		return
	}
	if _, exists := current.Badges[track]; exists {
		return
	}
	resourceID, _ := resource["resource_id"].(int64)
	if resourceID <= 0 {
		return
	}
	current.Badges[track] = map[string]any{
		"track":          track,
		"source_type":    "wallet_entitlement",
		"source_level":   roomLevelValue(current.Levels, track),
		"badge_kind":     "level",
		"resource_id":    resourceID,
		"entitlement_id": item.GetEntitlementId(),
		"updated_at_ms":  item.GetUpdatedAtMs(),
		"resource":       resource,
	}
}

func roomLevelValue(levels map[string]any, track string) int64 {
	trackLevel, ok := levels[track].(map[string]any)
	if !ok {
		return 0
	}
	switch value := trackLevel["level"].(type) {
	case int64:
		return value
	case int32:
		return int64(value)
	case int:
		return int64(value)
	case float64:
		return int64(value)
	default:
		return 0
	}
}

func roomApplyLevelDisplayTrack(current roomLevelDisplayResources, item *activityv1.LevelDisplayTrackProfile, resources map[int64]map[string]any) {
	if item == nil || strings.TrimSpace(item.GetTrack()) == "" {
		return
	}
	track := strings.TrimSpace(item.GetTrack())
	current.Levels[track] = map[string]any{
		"level":                item.GetLevel(),
		"tier_id":              item.GetTierId(),
		"badge_resource_id":    item.GetBadgeResourceId(),
		"badge_source_level":   item.GetBadgeSourceLevel(),
		"avatar_frame_id":      item.GetAvatarFrameResourceId(),
		"updated_at_ms":        item.GetUpdatedAtMs(),
		"display_badge_source": "level_rule",
	}
	if item.GetBadgeResourceId() <= 0 {
		return
	}
	badge := map[string]any{
		"track":         track,
		"source_type":   "level_rule",
		"source_level":  item.GetBadgeSourceLevel(),
		"badge_kind":    "level",
		"resource_id":   item.GetBadgeResourceId(),
		"updated_at_ms": item.GetUpdatedAtMs(),
	}
	if resource, ok := resources[item.GetBadgeResourceId()]; ok {
		badge["resource"] = resource
	}
	current.Badges[track] = badge
}

func roomDefaultLevelDisplay() map[string]any {
	return map[string]any{
		"wealth": map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
		"game":   map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
		"charm":  map[string]any{"level": int32(0), "tier_id": int64(0), "badge_resource_id": int64(0), "badge_source_level": int32(0), "avatar_frame_id": int64(0), "updated_at_ms": int64(0), "display_badge_source": "level_rule"},
	}
}

func (h *Handler) roomBadgeDisplayMap(request *http.Request, userIDs []int64) map[int64][]map[string]any {
	result := make(map[int64][]map[string]any, len(userIDs))
	if h.achievementClient == nil || len(userIDs) == 0 {
		return result
	}
	// activity 目前没有 badge 批量接口；这里逐用户读取，但所有 badge 资源素材统一去重后解析。
	raw := make(map[int64][]*activityv1.BadgeDisplayItem, len(userIDs))
	resourceIDs := make([]int64, 0)
	for _, userID := range userIDs {
		resp, err := h.achievementClient.ListMyBadges(request.Context(), &activityv1.ListMyBadgesRequest{
			Meta:   httpkit.ActivityMeta(request),
			UserId: userID,
		})
		if err != nil {
			// 单个用户 badge 投影失败时只影响该用户徽章，不拖垮整个房间资料响应。
			continue
		}
		items := append([]*activityv1.BadgeDisplayItem{}, resp.GetStripBadges()...)
		items = append(items, resp.GetProfileTileBadges()...)
		raw[userID] = items
		for _, item := range items {
			resourceIDs = append(resourceIDs, item.GetResourceId())
		}
	}
	resources := h.roomResourcesByID(request, resourceIDs)
	for userID, items := range raw {
		result[userID] = roomBadgeItemsFromProto(items, resources)
	}
	return result
}

func (h *Handler) roomResourcesByID(request *http.Request, ids []int64) map[int64]map[string]any {
	ids = uniquePositiveUserIDs(ids)
	result := make(map[int64]map[string]any, len(ids))
	if h.walletClient == nil || len(ids) == 0 {
		return result
	}
	// badge display item 只带 resource_id；这里补齐客户端渲染需要的 asset/animation/metadata。
	for _, id := range ids {
		resp, err := h.walletClient.GetResource(request.Context(), &walletv1.GetResourceRequest{
			RequestId:  httpkit.RequestIDFromContext(request.Context()),
			AppCode:    appcode.FromContext(request.Context()),
			ResourceId: id,
		})
		if err != nil {
			// 素材缺失时保留 badge item 本体，客户端至少能根据 resource_id 做兜底展示。
			continue
		}
		resource := roomAppearanceResourceFromProto(resp.GetResource(), "", 0)
		if resourceID, ok := resource["resource_id"].(int64); ok && resourceID > 0 {
			result[resourceID] = resource
		}
	}
	return result
}

func roomBadgeItemsFromProto(items []*activityv1.BadgeDisplayItem, resources map[int64]map[string]any) []map[string]any {
	result := make([]map[string]any, 0, len(items))
	for _, item := range items {
		if item == nil {
			continue
		}
		data := map[string]any{
			"slot":           item.GetSlot(),
			"position":       item.GetPosition(),
			"badge_form":     item.GetBadgeForm(),
			"resource_id":    item.GetResourceId(),
			"entitlement_id": item.GetEntitlementId(),
			"source_type":    item.GetSourceType(),
			"source_id":      item.GetSourceId(),
			"pin_mode":       item.GetPinMode(),
			"updated_at_ms":  item.GetUpdatedAtMs(),
		}
		if resource, ok := resources[item.GetResourceId()]; ok {
			data["resource"] = resource
		}
		result = append(result, data)
	}
	return result
}

func roomAppearanceResourceFromEntitlement(item *walletv1.UserResourceEntitlement) map[string]any {
	if item == nil {
		return map[string]any{}
	}
	return roomAppearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs())
}

func roomAppearanceResourceFromProto(item *walletv1.Resource, entitlementID string, expiresAtMS int64) map[string]any {
	if item == nil || item.GetResourceId() <= 0 {
		return map[string]any{}
	}
	resource := map[string]any{
		"resource_id":    item.GetResourceId(),
		"resource_code":  item.GetResourceCode(),
		"resource_type":  item.GetResourceType(),
		"name":           item.GetName(),
		"asset_url":      item.GetAssetUrl(),
		"preview_url":    item.GetPreviewUrl(),
		"animation_url":  item.GetAnimationUrl(),
		"metadata_json":  item.GetMetadataJson(),
		"entitlement_id": entitlementID,
		"expires_at_ms":  expiresAtMS,
	}
	if item.GetResourceType() == "badge" {
		badgeForm, badgeKind, levelTrack := roomBadgeMetadataFields(item.GetMetadataJson())
		resource["badge_form"] = badgeForm
		resource["badge_kind"] = badgeKind
		resource["level_track"] = levelTrack
	}
	return resource
}

func roomBadgeMetadataFields(raw string) (string, string, string) {
	payload := map[string]any{}
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &payload); err != nil {
		return "", "normal", ""
	}
	badgeForm := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_form"])))
	if badgeForm != "strip" && badgeForm != "tile" {
		badgeForm = ""
	}
	badgeKind := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["badge_kind"])))
	if badgeKind != "level" {
		badgeKind = "normal"
	}
	levelTrack := strings.ToLower(strings.TrimSpace(fmt.Sprint(payload["level_track"])))
	switch levelTrack {
	case "wealth", "game", "charm":
	default:
		levelTrack = ""
	}
	return badgeForm, badgeKind, levelTrack
}

func roomProfileAgeFromBirth(birth string, now time.Time) int32 {
	birth = strings.TrimSpace(birth)
	if birth == "" {
		return 0
	}
	born, err := time.Parse("2006-01-02", birth)
	if err != nil {
		return 0
	}
	now = now.UTC()
	today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
	birthdayThisYear := time.Date(now.Year(), born.Month(), born.Day(), 0, 0, 0, 0, time.UTC)
	age := now.Year() - born.Year()
	if today.Before(birthdayThisYear) {
		age--
	}
	if age < 0 || age > 150 {
		return 0
	}
	return int32(age)
}

func roomProfileCountryFlag(country string) string {
	country = strings.ToUpper(strings.TrimSpace(country))
	if len(country) != 2 {
		return ""
	}
	runes := []rune(country)
	for _, value := range runes {
		if value < 'A' || value > 'Z' {
			return ""
		}
	}
	const regionalIndicatorA = 0x1F1E6
	return string([]rune{
		regionalIndicatorA + (runes[0] - 'A'),
		regionalIndicatorA + (runes[1] - 'A'),
	})
}

func roomGiftRecipientUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
	userIDs := make([]int64, 0, len(snapshot.GetMicSeats())+1)
	for _, seat := range snapshot.GetMicSeats() {
		if seat.GetUserId() > 0 {
			userIDs = append(userIDs, seat.GetUserId())
		}
	}
	if len(userIDs) == 0 {
		userIDs = append(userIDs, snapshot.GetOwnerUserId())
	}
	return uniquePositiveUserIDs(userIDs)
}

func roomGiftRecipients(snapshot *roomv1.RoomSnapshot, profiles map[int64]roomDisplayProfileData) []roomGiftRecipientData {
	userIDs := roomGiftRecipientUserIDs(snapshot)
	recipients := make([]roomGiftRecipientData, 0, len(userIDs)+1)
	if len(userIDs) > 1 {
		recipients = append(recipients, roomGiftRecipientData{TargetType: "all_mic", Label: "all_mic"})
	}
	seatByUser := make(map[int64]int32, len(snapshot.GetMicSeats()))
	for _, seat := range snapshot.GetMicSeats() {
		if seat.GetUserId() > 0 {
			seatByUser[seat.GetUserId()] = seat.GetSeatNo()
		}
	}
	for _, userID := range userIDs {
		recipient := roomGiftRecipientData{
			TargetType: "user",
			UserID:     formatOptionalUserID(userID),
			SeatNo:     seatByUser[userID],
		}
		if profile, ok := profiles[userID]; ok {
			copied := profile
			recipient.Profile = &copied
			recipient.Label = copied.Username
		}
		recipients = append(recipients, recipient)
	}
	return recipients
}

func roomGiftTabs(gifts []giftConfigData, giftTypes []giftTypeConfigData) []roomGiftTabData {
	tabs := []roomGiftTabData{{Key: "all", Order: 0}}
	hasBag := false
	if len(giftTypes) > 0 {
		hasGift := make(map[string]bool, len(gifts))
		for _, gift := range gifts {
			key := strings.TrimSpace(gift.GiftTypeCode)
			if key == "bag" || strings.TrimSpace(gift.Source) == "bag" {
				hasBag = true
				continue
			}
			if key != "" {
				hasGift[key] = true
			}
		}
		for _, giftType := range giftTypes {
			typeCode := strings.TrimSpace(giftType.TypeCode)
			if typeCode == "" || !hasGift[typeCode] {
				continue
			}
			tabKey := strings.TrimSpace(giftType.TabKey)
			if tabKey == "" {
				tabKey = typeCode
			}
			tabs = append(tabs, roomGiftTabData{
				Key:          typeCode,
				GiftTypeCode: typeCode,
				Label:        tabKey,
				Order:        giftType.SortOrder,
			})
		}
		if hasBag {
			tabs = append(tabs, roomGiftTabData{Key: "bag", GiftTypeCode: "bag", Label: "Bag", Order: 5})
		}
		return tabs
	}
	seen := map[string]bool{"all": true}
	for _, gift := range gifts {
		key := strings.TrimSpace(gift.GiftTypeCode)
		if key == "" || seen[key] {
			continue
		}
		seen[key] = true
		tabs = append(tabs, roomGiftTabData{Key: key, Order: int32(len(tabs) * 10)})
	}
	return tabs
}

func filterGiftPanelGifts(gifts []giftConfigData, giftTypes []giftTypeConfigData) []giftConfigData {
	if len(giftTypes) == 0 {
		return gifts
	}
	activeTypes := make(map[string]bool, len(giftTypes))
	for _, giftType := range giftTypes {
		typeCode := strings.TrimSpace(giftType.TypeCode)
		if typeCode != "" {
			activeTypes[typeCode] = true
		}
	}
	out := make([]giftConfigData, 0, len(gifts))
	for _, gift := range gifts {
		if activeTypes[strings.TrimSpace(gift.GiftTypeCode)] {
			out = append(out, gift)
		}
	}
	return out
}

func coinBalanceFromProto(resp *walletv1.GetBalancesResponse) int64 {
	for _, balance := range resp.GetBalances() {
		if balance.GetAssetType() == "COIN" {
			return balance.GetAvailableAmount()
		}
	}
	return 0
}

func uniquePositiveUserIDs(userIDs []int64) []int64 {
	seen := make(map[int64]bool, len(userIDs))
	result := make([]int64, 0, len(userIDs))
	for _, userID := range userIDs {
		if userID <= 0 || seen[userID] {
			continue
		}
		seen[userID] = true
		result = append(result, userID)
	}
	return result
}

func normalizeGiftTargetUserIDs(targetUserID int64, targetUserIDs []int64) []int64 {
	result := uniquePositiveUserIDs(targetUserIDs)
	if len(result) == 0 && targetUserID > 0 {
		result = append(result, targetUserID)
	}
	return result
}

func firstUserID(userIDs []int64) int64 {
	if len(userIDs) == 0 {
		return 0
	}
	return userIDs[0]
}

func robotRoomProfileUserIDs(snapshot *roomv1.RoomSnapshot) []int64 {
	if snapshot == nil || snapshot.GetRoomExt()[roomExtRobotRoomKey] != "true" {
		return nil
	}
	return parsePositiveInt64CSV(snapshot.GetRoomExt()[roomExtRobotUserIDsKey])
}

func parsePositiveInt64CSV(raw string) []int64 {
	userIDs := make([]int64, 0)
	for piece := range strings.SplitSeq(raw, ",") {
		piece = strings.TrimSpace(piece)
		if piece == "" {
			continue
		}
		userID, err := strconv.ParseInt(piece, 10, 64)
		if err != nil || userID <= 0 {
			continue
		}
		userIDs = append(userIDs, userID)
	}
	return uniquePositiveUserIDs(userIDs)
}
