package roomapi

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

	"hyapp/pkg/appcode"
	"hyapp/pkg/giftlimits"
	"hyapp/pkg/roomid"
	"hyapp/pkg/xerr"
	"hyapp/services/gateway-service/internal/auth"
	"hyapp/services/gateway-service/internal/transport/http/resourceview"

	"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"
	roomAppearanceResourceChatBubble       = "chat_bubble"
	roomAppearanceResourceBadge            = "badge"
	roomAppearanceResourceMicSeatIcon      = "mic_seat_icon"
	roomAppearanceResourceMicSeatAnimation = "mic_seat_animation"

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

	// JoinRoom 默认保持历史完整包；lite 只保留进房首屏必要字段，把装扮/榜单等增强展示后移。
	joinRoomResponseModeFull = "full"
	joinRoomResponseModeLite = "lite"
	// 座驾只影响进房展示，独立短预算避免 wallet 抖动把 lite 首屏拖到通用 5 秒 RPC 上限。
	joinRoomEntryVehicleLookupTimeout = 100 * time.Millisecond
	// VIP 只增强送礼 IM 展示；短预算并发查询，wallet 抖动时降级为 0，不能阻断变现主链路。
	giftSenderVIPLookupTimeout = 500 * time.Millisecond
)

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"`
}

// publicRoomListFilter 只表达 Flutter 从 /rooms/filters 原样回传的一种筛选。
// RegionID 和 CountryCode 必须互斥；两者为空才表示 All。
type publicRoomListFilter struct {
	RegionID    int64
	CountryCode string
}

// listRooms 按 App 策略解析默认 All/当前区域，并校验 Flutter 回传的区域或国家筛选。
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
	}
	filter, ok := parsePublicRoomListFilter(request)
	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
	}
	allRegionDiscovery := h.roomDiscoveryAllRegions(request)
	if !allRegionDiscovery && userResp.GetUser().GetRegionId() <= 0 {
		// viewer_region 能力必须有身份区域；配置缺失或读取失败均 fail closed，不退化到 GLOBAL。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	countries, registrationCountries, err := h.roomListCountriesForUser(request, userResp.GetUser())
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	allVisibleRegions := false
	filterRegionID := int64(0)
	countryCode := filter.CountryCode
	if allRegionDiscovery {
		switch {
		case filter.RegionID > 0:
			regions, err := h.roomListActiveRegions(request)
			if err != nil {
				httpkit.WriteRPCError(writer, request, err)
				return
			}
			if !roomListContainsActiveRegion(regions, filter.RegionID) {
				httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
				return
			}
			filterRegionID = filter.RegionID
		case countryCode != "":
			regions, err := h.roomListActiveRegions(request)
			if err != nil {
				httpkit.WriteRPCError(writer, request, err)
				return
			}
			if !roomListCatalogContainsCountry(regions, registrationCountries, countryCode) {
				httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
				return
			}
			allVisibleRegions = true
		default:
			allVisibleRegions = true
		}
	} else {
		if filter.RegionID > 0 || !roomListCountryAllowed(countries, countryCode) {
			httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
			return
		}
		allVisibleRegions = h.roomRegionWhitelistAllows(request, viewerUserID)
	}
	queryRegionID := userResp.GetUser().GetRegionId()
	if filterRegionID > 0 {
		// 滚动发布期间旧 room-service 会忽略新 FilterRegionId；同时覆盖旧 VisibleRegionId 保证首页仍查选中区域。
		queryRegionID = filterRegionID
	}

	resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{
		Meta:              httpkit.RoomMeta(request, "", ""),
		ViewerUserId:      viewerUserID,
		VisibleRegionId:   queryRegionID,
		ViewerCountryCode: userResp.GetUser().GetCountry(),
		AllVisibleRegions: allVisibleRegions,
		FilterRegionId:    filterRegionID,
		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)
}

func (h *Handler) roomDiscoveryAllRegions(request *http.Request) bool {
	reader, ok := h.appConfigReader.(roomDiscoveryCapabilityReader)
	if !ok || reader == nil {
		return false
	}
	allowed, err := reader.RoomDiscoveryAllRegions(request.Context(), appcode.FromContext(request.Context()))
	if err != nil {
		return false
	}
	return allowed
}

// 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, []*userv1.Country, error) {
	if user == nil {
		return nil, nil, xerr.New(xerr.InvalidArgument, "user region is required")
	}
	countryResp, err := h.userCountryClient.ListRegistrationCountries(request.Context(), &userv1.ListRegistrationCountriesRequest{
		Meta: httpkit.UserMeta(request, ""),
	})
	if err != nil {
		return nil, nil, err
	}
	if user.GetRegionId() <= 0 {
		// Huwaa All 不需要用户区域；旧 countries 字段留空，不用 IP/国家猜一个区域。
		return []roomListCountryData{}, countryResp.GetCountries(), nil
	}
	regionResp, err := h.userRegionClient.GetRegion(request.Context(), &userv1.GetRegionRequest{
		Meta:     httpkit.UserMeta(request, ""),
		RegionId: user.GetRegionId(),
	})
	if err != nil {
		return nil, nil, err
	}
	return roomListCountriesFromProto(user, regionResp.GetRegion(), countryResp.GetCountries()), countryResp.GetCountries(), nil
}

func (h *Handler) roomListActiveRegions(request *http.Request) ([]*userv1.Region, error) {
	resp, err := h.userRegionClient.ListRegions(request.Context(), &userv1.ListRegionsRequest{
		Meta:   httpkit.UserMeta(request, ""),
		Status: roomFilterRegionStatusActive,
	})
	if err != nil {
		return nil, err
	}
	return resp.GetRegions(), 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 roomListCountryAllowed(countries []roomListCountryData, code string) bool {
	code = normalizeRoomListCountryCode(code)
	if code == "" {
		return true
	}
	for _, country := range countries {
		if normalizeRoomListCountryCode(country.CountryCode) == code {
			return true
		}
	}
	return false
}

func roomListContainsActiveRegion(regions []*userv1.Region, regionID int64) bool {
	if regionID <= 0 {
		return false
	}
	for _, region := range regions {
		if region != nil && region.GetRegionId() == regionID {
			return true
		}
	}
	return false
}

func roomListCatalogContainsCountry(regions []*userv1.Region, countries []*userv1.Country, countryCode string) bool {
	countryCode = normalizeRoomListCountryCode(countryCode)
	if countryCode == "" {
		return false
	}
	// 和 /rooms/filters 复用同一投影规则：只有 enabled 国家 + active 区域映射同时成立才能成为可查询值。
	for _, item := range roomFilterCatalogDataFromProto(regions, countries).Countries {
		if item.CountryCode == countryCode {
			return true
		}
	}
	return false
}

func parsePublicRoomListFilter(request *http.Request) (publicRoomListFilter, bool) {
	if request == nil {
		return publicRoomListFilter{}, false
	}
	rawRegionID := strings.TrimSpace(request.URL.Query().Get("region_id"))
	rawCountryCode := strings.TrimSpace(request.URL.Query().Get("country_code"))
	if rawCountryCode == "" {
		// country 是既有客户端别名；新 Flutter 按目录下发的 country_code 回传。
		rawCountryCode = strings.TrimSpace(request.URL.Query().Get("country"))
	}
	if rawRegionID != "" && rawCountryCode != "" {
		return publicRoomListFilter{}, false
	}
	filter := publicRoomListFilter{}
	if rawRegionID != "" {
		regionID, err := strconv.ParseInt(rawRegionID, 10, 64)
		if err != nil || regionID <= 0 {
			// All 必须不传筛选参数；region_id=0 不是目录下发的合法选项。
			return publicRoomListFilter{}, false
		}
		filter.RegionID = regionID
	}
	if rawCountryCode != "" {
		filter.CountryCode = normalizeRoomListCountryCode(rawCountryCode)
		if filter.CountryCode == "" {
			return publicRoomListFilter{}, false
		}
	}
	return filter, true
}

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)))
}

// getRoomContributionRank 返回房间内用户贡献周期榜。
func (h *Handler) getRoomContributionRank(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
	}
	period := strings.ToLower(strings.TrimSpace(request.URL.Query().Get("period")))
	switch period {
	case "", "day", "week", "month":
		// 空 period 在 room-service 收敛为 day，方便首屏默认 tab 不重复写参数。
	default:
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	limit, ok := httpkit.PositiveInt32Query(request, "limit", 20)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}

	resp, err := h.roomQueryClient.ListRoomContributionRank(request.Context(), &roomv1.ListRoomContributionRankRequest{
		Meta:         httpkit.RoomMeta(request, roomID, ""),
		RoomId:       roomID,
		Period:       period,
		Limit:        limit,
		ViewerUserId: auth.UserIDFromContext(request.Context()),
	})
	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, roomContributionRankDataFromProto(resp, h.roomDisplayProfileMap(request, userIDs)))
}

// getRoomGiftPanel 返回房间送礼面板初始化数据。
func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Request) {
	h.serveRoomGiftPanel(writer, request)
}

func (h *Handler) roomGiftPanelBaseConfig(request *http.Request, regionID int64) (roomGiftPanelConfig, error) {
	app := appcode.FromContext(request.Context())
	catalogVersion, cacheable, err := h.roomGiftCatalogVersion(request, app)
	if err != nil {
		return roomGiftPanelConfig{}, err
	}
	return h.roomGiftPanelBaseConfigForVersion(request, app, regionID, catalogVersion, cacheable)
}

// roomGiftPanelBaseConfigForVersion 使用已并行取得的 owner 版本加载稳定配置。
// 类型与礼物列表互不依赖，因此 cache miss 时同时查询；区域过滤仍只由 wallet-service 解释。
func (h *Handler) roomGiftPanelBaseConfigForVersion(request *http.Request, app string, regionID int64, catalogVersion int64, cacheable bool) (roomGiftPanelConfig, error) {
	requestID := httpkit.RequestIDFromContext(request.Context())
	roomID := strings.TrimSpace(request.PathValue("room_id"))
	if cacheable && h.giftPanelCache != nil {
		cacheStartedAt := time.Now()
		if cached, ok := h.giftPanelCache.Get(app, regionID, catalogVersion, time.Now()); ok {
			logRoomGiftPanelStage(request.Context(), requestID, roomID, "config_cache_hit", cacheStartedAt, nil, false)
			return cached, nil
		}
	}

	// 任一配置 RPC 失败就取消同批 sibling，并在 Wait 后返回；这样总预算取消时不会遗留继续占连接的查询 goroutine。
	configCtx, cancelConfig := context.WithCancel(request.Context())
	configRequest := request.WithContext(configCtx)
	var waitGroup sync.WaitGroup
	var giftTypeResp *walletv1.ListGiftTypeConfigsResponse
	var giftTypeErr error
	var giftResp *walletv1.ListGiftConfigsResponse
	var giftErr error
	waitGroup.Add(2)
	go func() {
		defer waitGroup.Done()
		startedAt := time.Now()
		giftTypeResp, giftTypeErr = h.walletClient.ListGiftTypeConfigs(configCtx, &walletv1.ListGiftTypeConfigsRequest{
			RequestId:  requestID,
			AppCode:    app,
			ActiveOnly: true,
		})
		logRoomGiftPanelStage(configRequest.Context(), requestID, roomID, "gift_types", startedAt, giftTypeErr, false)
		if giftTypeErr != nil {
			cancelConfig()
		}
	}()
	go func() {
		defer waitGroup.Done()
		startedAt := time.Now()
		giftResp, giftErr = h.walletClient.ListGiftConfigs(configCtx, &walletv1.ListGiftConfigsRequest{
			RequestId:    requestID,
			AppCode:      app,
			Page:         1,
			PageSize:     500,
			ActiveOnly:   true,
			RegionId:     regionID,
			FilterRegion: true,
		})
		logRoomGiftPanelStage(configRequest.Context(), requestID, roomID, "gifts", startedAt, giftErr, false)
		if giftErr != nil {
			cancelConfig()
		}
	}()
	waitGroup.Wait()
	cancelConfig()
	if giftTypeErr != nil {
		return roomGiftPanelConfig{}, giftTypeErr
	}
	if giftErr != nil {
		return roomGiftPanelConfig{}, giftErr
	}
	giftTypes := make([]giftTypeConfigData, 0, len(giftTypeResp.GetGiftTypes()))
	for _, item := range giftTypeResp.GetGiftTypes() {
		giftTypes = append(giftTypes, giftTypeFromProto(item))
	}
	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 cacheable && h.giftPanelCache != nil {
		h.giftPanelCache.Set(app, regionID, catalogVersion, config, time.Now())
	}
	return config, nil
}

func (h *Handler) roomGiftCatalogVersion(request *http.Request, app string) (int64, bool, error) {
	resp, err := h.walletClient.GetGiftCatalogVersion(request.Context(), &walletv1.GetGiftCatalogVersionRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()),
		AppCode:   app,
	})
	if err != nil {
		if status.Code(err) == codes.Unimplemented {
			// 滚动发布期间旧 wallet-service 不认识版本 RPC 时，禁用本次配置缓存并回退到原来的直接查询路径。
			return 0, false, nil
		}
		return 0, false, err
	}
	// wallet-service 是礼物目录 owner；gateway 只把 owner 版本当不透明令牌，避免用 TTL 命中跨过后台配置变更。
	return resp.GetVersion(), true, nil
}

func (h *Handler) roomGiftBagResources(request *http.Request, viewerUserID int64) (*walletv1.ListUserResourcesResponse, error) {
	if viewerUserID <= 0 || h.walletClient == nil {
		return &walletv1.ListUserResourcesResponse{}, nil
	}
	return h.walletClient.ListUserResources(request.Context(), &walletv1.ListUserResourcesRequest{
		RequestId:    httpkit.RequestIDFromContext(request.Context()),
		AppCode:      appcode.FromContext(request.Context()),
		UserId:       viewerUserID,
		ResourceType: "gift",
		ActiveOnly:   true,
	})
}

func roomGiftBagGiftsFromResources(resp *walletv1.ListUserResourcesResponse, gifts []giftConfigData, visibleRegionID int64) []giftConfigData {
	if resp == nil {
		return nil
	}
	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
		}
		if item.GetSourceSnapshotId() != "" {
			// Pinned activity rewards are wallet-owned promises. Current catalog disable/edit must not hide or
			// rewrite them; wallet already verified hash/app/resource binding before returning these configs.
			for _, pinned := range item.GetPinnedGiftConfigs() {
				if !giftProtoAvailableInRegion(pinned, visibleRegionID) {
					continue
				}
				gift := giftFromProto(pinned)
				gift = roomGiftBagConfig(gift, item, remaining)
				bagGifts = append(bagGifts, gift)
			}
			continue
		}
		gift, ok := giftByResourceID[item.GetResourceId()]
		if !ok {
			continue
		}
		// Bag tab 只承载用户实际拥有的 gift 权益；仍复用 gift 配置的价格/动效，避免背包资源和房间送礼协议出现两套展示口径。
		gift = roomGiftBagConfig(gift, item, remaining)
		bagGifts = append(bagGifts, gift)
	}
	return bagGifts
}

func roomGiftBagConfig(gift giftConfigData, item *walletv1.UserResourceEntitlement, remaining int64) giftConfigData {
	gift.GiftTypeCode = "bag"
	gift.Source = "bag"
	gift.EntitlementID = item.GetEntitlementId()
	gift.OwnedQuantity = item.GetQuantity()
	gift.RemainingQuantity = remaining
	return gift
}

func giftProtoAvailableInRegion(gift *walletv1.GiftConfig, regionID int64) bool {
	if gift == nil {
		return false
	}
	for _, allowed := range gift.GetRegionIds() {
		if allowed == 0 || allowed == regionID {
			return true
		}
	}
	return false
}

// 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"`
		CoverUploadID string `json:"cover_upload_id"`
		// RoomAvatar 仅用于 legacy_timed App 的旧客户端；权益制 App 由 room-service 强制要求上传凭证。
		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.CoverUploadID = strings.TrimSpace(body.CoverUploadID)
	body.RoomAvatar = strings.TrimSpace(body.RoomAvatar)
	body.RoomDescription = strings.TrimSpace(body.RoomDescription)
	if body.CommandID == "" || body.SeatCount < 0 || body.Mode == "" || body.RoomName == "" ||
		(body.CoverUploadID == "" && body.RoomAvatar == "") || (body.CoverUploadID != "" && body.RoomAvatar != "") {
		// 新协议只传 cover_upload_id；room_avatar 分支由 owner service 根据 App VIP program 决定是否仍可兼容。
		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,
		CoverUploadId:    body.CoverUploadID,
		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"`
		CoverUploadID   *string `json:"cover_upload_id"`
		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.CoverUploadID)
	trimOptionalString(body.RoomAvatar)
	trimOptionalString(body.RoomDescription)
	if body.CoverUploadID != nil && body.RoomAvatar != nil {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "cover_upload_id and room_avatar are mutually exclusive")
		return
	}

	resp, err := h.roomClient.UpdateRoomProfile(request.Context(), &roomv1.UpdateRoomProfileRequest{
		Meta:            httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		RoomName:        body.RoomName,
		CoverUploadId:   body.CoverUploadID,
		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"`
		CommandID string         `json:"command_id"`
		ImageURL  string         `json:"image_url"`
		Media     *roomMediaData `json:"media"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	body.RoomID = strings.TrimSpace(body.RoomID)
	body.CommandID = strings.TrimSpace(body.CommandID)
	body.ImageURL = strings.TrimSpace(body.ImageURL)
	if body.Media != nil && body.ImageURL == "" {
		body.ImageURL = strings.TrimSpace(body.Media.URL)
	}
	if body.Media != nil && body.ImageURL != strings.TrimSpace(body.Media.URL) {
		// 新版请求的 image_url 只是 media.url 的兼容镜像；两者不一致时拒绝，避免调用方误以为保存了另一张图。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "image_url does not match media.url")
		return
	}
	if !roomid.ValidStringID(body.RoomID) || body.ImageURL == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	var media *roomv1.RoomMedia
	if body.Media != nil {
		if h.objectURLVerifier == nil || !h.objectURLVerifier.OwnsObjectURL(body.Media.URL, body.Media.ObjectKey) {
			httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid room background media")
			return
		}
		media = body.Media.proto()
	}

	resp, err := h.roomClient.SaveRoomBackground(request.Context(), &roomv1.SaveRoomBackgroundRequest{
		Meta:     httpkit.RoomMeta(request, body.RoomID, body.CommandID),
		RoomId:   body.RoomID,
		ImageUrl: body.ImageURL,
		Media:    media,
	})
	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"`
		ResponseMode string `json:"response_mode"`
		// PresenceRecovery 只由房间心跳 NOT_FOUND 后的客户端恢复链路设置。
		PresenceRecovery bool `json:"presence_recovery"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	responseMode, ok := joinRoomResponseMode(request, body.ResponseMode)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid response_mode")
		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, responseMode),
		ResponseMode:     responseMode,
		PresenceRecovery: body.PresenceRecovery,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	if responseMode == joinRoomResponseModeLite {
		httpkit.WriteOK(writer, request, h.joinRoomInitialLiteData(request, body.RoomID, resp))
		return
	}
	httpkit.WriteOK(writer, request, h.joinRoomInitialData(request, body.RoomID, resp))
}

func joinRoomResponseMode(request *http.Request, bodyMode string) (string, bool) {
	mode := strings.TrimSpace(bodyMode)
	if mode == "" {
		mode = strings.TrimSpace(request.URL.Query().Get("response_mode"))
	}
	if mode == "" {
		return joinRoomResponseModeFull, true
	}
	mode = strings.ToLower(mode)
	switch mode {
	case joinRoomResponseModeFull, joinRoomResponseModeLite:
		return mode, true
	default:
		return "", false
	}
}

func (h *Handler) joinRoomEntryVehicleSnapshot(request *http.Request, responseMode string) *roomv1.RoomEntryVehicleSnapshot {
	if h.walletClient == nil {
		// 座驾只影响进房展示，不应该因为 wallet 暂不可用阻断 JoinRoom。
		return nil
	}
	userID := auth.UserIDFromContext(request.Context())
	if userID <= 0 {
		return nil
	}
	lookupCtx := request.Context()
	if responseMode == joinRoomResponseModeLite {
		// lite 只裁剪返回给进房者的首屏数据，不改变发给房内其他用户的进房事件事实；
		// 新增查询只查当前用户的一种资源，并用短预算保护 lite 延迟。full 保持原超时语义，避免扩大兼容性影响。
		var cancel context.CancelFunc
		lookupCtx, cancel = context.WithTimeout(lookupCtx, joinRoomEntryVehicleLookupTimeout)
		defer cancel()
	}
	resp, err := h.walletClient.BatchGetUserEquippedResources(lookupCtx, &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),
		EffectiveVIP:     roomEffectiveVIPDataFromProto(resp.GetEffectiveVip()),
		ServerTimeMS:     resp.GetResult().GetServerTimeMs(),
	}
}

func (h *Handler) joinRoomInitialLiteData(request *http.Request, requestedRoomID string, resp *roomv1.JoinRoomResponse) joinRoomLiteData {
	if resp == nil {
		return joinRoomLiteData{}
	}
	viewerUserID := auth.UserIDFromContext(request.Context())
	snapshot := resp.GetRoom()
	roomID := snapshot.GetRoomId()
	if roomID == "" {
		// lite 和完整包共用同一兜底，避免异常上游只回 result 时客户端拿不到 IM/RTC 房间 ID。
		roomID = strings.TrimSpace(requestedRoomID)
	}

	return joinRoomLiteData{
		Result:       commandResultDataFromProto(resp.GetResult()),
		Room:         roomInitialRoomDataFromSnapshot(snapshot, roomID),
		Viewer:       roomViewerDataFromProto(resp.GetUser(), viewerUserID),
		Seats:        roomSeatDataFromSnapshot(snapshot),
		Profiles:     h.roomInitialBasicProfiles(request, snapshot, viewerUserID),
		IM:           roomIMData{GroupID: roomIMGroupID(roomID), NeedJoinGroup: true},
		RTC:          h.joinRoomRTCData(roomID, viewerUserID),
		EffectiveVIP: roomEffectiveVIPDataFromProto(resp.GetEffectiveVip()),
		ServerTimeMS: resp.GetResult().GetServerTimeMs(),
	}
}

func roomEffectiveVIPDataFromProto(snapshot *roomv1.RoomEffectiveVipSnapshot) roomEffectiveVIPData {
	if snapshot == nil {
		return roomEffectiveVIPData{}
	}
	return roomEffectiveVIPData{
		ProgramType:            snapshot.GetProgramType(),
		Level:                  snapshot.GetLevel(),
		Name:                   snapshot.GetName(),
		RoomEntryNoticeEnabled: snapshot.GetRoomEntryNoticeEnabled(),
	}
}

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)
}

func (h *Handler) roomInitialBasicProfiles(request *http.Request, snapshot *roomv1.RoomSnapshot, viewerUserID int64) []roomBasicProfileData {
	userIDs := roomInitialBasicProfileUserIDs(snapshot, viewerUserID)
	profileMap := h.roomBasicProfileMap(request, userIDs)
	profiles := make([]roomBasicProfileData, 0, len(userIDs))
	for _, userID := range userIDs {
		if profile, ok := profileMap[userID]; ok {
			profiles = append(profiles, profile)
		}
	}
	return profiles
}

func (h *Handler) roomBasicProfileMap(request *http.Request, userIDs []int64) map[int64]roomBasicProfileData {
	if h.userProfileClient == nil {
		return nil
	}
	userIDs = uniquePositiveUserIDs(userIDs)
	if len(userIDs) == 0 {
		return nil
	}
	resp, err := h.userProfileClient.BatchGetRoomBasicUsers(request.Context(), &userv1.BatchGetRoomBasicUsersRequest{
		Meta:    httpkit.UserMeta(request, ""),
		UserIds: userIDs,
	})
	if err != nil {
		// lite 首屏资料是展示兜底，失败不能回滚已经成功的进房 presence。
		return nil
	}
	profiles := make(map[int64]roomBasicProfileData, len(resp.GetUsers()))
	for _, userID := range userIDs {
		user := resp.GetUsers()[userID]
		if user == nil {
			continue
		}
		profiles[userID] = roomBasicProfileData{
			UserID:        formatOptionalUserID(user.GetUserId()),
			Username:      strings.TrimSpace(user.GetUsername()),
			Avatar:        strings.TrimSpace(user.GetAvatar()),
			DisplayUserID: strings.TrimSpace(user.GetDisplayUserId()),
		}
	}
	return profiles
}

// 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 仅保留给已发布旧客户端的 V1 多人送礼入口。
// 它不改变账务和房间事实，只通过 display_mode=batch 要求 room-service 合并房间展示 IM；
// 当前 Flutter 无论单人或多人都固定调用 sendGiftV2。
func (h *Handler) sendGiftBatch(writer http.ResponseWriter, request *http.Request) {
	h.handleSendGift(writer, request, "batch")
}

// sendGiftV2 unifies single/multi-target gift batches. It preserves every V1 response field and adds
// a nested V2 settlement result, so old model parsing remains valid while new Flutter can reject stale
// responses and replay the exact committed result after a timeout.
func (h *Handler) sendGiftV2(writer http.ResponseWriter, request *http.Request) {
	h.handleSendGift(writer, request, "auto_v2")
}

func (h *Handler) handleSendGift(writer http.ResponseWriter, request *http.Request, displayMode string) {
	protocolV2 := displayMode == "auto_v2"
	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"`
		ComboSessionID string  `json:"combo_session_id"`
		BatchSeq       int64   `json:"batch_seq"`
	}

	if !httpkit.Decode(writer, request, &body) {
		return
	}
	roomID := strings.TrimSpace(body.RoomID)
	if roomID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	targetType := strings.TrimSpace(body.TargetType)
	if targetType == "" {
		targetType = "user"
	}
	if targetType != "user" {
		// room-service 当前协议只开放 user；未知类型必须在进入 limiter 前明确 4xx，
		// 不能用零目标请求污染容量 key，或因 limiter 输入不完整退化成 502。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	targetUserIDs := normalizeGiftTargetUserIDs(body.TargetUserID, body.TargetUserIDs)
	if len(targetUserIDs) == 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	if len(targetUserIDs) > h.giftMaxTargets {
		// 权重超过所有桶容量时永远不可能靠等待恢复；在进入 Redis 前明确拒绝为 4xx，
		// 同时用去重后的目标数判定，保留旧客户端重复提交同一 target 的兼容性。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	if displayMode == "auto_v2" {
		// RoomGiftBatchSent is only the merged multi-recipient display event. A single-recipient V2
		// micro-batch still gets V2 settlement metadata but must not enter the legacy multi-target path.
		if len(targetUserIDs) > 1 {
			displayMode = "batch"
		} else {
			displayMode = ""
		}
	}
	commandID := strings.TrimSpace(body.CommandID)
	comboSessionID := strings.TrimSpace(body.ComboSessionID)
	if !giftlimits.ValidCommandID(commandID) || strings.TrimSpace(body.GiftID) == "" || body.GiftCount <= 0 || int(body.GiftCount) > h.giftMaxCount {
		// command_id is the financial idempotency key, not a free-form trace field. Bound it before
		// Redis/MySQL keys are built; gift_count also gets a transport cap before any profile RPC.
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	if protocolV2 && (comboSessionID == "" || len(comboSessionID) > giftlimits.MaxCommandIDBytes || body.BatchSeq <= 0) {
		// V2 batches expose the session/sequence used to derive command_id. Both fields are persisted
		// in the idempotency payload, so a retry cannot silently attach the same key to another batch.
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	totalGiftUnits, hardLimitOK := giftlimits.WorkUnits(int64(body.GiftCount), len(targetUserIDs))
	if !hardLimitOK || totalGiftUnits > int64(h.giftMaxUnits) {
		// A per-count limit alone still permits 999*30 lucky draws. The product quantity remains
		// available for a small recipient set while explosive fan-out is rejected deterministically.
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	lease, allowed := h.acquireGiftCapacity(writer, request, roomID, int(totalGiftUnits))
	if !allowed {
		return
	}
	defer h.releaseGiftCapacity(lease)
	// profile、host 和最终 room SendGift 共用一个总 deadline，不能让三个串行 RPC 各自
	// 获得完整 gRPC 默认超时。in_flight lease 在配置校验中严格大于此预算并额外留 2s。
	giftCtx, cancelGift := context.WithTimeout(request.Context(), h.giftRequestTimeout)
	defer cancelGift()
	request = request.WithContext(giftCtx)

	userSnapshots, targetHostScopes, err := h.resolveGiftPreflight(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, roomID, commandID),
		TargetType:    targetType,
		TargetUserIds: targetUserIDs,
		TargetUserId:  firstUserID(targetUserIDs),
		GiftId:        body.GiftID,
		GiftCount:     body.GiftCount,
		PoolId:        body.PoolID,
		// 注册时间属于 user-service 主数据快照；只允许 gateway 从已鉴权 sender 资料注入，
		// room/lucky-gift 后续据此判断账号是否已跨过完整历史窗口，不能信任客户端自报。
		SenderRegisteredAtMs:    userSnapshots.senderRegisteredAtMS,
		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),
		ComboSessionId:          comboSessionID,
		BatchSeq:                body.BatchSeq,
	})
	if err == nil && !protocolV2 {
		// room-service 对所有送礼命令都持久化 typed V2 结果，保证内部 Saga 和幂等重放只有
		// 一套实现；但旧 HTTP 路由的外部 JSON 必须保持原形。这里重新组装所有 V1 顶层字段，
		// 只剥离内部追加的 v2，既不修改 gRPC 返回对象，也不影响 V2 路由返回完整结算结果。
		resp = legacySendGiftHTTPResponse(resp)
	}
	httpkit.Write(writer, request, resp, err)
}

func legacySendGiftHTTPResponse(resp *roomv1.SendGiftResponse) *roomv1.SendGiftResponse {
	if resp == nil {
		return nil
	}
	return &roomv1.SendGiftResponse{
		Result:                 resp.GetResult(),
		BillingReceiptId:       resp.GetBillingReceiptId(),
		RoomHeat:               resp.GetRoomHeat(),
		GiftRank:               resp.GetGiftRank(),
		Room:                   resp.GetRoom(),
		Rocket:                 resp.GetRocket(),
		LuckyGift:              resp.GetLuckyGift(),
		LuckyGifts:             resp.GetLuckyGifts(),
		CoinBalanceAfter:       resp.GetCoinBalanceAfter(),
		GiftIncomeBalanceAfter: resp.GetGiftIncomeBalanceAfter(),
		BatchDisplay:           resp.GetBatchDisplay(),
	}
}

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 {
	senderRegisteredAtMS  int64
	senderCountryID       int64
	senderRegionID        int64
	senderDisplayProfile  *roomv1.SendGiftDisplayProfile
	targetDisplayProfiles []*roomv1.SendGiftDisplayProfile
}

type giftSenderVIPPresentation struct {
	effectiveLevel            int32
	giftBroadcastAnimationURL string
}

func (h *Handler) resolveGiftPreflight(request *http.Request, targetUserIDs []int64) (giftUserSnapshots, []*roomv1.SendGiftTargetHostScope, error) {
	// 用户资料、主播工资归属和发送者 VIP 互不依赖。并发取得三份入站快照，让新增 VIP 不成为
	// 原有 profile -> host 串行链路之后的额外等待；任一结算必需查询失败时取消同批请求。
	preflightCtx, cancelPreflight := context.WithCancel(request.Context())
	defer cancelPreflight()
	preflightRequest := request.WithContext(preflightCtx)

	var waitGroup sync.WaitGroup
	var userSnapshots giftUserSnapshots
	var userSnapshotsErr error
	var targetHostScopes []*roomv1.SendGiftTargetHostScope
	var targetHostScopesErr error
	var senderVIP giftSenderVIPPresentation
	waitGroup.Add(3)
	go func() {
		defer waitGroup.Done()
		userSnapshots, userSnapshotsErr = h.resolveGiftUserSnapshots(preflightRequest, targetUserIDs)
		if userSnapshotsErr != nil {
			cancelPreflight()
		}
	}()
	go func() {
		defer waitGroup.Done()
		targetHostScopes, targetHostScopesErr = h.resolveGiftTargetHostScopes(preflightRequest, targetUserIDs)
		if targetHostScopesErr != nil {
			cancelPreflight()
		}
	}()
	go func() {
		defer waitGroup.Done()
		senderVIP = h.resolveGiftSenderVIPPresentationBestEffort(preflightRequest)
	}()
	waitGroup.Wait()

	if userSnapshotsErr != nil {
		return giftUserSnapshots{}, nil, userSnapshotsErr
	}
	if targetHostScopesErr != nil {
		return giftUserSnapshots{}, nil, targetHostScopesErr
	}
	if userSnapshots.senderDisplayProfile != nil {
		// VIP 等级和托盘资源属于同一份发送者展示快照；room-service 会把它随首次命令持久化，
		// 普通、幸运、超级幸运礼物的重试和 Saga 恢复都不能回查当前配置改写历史展示。
		userSnapshots.senderDisplayProfile.EffectiveVipLevel = senderVIP.effectiveLevel
		userSnapshots.senderDisplayProfile.GiftBroadcastAnimationUrl = senderVIP.giftBroadcastAnimationURL
	}
	return userSnapshots, targetHostScopes, nil
}

func (h *Handler) resolveGiftSenderVIPPresentationBestEffort(request *http.Request) giftSenderVIPPresentation {
	if h.walletClient == nil {
		return giftSenderVIPPresentation{}
	}
	senderUserID := auth.UserIDFromContext(request.Context())
	if senderUserID <= 0 {
		return giftSenderVIPPresentation{}
	}
	vipCtx, cancelVIP := context.WithTimeout(request.Context(), giftSenderVIPLookupTimeout)
	defer cancelVIP()
	resp, err := h.walletClient.GetMyVip(vipCtx, &walletv1.GetMyVipRequest{
		RequestId: httpkit.RequestIDFromContext(request.Context()),
		AppCode:   appcode.FromContext(request.Context()),
		UserId:    senderUserID,
	})
	if err != nil || resp == nil {
		return giftSenderVIPPresentation{}
	}
	state := resp.GetState()
	effectiveVIP := state.GetEffectiveVip()
	if effectiveVIP == nil {
		// GetMyVip.vip 历史上就是 effective VIP；保留滚动发布期间旧 wallet-service 的响应兼容。
		effectiveVIP = resp.GetVip()
	}
	if !effectiveVIP.GetActive() || effectiveVIP.GetLevel() <= 0 || (effectiveVIP.GetUserId() > 0 && effectiveVIP.GetUserId() != senderUserID) {
		return giftSenderVIPPresentation{}
	}
	return giftSenderVIPPresentation{
		effectiveLevel:            effectiveVIP.GetLevel(),
		giftBroadcastAnimationURL: giftTrayAnimationURLFromVIPState(state),
	}
}

func giftTrayAnimationURLFromVIPState(state *walletv1.VipState) string {
	if state == nil {
		return ""
	}
	for _, benefit := range state.GetEffectiveBenefits() {
		if !strings.EqualFold(strings.TrimSpace(benefit.GetBenefitCode()), "gift_tray_skin") ||
			!strings.EqualFold(strings.TrimSpace(benefit.GetStatus()), "active") ||
			!strings.EqualFold(strings.TrimSpace(benefit.GetResourceType()), "gift_tray_skin") ||
			benefit.GetResourceId() <= 0 {
			continue
		}
		resource := benefit.GetResource()
		// wallet 负责 App、VIP 和目录有效性；gateway 仍核对返回资源坐标，避免滚动发布或脏配置把别的装扮 URL 塞进礼物托盘。
		if resource == nil || resource.GetResourceId() != benefit.GetResourceId() ||
			!strings.EqualFold(strings.TrimSpace(resource.GetResourceType()), "gift_tray_skin") ||
			!strings.EqualFold(strings.TrimSpace(resource.GetStatus()), "active") {
			continue
		}
		return strings.TrimSpace(resource.GetAnimationUrl())
	}
	return ""
}

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{
		// created_at_ms 是账号 owner 返回的稳定事实；即使同一 command_id 重试时资料发生变化，
		// room-service 也会恢复首次持久化的命令快照，避免补偿大奖资格随重试漂移。
		senderRegisteredAtMS:  sender.GetCreatedAtMs(),
		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 {
		return false, 0, 0, status.Error(codes.InvalidArgument, "target user is invalid")
	}
	if h.userHostClient == nil {
		// Host 身份与 Agency 归属只能由 user-service 判定；配置缺失时不能把真实 Host 降级成普通收礼人绕过收益政策。
		return false, 0, 0, status.Error(codes.Unavailable, "user host service is unavailable")
	}
	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
	}
	if resp == nil || resp.GetHostProfile() == nil {
		return false, 0, 0, status.Error(codes.Unavailable, "user host service returned an invalid response")
	}
	profile := resp.GetHostProfile()
	if !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") {
		// user-service 明确返回非 active profile 时才按非主播处理。
		return false, 0, 0, nil
	}
	if profile.GetRegionId() <= 0 {
		return false, 0, 0, status.Error(codes.FailedPrecondition, "active host region is missing")
	}
	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 {
		return nil, status.Error(codes.Unavailable, "user host service is unavailable")
	}
	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
	}
	if resp == nil {
		return nil, status.Error(codes.Unavailable, "user host service returned an invalid response")
	}
	profiles := resp.GetHostProfiles()
	scopes := make([]*roomv1.SendGiftTargetHostScope, 0, len(targetUserIDs))
	for _, targetUserID := range targetUserIDs {
		// 批量送礼必须按接收方分别固化主播快照；缺失 profile 表示该 target 当前不是主播。
		scope, err := giftTargetHostScopeFromProfile(targetUserID, profiles[targetUserID])
		if err != nil {
			return nil, err
		}
		scopes = append(scopes, scope)
	}
	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, error) {
	scope := &roomv1.SendGiftTargetHostScope{TargetUserId: targetUserID}
	if profile == nil || !strings.EqualFold(strings.TrimSpace(profile.GetStatus()), "active") {
		// BatchGet 明确省略目标或返回非 active profile 时，该目标当前不是 Host。
		return scope, nil
	}
	if profile.GetRegionId() <= 0 {
		return nil, status.Error(codes.FailedPrecondition, "active host region is missing")
	}
	scope.TargetIsHost = true
	scope.TargetHostRegionId = profile.GetRegionId()
	scope.TargetAgencyOwnerUserId = profile.GetCurrentAgencyOwnerUserId()
	return scope, nil
}

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
	}
	// 房间资料高频读取，头像框/资料卡/座驾/聊天气泡/麦位图标/Voice Wave/当前佩戴徽章
	// 用 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:                 appearance.VIP,
			Level:               levels.Levels,
			LevelBadges:         levels.Badges,
			Badges:              roomMergeBadgeItems(badgeMap[userID], appearance.Badges),
			AvatarFrame:         appearance.AvatarFrame,
			ProfileCard:         appearance.ProfileCard,
			Vehicle:             appearance.Vehicle,
			ChatBubble:          appearance.ChatBubble,
			MicSeatIcon:         appearance.MicSeatIcon,
			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].VIP == nil {
			profile := profiles[userID]
			profile.VIP = 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].ChatBubble == nil {
			// 公屏普通文本只携带发送人 ID；空对象保持稳定 JSON 契约，观众端无需再区分 null/缺字段。
			profile := profiles[userID]
			profile.ChatBubble = 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].MicSeatIcon == nil {
			// icon 与 animation 是两个稳定槽位；任一缺失都返回空对象，客户端不能互相兜底覆盖。
			profile := profiles[userID]
			profile.MicSeatIcon = 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
	ChatBubble       map[string]any
	MicSeatIcon      map[string]any
	MicSeatAnimation map[string]any
	Badges           []map[string]any
	VIP              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 内做过期佩戴过滤，并在同一个有界批次中计算付费/体验卡合并后的
	// VIP 等级与彩色身份权益；房间链路不能再逐用户查询或从体验卡资源反推等级。
	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, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatIcon, roomAppearanceResourceMicSeatAnimation},
		VipBenefitCodes: []string{"colored_nickname", "colored_id", "colored_room_name"},
	})
	if err != nil {
		// 装扮是展示增强，失败不影响房间主数据返回。
		return result
	}
	for _, user := range resp.GetUsers() {
		if user.GetUserId() <= 0 {
			continue
		}
		current := result[user.GetUserId()]
		current.VIP = map[string]any{
			"level":         user.GetEffectiveVipLevel(),
			"active":        user.GetEffectiveVipLevel() > 0,
			"benefit_codes": append([]string(nil), user.GetAllowedVipBenefitCodes()...),
		}
		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 roomAppearanceResourceChatBubble:
				current.ChatBubble = resource
			case roomAppearanceResourceBadge:
				current.Badges = append(current.Badges, roomBadgeItemFromEquippedResource(resource))
			case roomAppearanceResourceMicSeatIcon:
				current.MicSeatIcon = 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{}
	}
	resource := roomAppearanceResourceFromProto(item.GetResource(), item.GetEntitlementId(), item.GetExpiresAtMs())
	if len(resource) > 0 && len(item.GetVipBenefitCodes()) > 0 {
		// VIP 身份标识、VIP 勋章和普通徽章共用 badge 类型；必须透传 wallet 已校验的
		// 资源级 benefit code，Flutter 才能在不猜素材名称的前提下选择正确展示层。
		resource["vip_benefit_codes"] = append([]string(nil), item.GetVipBenefitCodes()...)
	}
	return resource
}

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 format := resourceview.FormatFromMetadata(item.GetMetadataJson()); format != "" {
		// 房间首屏/profile 是其他用户渲染麦位动效的权威快照，因此必须投影显式格式；
		// 缺省时不猜 animation_url 后缀，避免 CDN 地址变化导致客户端选择错误 renderer。
		resource["format"] = format
	}
	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)
}
