package http

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"hyapp/services/gateway-service/internal/transport/http/httpkit"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"net/textproto"
	"strconv"
	"strings"
	"testing"
	"time"

	jwt "github.com/golang-jwt/jwt/v5"
	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"
	"hyapp/pkg/xerr"
	"hyapp/services/gateway-service/internal/appconfig"
	"hyapp/services/gateway-service/internal/auth"
)

// fakeRoomClient 只承接 gateway transport 测试需要观察的 gRPC 入参。
// 测试目标是 HTTP envelope 和 RequestMeta 传递，不验证 room-service 业务行为。
type fakeRoomClient struct {
	lastCreate        *roomv1.CreateRoomRequest
	lastUpdateProfile *roomv1.UpdateRoomProfileRequest
	lastJoin          *roomv1.JoinRoomRequest
	lastHeartbeat     *roomv1.RoomHeartbeatRequest
	lastLeave         *roomv1.LeaveRoomRequest
	lastMicUp         *roomv1.MicUpRequest
	lastMicDown       *roomv1.MicDownRequest
	lastChangeMicSeat *roomv1.ChangeMicSeatRequest
	lastConfirmMic    *roomv1.ConfirmMicPublishingRequest
	lastSetMicMute    *roomv1.SetMicMuteRequest
	lastRTCEvent      *roomv1.ApplyRTCEventRequest
	lastMicSeatLock   *roomv1.SetMicSeatLockRequest
	lastChatEnabled   *roomv1.SetChatEnabledRequest
	lastSetAdmin      *roomv1.SetRoomAdminRequest
	lastMute          *roomv1.MuteUserRequest
	lastKick          *roomv1.KickUserRequest
	lastUnban         *roomv1.UnbanUserRequest
	lastGift          *roomv1.SendGiftRequest
	createErr         error
	updateProfileErr  error
	joinErr           error
	joinResp          *roomv1.JoinRoomResponse
}

func (f *fakeRoomClient) CreateRoom(_ context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
	f.lastCreate = req
	if f.createErr != nil {
		return nil, f.createErr
	}

	return &roomv1.CreateRoomResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 1},
	}, nil
}

func (f *fakeRoomClient) UpdateRoomProfile(_ context.Context, req *roomv1.UpdateRoomProfileRequest) (*roomv1.UpdateRoomProfileResponse, error) {
	f.lastUpdateProfile = req
	if f.updateProfileErr != nil {
		return nil, f.updateProfileErr
	}

	return &roomv1.UpdateRoomProfileResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3, ServerTimeMs: 1_700_000_000_002},
		Room: &roomv1.RoomSnapshot{
			RoomId:      req.GetMeta().GetRoomId(),
			OwnerUserId: req.GetMeta().GetActorUserId(),
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}, {SeatNo: 3}},
			RoomExt: map[string]string{
				"title":       req.GetRoomName(),
				"cover_url":   req.GetRoomAvatar(),
				"description": req.GetRoomDescription(),
			},
			Version: 3,
		},
	}, nil
}

func (f *fakeRoomClient) JoinRoom(_ context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
	f.lastJoin = req
	if f.joinErr != nil {
		return nil, f.joinErr
	}
	if f.joinResp != nil {
		return f.joinResp, nil
	}
	userID := req.GetMeta().GetActorUserId()
	role := req.GetRole()
	if role == "" {
		role = "audience"
	}
	return &roomv1.JoinRoomResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2, ServerTimeMs: 1_700_000_000_001},
		User:   &roomv1.RoomUser{UserId: userID, Role: role, JoinedAtMs: 1_700_000_000_000, LastSeenAtMs: 1_700_000_000_000},
		Room: &roomv1.RoomSnapshot{
			RoomId:      req.GetMeta().GetRoomId(),
			OwnerUserId: 1001,
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2, UserId: 1002, PublishState: "publishing", MicSessionId: "mic-1002"}},
			OnlineUsers: []*roomv1.RoomUser{{UserId: userID, Role: role}, {UserId: 1002, Role: "admin"}},
			GiftRank:    []*roomv1.RankItem{{UserId: 1002, Score: 200, GiftValue: 200}},
			RoomExt:     map[string]string{"title": "Room", "cover_url": "https://cdn.example/room.png", "description": "welcome", "room_short_id": "100001"},
			Heat:        99,
			Version:     2,
			RoomShortId: "100001",
		},
	}, nil
}

func (f *fakeRoomClient) RoomHeartbeat(_ context.Context, req *roomv1.RoomHeartbeatRequest) (*roomv1.RoomHeartbeatResponse, error) {
	f.lastHeartbeat = req
	return &roomv1.RoomHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 2}}, nil
}

func (f *fakeRoomClient) LeaveRoom(_ context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
	f.lastLeave = req
	return &roomv1.LeaveRoomResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 3}}, nil
}

func (f *fakeRoomClient) MicUp(_ context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
	f.lastMicUp = req
	return &roomv1.MicUpResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4}}, nil
}

func (f *fakeRoomClient) MicDown(_ context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error) {
	f.lastMicDown = req
	return &roomv1.MicDownResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 5}}, nil
}

func (f *fakeRoomClient) ChangeMicSeat(_ context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error) {
	f.lastChangeMicSeat = req
	return &roomv1.ChangeMicSeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 6}}, nil
}

func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) {
	f.lastConfirmMic = req
	return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
}

func (f *fakeRoomClient) SetMicMute(_ context.Context, req *roomv1.SetMicMuteRequest) (*roomv1.SetMicMuteResponse, error) {
	f.lastSetMicMute = req
	return &roomv1.SetMicMuteResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
}

func (f *fakeRoomClient) ApplyRTCEvent(_ context.Context, req *roomv1.ApplyRTCEventRequest) (*roomv1.ApplyRTCEventResponse, error) {
	f.lastRTCEvent = req
	return &roomv1.ApplyRTCEventResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
}

func (f *fakeRoomClient) SetMicSeatLock(_ context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
	f.lastMicSeatLock = req
	return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
}

func (f *fakeRoomClient) SetChatEnabled(_ context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) {
	f.lastChatEnabled = req
	return &roomv1.SetChatEnabledResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
}

func (f *fakeRoomClient) SetRoomAdmin(_ context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
	f.lastSetAdmin = req
	return &roomv1.SetRoomAdminResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil
}

func (f *fakeRoomClient) MuteUser(_ context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
	f.lastMute = req
	return &roomv1.MuteUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 11}}, nil
}

func (f *fakeRoomClient) KickUser(_ context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) {
	f.lastKick = req
	return &roomv1.KickUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 12}}, nil
}

func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) {
	f.lastUnban = req
	return &roomv1.UnbanUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 13}}, nil
}

func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
	f.lastGift = req
	return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
}

type fakeUserAuthClient struct {
	lastLoginThirdParty *userv1.LoginThirdPartyRequest
	lastLoginPassword   *userv1.LoginPasswordRequest
	lastSetPassword     *userv1.SetPasswordRequest
	lastRefresh         *userv1.RefreshTokenRequest
	lastLogout          *userv1.LogoutRequest
	lastBlocked         *userv1.RecordLoginBlockedRequest
	loginPasswordCount  int
	loginThirdCount     int
	refreshCount        int
	logoutCount         int
	blockedCount        int
	loginErr            error
}

type fakeUserProfileClient struct {
	lastGet            *userv1.GetUserRequest
	lastBusinessLookup *userv1.BusinessUserLookupRequest
	lastStats          *userv1.GetMyProfileStatsRequest
	lastBatch          *userv1.BatchGetUsersRequest
	getRequests        []*userv1.GetUserRequest
	lastComplete       *userv1.CompleteOnboardingRequest
	lastUpdate         *userv1.UpdateUserProfileRequest
	lastCountry        *userv1.ChangeUserCountryRequest
	getErr             error
	statsResp          *userv1.GetMyProfileStatsResponse
	businessLookupResp *userv1.BusinessUserLookupResponse
	businessLookupErr  error
	statsErr           error
	completeErr        error
	countryErr         error
	regionID           int64
	regionByUserID     map[int64]int64
}

type fakeUserIdentityClient struct {
	lastResolve      *userv1.ResolveDisplayUserIDRequest
	resolveByDisplay map[string]int64
	resolveErr       error
}

type fakeRoomQueryClient struct {
	lastList     *roomv1.ListRoomsRequest
	lastFeeds    *roomv1.ListRoomFeedsRequest
	lastMyRoom   *roomv1.GetMyRoomRequest
	lastCurrent  *roomv1.GetCurrentRoomRequest
	lastSnapshot *roomv1.GetRoomSnapshotRequest
	lastOnline   *roomv1.ListRoomOnlineUsersRequest
	resp         *roomv1.ListRoomsResponse
	feedsResp    *roomv1.ListRoomsResponse
	myRoomResp   *roomv1.GetMyRoomResponse
	currentResp  *roomv1.GetCurrentRoomResponse
	snapshotResp *roomv1.GetRoomSnapshotResponse
	onlineResp   *roomv1.ListRoomOnlineUsersResponse
	err          error
	feedsErr     error
	myRoomErr    error
	currentErr   error
	snapshotErr  error
	onlineErr    error
}

type fakeUserDeviceClient struct {
	lastBind   *userv1.BindPushTokenRequest
	lastDelete *userv1.DeletePushTokenRequest
	bindErr    error
	deleteErr  error
}

type fakeUserCountryQueryClient struct {
	last *userv1.ListRegistrationCountriesRequest
	resp *userv1.ListRegistrationCountriesResponse
	err  error
}

type fakeUserHostClient struct {
	last            *userv1.GetCoinSellerProfileRequest
	profile         *userv1.CoinSellerProfile
	err             error
	lastHost        *userv1.GetHostProfileRequest
	hostProfile     *userv1.HostProfile
	hostErr         error
	lastBD          *userv1.GetBDProfileRequest
	bdProfile       *userv1.BDProfile
	bdErr           error
	lastRoleSummary *userv1.GetUserRoleSummaryRequest
	roleSummary     *userv1.UserRoleSummary
	roleSummaryErr  error
	lastCapability  *userv1.CheckBusinessCapabilityRequest
	capabilityResp  *userv1.CheckBusinessCapabilityResponse
	capabilityErr   error
}

type fakeWalletClient struct {
	last                 *walletv1.GetBalancesRequest
	resp                 *walletv1.GetBalancesResponse
	err                  error
	lastOverview         *walletv1.GetWalletOverviewRequest
	overviewResp         *walletv1.GetWalletOverviewResponse
	overviewErr          error
	lastValueSummary     *walletv1.GetWalletValueSummaryRequest
	valueSummaryResp     *walletv1.GetWalletValueSummaryResponse
	valueSummaryErr      error
	lastRechargeProducts *walletv1.ListRechargeProductsRequest
	rechargeProductsResp *walletv1.ListRechargeProductsResponse
	lastDiamondExchange  *walletv1.GetDiamondExchangeConfigRequest
	diamondExchangeResp  *walletv1.GetDiamondExchangeConfigResponse
	lastTransactions     *walletv1.ListWalletTransactionsRequest
	transactionsResp     *walletv1.ListWalletTransactionsResponse
	lastWithdrawal       *walletv1.ApplyWithdrawalRequest
	withdrawalResp       *walletv1.ApplyWithdrawalResponse
	lastVipPackages      *walletv1.ListVipPackagesRequest
	vipPackagesResp      *walletv1.ListVipPackagesResponse
	lastMyVip            *walletv1.GetMyVipRequest
	myVipResp            *walletv1.GetMyVipResponse
	vipErr               error
	lastPurchaseVip      *walletv1.PurchaseVipRequest
	purchaseVipResp      *walletv1.PurchaseVipResponse
	lastTransfer         *walletv1.TransferCoinFromSellerRequest
	transferResp         *walletv1.TransferCoinFromSellerResponse
	transferErr          error
	lastListResources    *walletv1.ListResourcesRequest
	listResourcesResp    *walletv1.ListResourcesResponse
	lastGrantResource    *walletv1.GrantResourceRequest
	grantResourceResp    *walletv1.ResourceGrantResponse
	grantResourceErr     error
}

type fakeMessageInboxClient struct {
	lastTabs    *activityv1.ListMessageTabsRequest
	lastList    *activityv1.ListInboxMessagesRequest
	lastRead    *activityv1.MarkInboxMessageReadRequest
	lastReadAll *activityv1.MarkInboxSectionReadRequest
	lastDelete  *activityv1.DeleteInboxMessageRequest
	tabsResp    *activityv1.ListMessageTabsResponse
	listResp    *activityv1.ListInboxMessagesResponse
	readResp    *activityv1.MarkInboxMessageReadResponse
	readAllResp *activityv1.MarkInboxSectionReadResponse
	deleteResp  *activityv1.DeleteInboxMessageResponse
	err         error
}

type fakeTaskClient struct {
	lastList  *activityv1.ListUserTasksRequest
	lastClaim *activityv1.ClaimTaskRewardRequest
	listResp  *activityv1.ListUserTasksResponse
	claimResp *activityv1.ClaimTaskRewardResponse
	err       error
}

type fakeBroadcastClient struct {
	lastRemove *activityv1.RemoveRegionBroadcastMemberRequest
	err        error
}

func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
	f.lastLoginPassword = req
	f.loginPasswordCount++
	if f.loginErr != nil {
		return nil, f.loginErr
	}

	return &userv1.AuthResponse{Token: &userv1.AuthToken{UserId: 10001}}, nil
}

func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) {
	f.lastLoginThirdParty = req
	f.loginThirdCount++
	return &userv1.AuthResponse{
		Token: &userv1.AuthToken{
			UserId:           10001,
			DisplayUserId:    "100001",
			SessionId:        "sess-1",
			AccessToken:      "access-1",
			RefreshToken:     "refresh-1",
			ExpiresInSec:     1800,
			TokenType:        "Bearer",
			ProfileCompleted: false,
			OnboardingStatus: "profile_required",
		},
		IsNewUser:        true,
		ProfileCompleted: false,
		OnboardingStatus: "profile_required",
	}, nil
}

func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
	f.lastSetPassword = req
	return &userv1.SetPasswordResponse{PasswordSet: true}, nil
}

func (f *fakeUserAuthClient) RefreshToken(_ context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) {
	f.lastRefresh = req
	f.refreshCount++
	return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed"}}, nil
}

func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error) {
	f.lastLogout = req
	f.logoutCount++
	return &userv1.LogoutResponse{Revoked: true}, nil
}

func (f *fakeUserAuthClient) RecordLoginBlocked(_ context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error) {
	f.lastBlocked = req
	f.blockedCount++
	return &userv1.RecordLoginBlockedResponse{Recorded: true}, nil
}

func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
	f.lastGet = req
	f.getRequests = append(f.getRequests, req)
	if f.getErr != nil {
		return nil, f.getErr
	}
	regionID := f.regionID
	if f.regionByUserID != nil {
		if mapped := f.regionByUserID[req.GetUserId()]; mapped != 0 {
			regionID = mapped
		}
	}
	if regionID == 0 {
		// 测试默认区域固定为 1001，避免新增入口在未配置 fake region 时误走 GLOBAL 桶。
		regionID = 1001
	}
	return &userv1.GetUserResponse{User: &userv1.User{
		UserId:           req.GetUserId(),
		Status:           userv1.UserStatus_USER_STATUS_ACTIVE,
		DisplayUserId:    "100001",
		Username:         "hy",
		Gender:           "female",
		Avatar:           "https://cdn.example/avatar.png",
		Birth:            "2000-01-02",
		RegionId:         regionID,
		ProfileCompleted: true,
		OnboardingStatus: "completed",
		Invite: &userv1.InviteOverview{
			MyInviteCode:             "A1B2C3",
			InviteEnabled:            true,
			InviteCount:              12,
			ValidInviteCount:         3,
			ValidInviteThresholdCoin: 80000,
		},
	}}, nil
}

func (f *fakeUserProfileClient) BusinessUserLookup(_ context.Context, req *userv1.BusinessUserLookupRequest) (*userv1.BusinessUserLookupResponse, error) {
	f.lastBusinessLookup = req
	if f.businessLookupErr != nil {
		return nil, f.businessLookupErr
	}
	if f.businessLookupResp != nil {
		return f.businessLookupResp, nil
	}
	return &userv1.BusinessUserLookupResponse{}, nil
}

func (f *fakeUserProfileClient) GetMyProfileStats(_ context.Context, req *userv1.GetMyProfileStatsRequest) (*userv1.GetMyProfileStatsResponse, error) {
	f.lastStats = req
	if f.statsErr != nil {
		return nil, f.statsErr
	}
	if f.statsResp != nil {
		return f.statsResp, nil
	}
	return &userv1.GetMyProfileStatsResponse{Stats: &userv1.UserProfileStats{UserId: req.GetUserId()}}, nil
}

func (f *fakeUserProfileClient) BatchGetUsers(_ context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
	f.lastBatch = req
	users := make(map[int64]*userv1.User, len(req.GetUserIds()))
	for _, userID := range req.GetUserIds() {
		users[userID] = &userv1.User{
			UserId:        userID,
			DisplayUserId: strconv.FormatInt(100000+userID, 10),
			Username:      "user-" + strconv.FormatInt(userID, 10),
			Gender:        "female",
			Country:       "US",
			Avatar:        "https://cdn.example/avatar.png",
			RegionId:      1001,
			AppCode:       req.GetMeta().GetAppCode(),
		}
	}
	return &userv1.BatchGetUsersResponse{Users: users}, nil
}

func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
	f.lastComplete = req
	if f.completeErr != nil {
		return nil, f.completeErr
	}
	return &userv1.CompleteOnboardingResponse{User: &userv1.User{
		UserId:               req.GetUserId(),
		DisplayUserId:        "100001",
		Username:             req.GetUsername(),
		Avatar:               req.GetAvatar(),
		Country:              req.GetCountry(),
		RegionId:             1001,
		ProfileCompleted:     true,
		ProfileCompletedAtMs: 4000,
		OnboardingStatus:     "completed",
		UpdatedAtMs:          4000,
	}, ProfileCompleted: true, ProfileCompletedAtMs: 4000, OnboardingStatus: "completed", Token: &userv1.AuthToken{
		UserId:           req.GetUserId(),
		SessionId:        req.GetMeta().GetSessionId(),
		AccessToken:      "access-completed",
		ExpiresInSec:     1800,
		TokenType:        "Bearer",
		DisplayUserId:    "100001",
		ProfileCompleted: true,
		OnboardingStatus: "completed",
	}, Invite: &userv1.InviteBinding{
		Bound:         req.GetInviteCode() != "",
		InviteCode:    req.GetInviteCode(),
		InviterUserId: 10001,
	}}, nil
}

func (f *fakeUserProfileClient) UpdateUserProfile(_ context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
	f.lastUpdate = req
	return &userv1.UpdateUserProfileResponse{User: &userv1.User{
		UserId:        req.GetUserId(),
		DisplayUserId: "100001",
		Username:      valueOfString(req.Username),
		Avatar:        valueOfString(req.Avatar),
		Gender:        valueOfString(req.Gender),
		Birth:         valueOfString(req.Birth),
		UpdatedAtMs:   2000,
	}}, nil
}

func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
	f.lastCountry = req
	if f.countryErr != nil {
		return nil, f.countryErr
	}
	return &userv1.ChangeUserCountryResponse{User: &userv1.User{
		UserId:        req.GetUserId(),
		DisplayUserId: "100001",
		Country:       req.GetCountry(),
		RegionId:      2002,
		UpdatedAtMs:   3000,
	}, NextChangeAllowedAtMs: 3600000, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil
}

func (f *fakeUserIdentityClient) GetUserIdentity(_ context.Context, req *userv1.GetUserIdentityRequest) (*userv1.GetUserIdentityResponse, error) {
	return &userv1.GetUserIdentityResponse{Identity: &userv1.UserIdentity{
		UserId:        req.GetUserId(),
		DisplayUserId: "100001",
		Status:        "active",
		AppCode:       req.GetMeta().GetAppCode(),
	}}, nil
}

func (f *fakeUserIdentityClient) ResolveDisplayUserID(_ context.Context, req *userv1.ResolveDisplayUserIDRequest) (*userv1.ResolveDisplayUserIDResponse, error) {
	f.lastResolve = req
	if f.resolveErr != nil {
		return nil, f.resolveErr
	}
	userID := int64(0)
	if f.resolveByDisplay != nil {
		userID = f.resolveByDisplay[req.GetDisplayUserId()]
	}
	return &userv1.ResolveDisplayUserIDResponse{Identity: &userv1.UserIdentity{
		UserId:        userID,
		DisplayUserId: req.GetDisplayUserId(),
		Status:        "active",
		AppCode:       req.GetMeta().GetAppCode(),
	}}, nil
}

func (f *fakeUserIdentityClient) ChangeDisplayUserID(_ context.Context, req *userv1.ChangeDisplayUserIDRequest) (*userv1.ChangeDisplayUserIDResponse, error) {
	return &userv1.ChangeDisplayUserIDResponse{Identity: &userv1.UserIdentity{
		UserId:        req.GetUserId(),
		DisplayUserId: req.GetNewDisplayUserId(),
		Status:        "active",
		AppCode:       req.GetMeta().GetAppCode(),
	}}, nil
}

func (f *fakeUserIdentityClient) ApplyPrettyDisplayUserID(_ context.Context, req *userv1.ApplyPrettyDisplayUserIDRequest) (*userv1.ApplyPrettyDisplayUserIDResponse, error) {
	return &userv1.ApplyPrettyDisplayUserIDResponse{Identity: &userv1.UserIdentity{
		UserId:        req.GetUserId(),
		DisplayUserId: req.GetPrettyDisplayUserId(),
		Status:        "active",
		AppCode:       req.GetMeta().GetAppCode(),
	}}, nil
}

func (f *fakeRoomQueryClient) ListRooms(_ context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
	f.lastList = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resp != nil {
		return f.resp, nil
	}
	return &roomv1.ListRoomsResponse{}, nil
}

func (f *fakeRoomQueryClient) ListRoomFeeds(_ context.Context, req *roomv1.ListRoomFeedsRequest) (*roomv1.ListRoomsResponse, error) {
	f.lastFeeds = req
	if f.feedsErr != nil {
		return nil, f.feedsErr
	}
	if f.feedsResp != nil {
		return f.feedsResp, nil
	}
	if f.resp != nil {
		return f.resp, nil
	}
	return &roomv1.ListRoomsResponse{}, nil
}

func (f *fakeRoomQueryClient) GetMyRoom(_ context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) {
	f.lastMyRoom = req
	if f.myRoomErr != nil {
		return nil, f.myRoomErr
	}
	if f.myRoomResp != nil {
		return f.myRoomResp, nil
	}
	return &roomv1.GetMyRoomResponse{}, nil
}

func (f *fakeRoomQueryClient) GetCurrentRoom(_ context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) {
	f.lastCurrent = req
	if f.currentErr != nil {
		return nil, f.currentErr
	}
	if f.currentResp != nil {
		return f.currentResp, nil
	}
	return &roomv1.GetCurrentRoomResponse{}, nil
}

func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) {
	f.lastSnapshot = req
	if f.snapshotErr != nil {
		return nil, f.snapshotErr
	}
	if f.snapshotResp != nil {
		return f.snapshotResp, nil
	}
	return &roomv1.GetRoomSnapshotResponse{}, nil
}

func (f *fakeRoomQueryClient) ListRoomOnlineUsers(_ context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
	f.lastOnline = req
	if f.onlineErr != nil {
		return nil, f.onlineErr
	}
	if f.onlineResp != nil {
		return f.onlineResp, nil
	}
	return &roomv1.ListRoomOnlineUsersResponse{Page: req.GetPage(), PageSize: req.GetPageSize()}, nil
}

func (f *fakeUserDeviceClient) BindPushToken(_ context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
	f.lastBind = req
	if f.bindErr != nil {
		return nil, f.bindErr
	}
	return &userv1.BindPushTokenResponse{Bound: true, UpdatedAtMs: 1234}, nil
}

func (f *fakeUserDeviceClient) DeletePushToken(_ context.Context, req *userv1.DeletePushTokenRequest) (*userv1.DeletePushTokenResponse, error) {
	f.lastDelete = req
	if f.deleteErr != nil {
		return nil, f.deleteErr
	}
	return &userv1.DeletePushTokenResponse{Deleted: true, UpdatedAtMs: 1235}, nil
}

func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
	f.last = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resp != nil {
		return f.resp, nil
	}

	return &userv1.ListRegistrationCountriesResponse{}, nil
}

func (f *fakeMessageInboxClient) ListMessageTabs(_ context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
	f.lastTabs = req
	if f.err != nil {
		return nil, f.err
	}
	if f.tabsResp != nil {
		return f.tabsResp, nil
	}
	return &activityv1.ListMessageTabsResponse{}, nil
}

func (f *fakeMessageInboxClient) ListInboxMessages(_ context.Context, req *activityv1.ListInboxMessagesRequest) (*activityv1.ListInboxMessagesResponse, error) {
	f.lastList = req
	if f.err != nil {
		return nil, f.err
	}
	if f.listResp != nil {
		return f.listResp, nil
	}
	return &activityv1.ListInboxMessagesResponse{Section: req.GetSection()}, nil
}

func (f *fakeMessageInboxClient) MarkInboxMessageRead(_ context.Context, req *activityv1.MarkInboxMessageReadRequest) (*activityv1.MarkInboxMessageReadResponse, error) {
	f.lastRead = req
	if f.err != nil {
		return nil, f.err
	}
	if f.readResp != nil {
		return f.readResp, nil
	}
	return &activityv1.MarkInboxMessageReadResponse{MessageId: req.GetMessageId(), Read: true, ReadAtMs: 1234}, nil
}

func (f *fakeMessageInboxClient) MarkInboxSectionRead(_ context.Context, req *activityv1.MarkInboxSectionReadRequest) (*activityv1.MarkInboxSectionReadResponse, error) {
	f.lastReadAll = req
	if f.err != nil {
		return nil, f.err
	}
	if f.readAllResp != nil {
		return f.readAllResp, nil
	}
	return &activityv1.MarkInboxSectionReadResponse{Section: req.GetSection(), ReadCount: 2}, nil
}

func (f *fakeMessageInboxClient) DeleteInboxMessage(_ context.Context, req *activityv1.DeleteInboxMessageRequest) (*activityv1.DeleteInboxMessageResponse, error) {
	f.lastDelete = req
	if f.err != nil {
		return nil, f.err
	}
	if f.deleteResp != nil {
		return f.deleteResp, nil
	}
	return &activityv1.DeleteInboxMessageResponse{MessageId: req.GetMessageId(), Deleted: true}, nil
}

func (f *fakeTaskClient) ListUserTasks(_ context.Context, req *activityv1.ListUserTasksRequest) (*activityv1.ListUserTasksResponse, error) {
	f.lastList = req
	if f.err != nil {
		return nil, f.err
	}
	if f.listResp != nil {
		return f.listResp, nil
	}
	return &activityv1.ListUserTasksResponse{}, nil
}

func (f *fakeTaskClient) ClaimTaskReward(_ context.Context, req *activityv1.ClaimTaskRewardRequest) (*activityv1.ClaimTaskRewardResponse, error) {
	f.lastClaim = req
	if f.err != nil {
		return nil, f.err
	}
	if f.claimResp != nil {
		return f.claimResp, nil
	}
	return &activityv1.ClaimTaskRewardResponse{}, nil
}

func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req *activityv1.RemoveRegionBroadcastMemberRequest) (*activityv1.RemoveRegionBroadcastMemberResponse, error) {
	f.lastRemove = req
	if f.err != nil {
		return nil, f.err
	}
	return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: "hy_lalu_bc_r_1001", Removed: true}, nil
}

func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
	f.lastHost = req
	if f.hostErr != nil {
		return nil, f.hostErr
	}
	if f.hostProfile != nil {
		return &userv1.GetHostProfileResponse{HostProfile: f.hostProfile}, nil
	}

	return &userv1.GetHostProfileResponse{}, nil
}

func (f *fakeUserHostClient) GetBDProfile(_ context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) {
	f.lastBD = req
	if f.bdErr != nil {
		return nil, f.bdErr
	}
	if f.bdProfile != nil {
		return &userv1.GetBDProfileResponse{BdProfile: f.bdProfile}, nil
	}

	return &userv1.GetBDProfileResponse{}, nil
}

func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error) {
	f.last = req
	if f.err != nil {
		return nil, f.err
	}
	if f.profile != nil {
		return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: f.profile}, nil
	}

	return &userv1.GetCoinSellerProfileResponse{}, nil
}

func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
	f.lastRoleSummary = req
	if f.roleSummaryErr != nil {
		return nil, f.roleSummaryErr
	}
	if f.roleSummary != nil {
		return &userv1.GetUserRoleSummaryResponse{Summary: f.roleSummary}, nil
	}
	return &userv1.GetUserRoleSummaryResponse{Summary: &userv1.UserRoleSummary{UserId: req.GetUserId()}}, nil
}

func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) {
	f.lastCapability = req
	if f.capabilityErr != nil {
		return nil, f.capabilityErr
	}
	if f.capabilityResp != nil {
		return f.capabilityResp, nil
	}
	return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
}

func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	f.last = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resp != nil {
		return f.resp, nil
	}

	return &walletv1.GetBalancesResponse{}, nil
}

func (f *fakeWalletClient) GetWalletOverview(_ context.Context, req *walletv1.GetWalletOverviewRequest) (*walletv1.GetWalletOverviewResponse, error) {
	f.lastOverview = req
	if f.overviewErr != nil {
		return nil, f.overviewErr
	}
	if f.overviewResp != nil {
		return f.overviewResp, nil
	}
	if f.err != nil {
		return nil, f.err
	}
	balances := []*walletv1.AssetBalance(nil)
	if f.resp != nil {
		balances = f.resp.GetBalances()
	}
	return &walletv1.GetWalletOverviewResponse{
		Balances: balances,
		FeatureFlags: &walletv1.WalletFeatureFlags{
			RechargeEnabled:        true,
			DiamondExchangeEnabled: true,
			WithdrawEnabled:        true,
		},
	}, nil
}

func (f *fakeWalletClient) GetWalletValueSummary(_ context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error) {
	f.lastValueSummary = req
	if f.valueSummaryErr != nil {
		return nil, f.valueSummaryErr
	}
	if f.valueSummaryResp != nil {
		return f.valueSummaryResp, nil
	}
	if f.err != nil {
		return nil, f.err
	}
	var coinAmount int64
	if f.resp != nil {
		for _, balance := range f.resp.GetBalances() {
			if balance.GetAssetType() == "COIN" {
				coinAmount = balance.GetAvailableAmount()
				break
			}
		}
	}
	return &walletv1.GetWalletValueSummaryResponse{Summary: &walletv1.WalletValueSummary{CoinAmount: coinAmount}}, nil
}

func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error) {
	f.lastRechargeProducts = req
	if f.err != nil {
		return nil, f.err
	}
	if f.rechargeProductsResp != nil {
		return f.rechargeProductsResp, nil
	}
	return &walletv1.ListRechargeProductsResponse{}, nil
}

func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
	f.lastDiamondExchange = req
	if f.err != nil {
		return nil, f.err
	}
	if f.diamondExchangeResp != nil {
		return f.diamondExchangeResp, nil
	}
	return &walletv1.GetDiamondExchangeConfigResponse{}, nil
}

func (f *fakeWalletClient) ListWalletTransactions(_ context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
	f.lastTransactions = req
	if f.err != nil {
		return nil, f.err
	}
	if f.transactionsResp != nil {
		return f.transactionsResp, nil
	}
	return &walletv1.ListWalletTransactionsResponse{}, nil
}

func (f *fakeWalletClient) ApplyWithdrawal(_ context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error) {
	f.lastWithdrawal = req
	if f.err != nil {
		return nil, f.err
	}
	if f.withdrawalResp != nil {
		return f.withdrawalResp, nil
	}
	return &walletv1.ApplyWithdrawalResponse{}, nil
}

func (f *fakeWalletClient) ListVipPackages(_ context.Context, req *walletv1.ListVipPackagesRequest) (*walletv1.ListVipPackagesResponse, error) {
	f.lastVipPackages = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.vipPackagesResp != nil {
		return f.vipPackagesResp, nil
	}
	return &walletv1.ListVipPackagesResponse{}, nil
}

func (f *fakeWalletClient) GetMyVip(_ context.Context, req *walletv1.GetMyVipRequest) (*walletv1.GetMyVipResponse, error) {
	f.lastMyVip = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.myVipResp != nil {
		return f.myVipResp, nil
	}
	return &walletv1.GetMyVipResponse{Vip: &walletv1.UserVip{UserId: req.GetUserId()}}, nil
}

func (f *fakeWalletClient) PurchaseVip(_ context.Context, req *walletv1.PurchaseVipRequest) (*walletv1.PurchaseVipResponse, error) {
	f.lastPurchaseVip = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.purchaseVipResp != nil {
		return f.purchaseVipResp, nil
	}
	return &walletv1.PurchaseVipResponse{}, nil
}

func (f *fakeWalletClient) TransferCoinFromSeller(_ context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) {
	f.lastTransfer = req
	if f.transferErr != nil {
		return nil, f.transferErr
	}
	if f.transferResp != nil {
		return f.transferResp, nil
	}

	return &walletv1.TransferCoinFromSellerResponse{}, nil
}

func (f *fakeWalletClient) ListResources(_ context.Context, req *walletv1.ListResourcesRequest) (*walletv1.ListResourcesResponse, error) {
	f.lastListResources = req
	if f.err != nil {
		return nil, f.err
	}
	if f.listResourcesResp != nil {
		return f.listResourcesResp, nil
	}
	return &walletv1.ListResourcesResponse{}, nil
}

func (f *fakeWalletClient) GetResourceGroup(context.Context, *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {
	return &walletv1.GetResourceGroupResponse{}, nil
}

func (f *fakeWalletClient) ListGiftConfigs(context.Context, *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
	return &walletv1.ListGiftConfigsResponse{}, nil
}

func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) {
	f.lastGrantResource = req
	if f.grantResourceErr != nil {
		return nil, f.grantResourceErr
	}
	if f.grantResourceResp != nil {
		return f.grantResourceResp, nil
	}
	return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-1", CommandId: req.GetCommandId(), TargetUserId: req.GetTargetUserId(), GrantSource: req.GetGrantSource(), GrantSubjectId: strconv.FormatInt(req.GetResourceId(), 10), Status: "succeeded", OperatorUserId: req.GetOperatorUserId()}}, nil
}

func (f *fakeWalletClient) ListUserResources(context.Context, *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
	return &walletv1.ListUserResourcesResponse{}, nil
}

func (f *fakeWalletClient) EquipUserResource(context.Context, *walletv1.EquipUserResourceRequest) (*walletv1.EquipUserResourceResponse, error) {
	return &walletv1.EquipUserResourceResponse{}, nil
}

func valueOfString(value *string) string {
	if value == nil {
		return ""
	}

	return *value
}

// fakeRoomGuardClient 只覆盖腾讯云 IM 回调测试需要的守卫 RPC。
type fakeRoomGuardClient struct {
	speakResp    *roomv1.CheckSpeakPermissionResponse
	presenceResp *roomv1.VerifyRoomPresenceResponse
	lastSpeak    *roomv1.CheckSpeakPermissionRequest
	lastPresence *roomv1.VerifyRoomPresenceRequest
	err          error
}

func (f *fakeRoomGuardClient) CheckSpeakPermission(_ context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) {
	f.lastSpeak = req
	if f.err != nil {
		return nil, f.err
	}
	if f.speakResp != nil {
		return f.speakResp, nil
	}

	return &roomv1.CheckSpeakPermissionResponse{Allowed: true, RoomVersion: 1}, nil
}

func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) {
	f.lastPresence = req
	if f.err != nil {
		return nil, f.err
	}
	if f.presenceResp != nil {
		return f.presenceResp, nil
	}

	return &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}, nil
}

type fakeObjectUploader struct {
	lastKey         string
	lastContent     []byte
	lastSizeBytes   int64
	lastContentType string
	err             error
}

func (f *fakeObjectUploader) PutObject(_ context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error) {
	f.lastKey = key
	f.lastSizeBytes = sizeBytes
	f.lastContentType = contentType
	body, err := io.ReadAll(reader)
	if err != nil {
		return "", err
	}
	f.lastContent = body
	if f.err != nil {
		return "", f.err
	}

	return "https://media.example.com/" + key, nil
}

func TestRoutesWriteUnifiedEnvelopeAndGenerateRequestID(t *testing.T) {
	client := &fakeRoomClient{}
	profileClient := &fakeUserProfileClient{regionID: 1001}
	router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"client-room-ignored","command_id":"client-command-ignored","seat_count":10,"mode":"voice","room_name":"  Lobby  ","room_avatar":" https://cdn.example.com/room.png ","room_description":"  welcome  "}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-test")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	generatedRequestID := recorder.Header().Get("X-Request-ID")
	if generatedRequestID == "" || generatedRequestID == "req-test" {
		t.Fatalf("response request header mismatch: got %q", recorder.Header().Get("X-Request-ID"))
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	if response.Code != httpkit.CodeOK || response.Message != "ok" || response.RequestID != generatedRequestID {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	if client.lastCreate == nil || client.lastCreate.GetMeta().GetRequestId() != generatedRequestID {
		t.Fatalf("request_id was not propagated to room-service meta: %+v", client.lastCreate)
	}
	if client.lastCreate.GetMeta().GetActorUserId() != 42 {
		t.Fatalf("actor user mismatch: got %d", client.lastCreate.GetMeta().GetActorUserId())
	}
	if roomID := client.lastCreate.GetMeta().GetRoomId(); !strings.HasPrefix(roomID, "lalu_") || roomID == "client-room-ignored" {
		t.Fatalf("CreateRoom must generate app-scoped room_id on gateway: %q", roomID)
	}
	if client.lastCreate.GetRoomShortId() != "100001" {
		t.Fatalf("CreateRoom must use owner display_user_id as room_short_id: %+v", client.lastCreate)
	}
	if client.lastCreate.GetMeta().GetCommandId() != "client-command-ignored" || client.lastCreate.GetMeta().GetCommandId() == generatedRequestID {
		t.Fatalf("CreateRoom must use client action command_id and keep it separate from request_id: %+v", client.lastCreate.GetMeta())
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || client.lastCreate.GetVisibleRegionId() != 1001 {
		t.Fatalf("CreateRoom must derive visible_region_id from user-service: profile=%+v create=%+v", profileClient.lastGet, client.lastCreate)
	}
	if client.lastCreate.GetRoomName() != "Lobby" || client.lastCreate.GetRoomAvatar() != "https://cdn.example.com/room.png" || client.lastCreate.GetRoomDescription() != "welcome" {
		t.Fatalf("CreateRoom should trim and forward room profile fields: %+v", client.lastCreate)
	}
}

func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
	tests := []struct {
		name        string
		path        string
		body        string
		commandID   string
		extractMeta func(*fakeRoomClient) *roomv1.RequestMeta
	}{
		{
			name:      "profile_update",
			path:      "/api/v1/rooms/profile/update",
			body:      `{"room_id":"room-1","command_id":"cmd-profile-1","room_name":"New Room","seat_count":20}`,
			commandID: "cmd-profile-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastUpdateProfile.GetMeta()
			},
		},
		{
			name:      "join",
			path:      "/api/v1/rooms/join",
			body:      `{"room_id":"room-1","command_id":"cmd-join-1","role":"audience"}`,
			commandID: "cmd-join-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastJoin.GetMeta()
			},
		},
		{
			name:      "leave",
			path:      "/api/v1/rooms/leave",
			body:      `{"room_id":"room-1","command_id":"cmd-leave-1"}`,
			commandID: "cmd-leave-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastLeave.GetMeta()
			},
		},
		{
			name:      "heartbeat",
			path:      "/api/v1/rooms/heartbeat",
			body:      `{"room_id":"room-1","command_id":"cmd-heartbeat-1"}`,
			commandID: "cmd-heartbeat-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastHeartbeat.GetMeta()
			},
		},
		{
			name:      "mic_up",
			path:      "/api/v1/rooms/mic/up",
			body:      `{"room_id":"room-1","command_id":"cmd-mic-up-1","seat_no":1}`,
			commandID: "cmd-mic-up-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastMicUp.GetMeta()
			},
		},
		{
			name:      "mic_down",
			path:      "/api/v1/rooms/mic/down",
			body:      `{"room_id":"room-1","command_id":"cmd-mic-down-1","target_user_id":43}`,
			commandID: "cmd-mic-down-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastMicDown.GetMeta()
			},
		},
		{
			name:      "change_mic",
			path:      "/api/v1/rooms/mic/change",
			body:      `{"room_id":"room-1","command_id":"cmd-change-mic-1","target_user_id":43,"seat_no":2}`,
			commandID: "cmd-change-mic-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastChangeMicSeat.GetMeta()
			},
		},
		{
			name:      "confirm_mic_publishing",
			path:      "/api/v1/rooms/mic/publishing/confirm",
			body:      `{"room_id":"room-1","command_id":"cmd-confirm-mic-1","mic_session_id":"mic-1","room_version":4,"event_time_ms":1700000001000,"source":"client"}`,
			commandID: "cmd-confirm-mic-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastConfirmMic.GetMeta()
			},
		},
		{
			name:      "mic_lock",
			path:      "/api/v1/rooms/mic/lock",
			body:      `{"room_id":"room-1","command_id":"cmd-mic-lock-1","seat_no":2,"locked":true}`,
			commandID: "cmd-mic-lock-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastMicSeatLock.GetMeta()
			},
		},
		{
			name:      "chat_enabled",
			path:      "/api/v1/rooms/chat/enabled",
			body:      `{"room_id":"room-1","command_id":"cmd-chat-enabled-1","enabled":false}`,
			commandID: "cmd-chat-enabled-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastChatEnabled.GetMeta()
			},
		},
		{
			name:      "set_admin",
			path:      "/api/v1/rooms/admin/set",
			body:      `{"room_id":"room-1","command_id":"cmd-admin-1","target_user_id":43,"enabled":true}`,
			commandID: "cmd-admin-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastSetAdmin.GetMeta()
			},
		},
		{
			name:      "mute",
			path:      "/api/v1/rooms/user/mute",
			body:      `{"room_id":"room-1","command_id":"cmd-mute-1","target_user_id":43,"muted":true}`,
			commandID: "cmd-mute-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastMute.GetMeta()
			},
		},
		{
			name:      "kick",
			path:      "/api/v1/rooms/user/kick",
			body:      `{"room_id":"room-1","command_id":"cmd-kick-1","target_user_id":43}`,
			commandID: "cmd-kick-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastKick.GetMeta()
			},
		},
		{
			name:      "unban",
			path:      "/api/v1/rooms/user/unban",
			body:      `{"room_id":"room-1","command_id":"cmd-unban-1","target_user_id":43}`,
			commandID: "cmd-unban-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastUnban.GetMeta()
			},
		},
		{
			name:      "gift",
			path:      "/api/v1/rooms/gift/send",
			body:      `{"room_id":"room-1","command_id":"cmd-gift-1","target_user_id":43,"gift_id":"rose","gift_count":1}`,
			commandID: "cmd-gift-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastGift.GetMeta()
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			client := &fakeRoomClient{}
			router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-"+test.name)
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			if recorder.Code != http.StatusOK {
				t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
			}

			meta := test.extractMeta(client)
			if meta == nil {
				t.Fatal("room-service meta was not sent")
			}
			if meta.GetCommandId() != test.commandID {
				t.Fatalf("command_id mismatch: got %q meta=%+v", meta.GetCommandId(), meta)
			}
			generatedRequestID := recorder.Header().Get("X-Request-ID")
			if generatedRequestID == "" || generatedRequestID == "req-"+test.name || meta.GetRequestId() != generatedRequestID {
				t.Fatalf("request_id must remain trace-only and unchanged: %+v", meta)
			}
			if meta.GetActorUserId() != 42 {
				t.Fatalf("actor user mismatch: %+v", meta)
			}
		})
	}
}

func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
	previousNow := timeNow
	timeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
	defer func() { timeNow = previousNow }()

	roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7, ServerTimeMs: 1_700_000_000_123},
		User:   &roomv1.RoomUser{UserId: 42, Role: "audience", JoinedAtMs: 1_700_000_000_000, LastSeenAtMs: 1_700_000_000_100},
		Room: &roomv1.RoomSnapshot{
			RoomId:      "room_1001",
			OwnerUserId: 101,
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing", MicSessionId: "mic-102"}, {SeatNo: 2, Locked: true}},
			OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}, {UserId: 102, Role: "admin"}},
			GiftRank:    []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}},
			RoomExt:     map[string]string{"title": "Live Room", "cover_url": "https://cdn.example/cover.png", "description": "welcome"},
			Heat:        88,
			Version:     7,
			RoomShortId: "100101",
		},
	}}
	profileClient := &fakeUserProfileClient{}
	handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
	handler.SetTencentRTC(TencentRTCConfig{
		Enabled:    true,
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: 2 * time.Hour,
		RoomIDType: "string",
		AppScene:   "voice_chat_room",
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_1001","command_id":"cmd-join","role":"audience"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-join-initial")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode join response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	room := data["room"].(map[string]any)
	if room["room_id"] != "room_1001" || room["im_group_id"] != "room_1001" || room["title"] != "Live Room" || room["online_count"].(float64) != 2 {
		t.Fatalf("unexpected initial room data: %+v", room)
	}
	seats := data["seats"].([]any)
	if len(seats) != 2 || seats[0].(map[string]any)["user_id"] != "102" {
		t.Fatalf("join response must include first-screen seats: %+v", seats)
	}
	rank := data["contribution_rank"].([]any)
	if len(rank) != 1 || rank[0].(map[string]any)["user_id"] != "301" {
		t.Fatalf("join response must include first-screen contribution rank: %+v", rank)
	}
	im := data["im"].(map[string]any)
	if im["group_id"] != "room_1001" || im["need_join_group"] != true {
		t.Fatalf("unexpected im block: %+v", im)
	}
	rtc := data["rtc"].(map[string]any)
	token := rtc["token"].(map[string]any)
	if rtc["available"] != true || token["str_room_id"] != "room_1001" || token["rtc_user_id"] != "42" {
		t.Fatalf("join response must embed usable rtc token: rtc=%+v token=%+v", rtc, token)
	}
	if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "101,42,102,301" {
		t.Fatalf("join response must batch only first-screen profile users: %+v", profileClient.lastBatch)
	}
}

func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
		Rooms: []*roomv1.RoomListItem{
			{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90},
		},
		NextCursor: "cursor-2",
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&cursor=cursor-1&q=room&visible_region_id=9999", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-list")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
		t.Fatalf("ListRooms must fetch current user from user-service: %+v", profileClient.lastGet)
	}
	if queryClient.lastList == nil {
		t.Fatal("room query client was not called")
	}
	if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 {
		t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList)
	}
	if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" {
		t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode room list response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	rooms := data["rooms"].([]any)
	first := rooms[0].(map[string]any)
	if first["im_group_id"] != "room-1" {
		t.Fatalf("room list must expose room IM group id: %+v", first)
	}
}

func TestListRoomsRejectsMineFeedTabs(t *testing.T) {
	for _, tab := range []string{"visited", "friend", "following", "me"} {
		t.Run(tab, func(t *testing.T) {
			queryClient := &fakeRoomQueryClient{}
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
			handler.SetRoomQueryClient(queryClient)
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab="+tab+"&limit=3", nil)
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			if recorder.Code != http.StatusBadRequest {
				t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
			}
			if queryClient.lastList != nil || queryClient.lastFeeds != nil {
				t.Fatalf("invalid public list tab must not reach room-service: list=%+v feeds=%+v", queryClient.lastList, queryClient.lastFeeds)
			}
		})
	}
}

func TestListRoomFeedsSupportsMineFeedTabs(t *testing.T) {
	for _, tab := range []string{"visited", "friend", "following"} {
		t.Run(tab, func(t *testing.T) {
			profileClient := &fakeUserProfileClient{regionID: 1001}
			socialClient := &fakeUserSocialClient{}
			queryClient := &fakeRoomQueryClient{feedsResp: &roomv1.ListRoomsResponse{
				Rooms: []*roomv1.RoomListItem{{RoomId: "room-feed", VisibleRegionId: 1001, Heat: 7}},
			}}
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
			handler.SetRoomQueryClient(queryClient)
			if tab != "visited" {
				handler.SetUserSocialClient(socialClient)
			}
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/feeds?tab="+tab+"&limit=3", nil)
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-room-feed-"+tab)
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			if recorder.Code != http.StatusOK {
				t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
			}
			if queryClient.lastFeeds == nil || queryClient.lastFeeds.GetTab() != tab || queryClient.lastFeeds.GetViewerUserId() != 42 || queryClient.lastFeeds.GetVisibleRegionId() != 1001 {
				t.Fatalf("feed tab was not forwarded with authenticated user and region: %+v", queryClient.lastFeeds)
			}
			switch tab {
			case "visited":
				if len(queryClient.lastFeeds.GetRelatedUsers()) != 0 || socialClient.lastFollowing != nil || socialClient.lastFriends != nil {
					t.Fatalf("visited feed must not load social relations: feeds=%+v social=%+v/%+v", queryClient.lastFeeds, socialClient.lastFollowing, socialClient.lastFriends)
				}
			case "following":
				if socialClient.lastFollowing == nil || socialClient.lastFollowing.GetUserId() != 42 {
					t.Fatalf("following feed must load following relation facts: %+v", socialClient.lastFollowing)
				}
				if got := queryClient.lastFeeds.GetRelatedUsers(); len(got) != 1 || got[0].GetUserId() != 10002 || got[0].GetRelationUpdatedAtMs() != 8000 {
					t.Fatalf("following related users mismatch: %+v", got)
				}
			case "friend":
				if socialClient.lastFriends == nil || socialClient.lastFriends.GetUserId() != 42 {
					t.Fatalf("friend feed must load friend relation facts: %+v", socialClient.lastFriends)
				}
				if got := queryClient.lastFeeds.GetRelatedUsers(); len(got) != 1 || got[0].GetUserId() != 10002 || got[0].GetRelationUpdatedAtMs() != 10000 {
					t.Fatalf("friend related users mismatch: %+v", got)
				}
			}
		})
	}
}

func TestListRoomFeedsRelationCursorContinuesUserSocialScan(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	socialClient := &fakeUserSocialClient{}
	queryClient := &fakeRoomQueryClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetRoomQueryClient(queryClient)
	handler.SetUserSocialClient(socialClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	cursorPayload, _ := json.Marshal(roomFeedRelationCursor{
		Tab:           "following",
		Query:         "music",
		UpdatedAtMS:   8000,
		SubjectUserID: 10002,
		RoomID:        "room-feed",
	})
	cursor := base64.RawURLEncoding.EncodeToString(cursorPayload)
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/feeds?tab=following&q=music&cursor="+cursor, nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if socialClient.lastFollowing == nil || socialClient.lastFollowing.GetCursorUpdatedAtMs() != 8000 || socialClient.lastFollowing.GetCursorUserId() != 10002 {
		t.Fatalf("following cursor was not propagated to user-service: %+v", socialClient.lastFollowing)
	}
}

func TestGetMyRoomUsesAuthenticatedOwnerAndReturnsIMGroup(t *testing.T) {
	queryClient := &fakeRoomQueryClient{myRoomResp: &roomv1.GetMyRoomResponse{
		HasRoom: true,
		Room: &roomv1.RoomListItem{
			RoomId:      "room-owner",
			OwnerUserId: 42,
			Title:       "Owner Room",
			Status:      "active",
			SeatCount:   8,
		},
		ServerTimeMs: 1_700_000_000_123,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/me", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-my-room")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if queryClient.lastMyRoom == nil || queryClient.lastMyRoom.GetOwnerUserId() != 42 {
		t.Fatalf("my room must use authenticated owner: %+v", queryClient.lastMyRoom)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode my room response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	if data["has_room"] != true {
		t.Fatalf("my room must preserve has_room=true: %+v", data)
	}
	room := data["room"].(map[string]any)
	if room["room_id"] != "room-owner" || room["im_group_id"] != "room-owner" {
		t.Fatalf("my room must expose room id and im group id: %+v", room)
	}
}

func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) {
	queryClient := &fakeRoomQueryClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?limit=bad", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-list-limit")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-list-limit")
	if queryClient.lastList != nil {
		t.Fatalf("invalid limit must not reach room-service: %+v", queryClient.lastList)
	}
}

func TestBindPushTokenUsesAuthenticatedUserAndDeviceMeta(t *testing.T) {
	deviceClient := &fakeUserDeviceClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserDeviceClient(deviceClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"device_id":"dev-1","push_token":"push-1","provider":"fcm","platform":"android","app_version":"1.2.3","language":"en-US","timezone":"America/Los_Angeles"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/devices/push-token", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-bind-push")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if deviceClient.lastBind == nil || deviceClient.lastBind.GetUserId() != 42 || deviceClient.lastBind.GetMeta().GetDeviceId() != "dev-1" {
		t.Fatalf("push token bind must use authenticated user and device meta: %+v", deviceClient.lastBind)
	}
	if deviceClient.lastBind.GetPushToken() != "push-1" || deviceClient.lastBind.GetPlatform() != "android" || deviceClient.lastBind.GetProvider() != "fcm" {
		t.Fatalf("push token bind fields mismatch: %+v", deviceClient.lastBind)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode bind response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["bound"] != true || data["updated_at_ms"].(float64) != 1234 {
		t.Fatalf("push token response must keep explicit fields: %+v", response.Data)
	}
}

func TestDeletePushTokenUsesAuthenticatedUser(t *testing.T) {
	deviceClient := &fakeUserDeviceClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserDeviceClient(deviceClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"device_id":"dev-1","push_token":"push-1"}`)
	request := httptest.NewRequest(http.MethodDelete, "/api/v1/devices/push-token", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-delete-push")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if deviceClient.lastDelete == nil || deviceClient.lastDelete.GetUserId() != 42 || deviceClient.lastDelete.GetDeviceId() != "dev-1" || deviceClient.lastDelete.GetPushToken() != "push-1" {
		t.Fatalf("push token delete fields mismatch: %+v", deviceClient.lastDelete)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode delete response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["deleted"] != true || data["updated_at_ms"].(float64) != 1235 {
		t.Fatalf("push token delete response must keep explicit fields: %+v", response.Data)
	}
}

func TestGetCurrentRoomUsesAuthenticatedUser(t *testing.T) {
	queryClient := &fakeRoomQueryClient{currentResp: &roomv1.GetCurrentRoomResponse{
		HasCurrentRoom:  true,
		RoomId:          "room-current",
		RoomVersion:     9,
		Role:            "audience",
		NeedJoinImGroup: true,
		ServerTimeMs:    123456,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/current?user_id=999", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-current-room")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if queryClient.lastCurrent == nil {
		t.Fatal("current room query was not sent")
	}
	if queryClient.lastCurrent.GetUserId() != 42 || queryClient.lastCurrent.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-current-room") {
		t.Fatalf("current room must use authenticated user and request meta: %+v", queryClient.lastCurrent)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode current room response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["has_current_room"] != true || data["need_rtc_token"] != false || data["room_id"] != "room-current" {
		t.Fatalf("current room response must keep explicit booleans: %+v", response.Data)
	}
}

func TestGetRoomSnapshotUsesAuthenticatedUser(t *testing.T) {
	queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
		Room: &roomv1.RoomSnapshot{
			RoomId:      "room-snapshot",
			OwnerUserId: 42,
			Status:      "active",
			Version:     7,
			MicSeats: []*roomv1.SeatState{
				{SeatNo: 1, UserId: 42},
				{SeatNo: 2},
			},
			RoomExt: map[string]string{"title": "Snapshot Room"},
		},
		ServerTimeMs: 123456,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/snapshot?room_id=room-snapshot&viewer_user_id=999", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-snapshot")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if queryClient.lastSnapshot == nil {
		t.Fatal("room snapshot query was not sent")
	}
	if queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-snapshot" || queryClient.lastSnapshot.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-room-snapshot") {
		t.Fatalf("snapshot query must use authenticated user and request meta: %+v", queryClient.lastSnapshot)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode snapshot response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if !ok {
		t.Fatalf("snapshot response must be wrapped in app DTO: %+v", response.Data)
	}
	room, ok := data["room"].(map[string]any)
	if !ok || room["seat_count"] != float64(2) || room["title"] != "Snapshot Room" {
		t.Fatalf("snapshot room DTO must include derived room fields: %+v", data["room"])
	}
	seats, ok := data["seats"].([]any)
	if !ok || len(seats) != 2 {
		t.Fatalf("snapshot DTO must include seat list: %+v", data["seats"])
	}
}

func TestGetRoomSnapshotRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
	queryClient := &fakeRoomQueryClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/snapshot?room_id=room:bad", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-snapshot-invalid")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-snapshot-invalid")
	if queryClient.lastSnapshot != nil {
		t.Fatalf("invalid room_id must not reach room-service: %+v", queryClient.lastSnapshot)
	}
}

func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T) {
	countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{
		Countries: []*userv1.Country{
			{
				CountryId:          86,
				CountryName:        "China",
				CountryCode:        "CN",
				IsoAlpha3:          "CHN",
				IsoNumeric:         "156",
				CountryDisplayName: "中国",
				PhoneCountryCode:   "+86",
				Enabled:            true,
				Flag:               "CN",
				SortOrder:          10,
			},
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserCountryQueryClient(countryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/countries", nil)
	request.Header.Set("X-Request-ID", "req-countries")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-countries") {
		t.Fatalf("country query request metadata mismatch: %+v", countryClient.last)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	countries, ok := data["countries"].([]any)
	if !ok || len(countries) != 1 {
		t.Fatalf("countries response shape mismatch: %+v", data)
	}
	country, ok := countries[0].(map[string]any)
	if !ok || country["country_code"] != "CN" || country["iso_numeric"] != "156" || country["phone_country_code"] != "+86" || country["enabled"] != true {
		t.Fatalf("country metadata mismatch: %+v", country)
	}
}

func TestListH5LinksReturnsAdminAppConfig(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAppConfigReader(appconfig.StaticReader{Links: []appconfig.H5Link{
		{Key: "host-center", Label: "Host Center", URL: "https://h5.example.com/host", UpdatedAtMs: 1700000000000},
		{Key: "agency-center", Label: "Agency Center", URL: "https://h5.example.com/agency", UpdatedAtMs: 1700000001000},
	}})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil)
	request.Header.Set("X-Request-ID", "req-h5-links")
	request.Header.Set("X-App-Package", "com.org.laluparty")
	request.Header.Set("X-App-Platform", "android")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	if data["total"].(float64) != 2 {
		t.Fatalf("h5 total mismatch: %+v", data)
	}
	items, ok := data["items"].([]any)
	if !ok || len(items) != 2 {
		t.Fatalf("h5 items shape mismatch: %+v", data)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["key"] != "host-center" || first["url"] != "https://h5.example.com/host" || first["updated_at_ms"].(float64) != 1700000000000 {
		t.Fatalf("h5 item mismatch: %+v", first)
	}
}

func TestListH5LinksRequiresConfigReader(t *testing.T) {
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil)
	request.Header.Set("X-Request-ID", "req-h5-links-missing")
	request.Header.Set("X-App-Package", "com.org.laluparty")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-h5-links-missing")
}

func TestAppBootstrapIsPublicAndLightweight(t *testing.T) {
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/bootstrap", nil)
	request.Header.Set("X-Request-ID", "req-bootstrap")
	request.Header.Set("X-App-Code", "lalu")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	if data["app_code"] != "lalu" || data["server_time_ms"].(float64) <= 0 || data["force_upgrade"] != false || data["maintenance"] != false {
		t.Fatalf("bootstrap base fields mismatch: %+v", data)
	}
	if _, exists := data["banners"]; exists {
		t.Fatalf("bootstrap must not return large config payloads: %+v", data)
	}
}

func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAppConfigReader(appconfig.StaticReader{Banners: []appconfig.Banner{
		{
			ID:          8,
			CoverURL:    "https://cdn.example.com/banner.png",
			BannerType:  "h5",
			Param:       "https://h5.example.com/activity",
			Platform:    "android",
			SortOrder:   10,
			RegionID:    1,
			CountryCode: "CN",
			Description: "homepage",
			UpdatedAtMs: 1700000002000,
		},
	}})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?platform=android&country=CN&region_id=1", nil)
	request.Header.Set("X-Request-ID", "req-app-banners")
	request.Header.Set("X-App-Package", "com.org.laluparty")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	if data["total"].(float64) != 1 {
		t.Fatalf("banner total mismatch: %+v", data)
	}
	items, ok := data["items"].([]any)
	if !ok || len(items) != 1 {
		t.Fatalf("banner items shape mismatch: %+v", data)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["cover_url"] != "https://cdn.example.com/banner.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/activity" {
		t.Fatalf("banner item mismatch: %+v", first)
	}
	if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["updated_at_ms"].(float64) != 1700000002000 {
		t.Fatalf("banner metadata mismatch: %+v", first)
	}
}

func TestGetAppVersionReturnsLatestVersionAndUpdateDecision(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAppConfigReader(appconfig.StaticReader{Version: appconfig.Version{
		AppCode:     "lalu",
		Platform:    "ios",
		Version:     "1.2.0",
		BuildNumber: 120,
		ForceUpdate: true,
		DownloadURL: "https://apps.apple.com/app/lalu",
		Description: "bug fixes",
		UpdatedAtMs: 1700000003000,
	}})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/version?build_number=100", nil)
	request.Header.Set("X-Request-ID", "req-app-version")
	request.Header.Set("X-App-Code", "lalu")
	request.Header.Set("X-App-Platform", "ios")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	if data["platform"] != "ios" ||
		data["version"] != "1.2.0" ||
		data["build_number"] != float64(120) ||
		data["current_build_number"] != float64(100) ||
		data["has_update"] != true ||
		data["force_update"] != true ||
		data["download_url"] != "https://apps.apple.com/app/lalu" {
		t.Fatalf("app version response mismatch: %+v", data)
	}
}

func TestGetMyHostIdentityCombinesActiveHostAndBDProfiles(t *testing.T) {
	hostClient := &fakeUserHostClient{
		hostProfile: &userv1.HostProfile{
			UserId: 42,
			Status: "active",
			Source: "admin_create_agency",
		},
		bdProfile: &userv1.BDProfile{
			UserId: 42,
			Status: "active",
			Role:   "bd_leader",
		},
		profile: &userv1.CoinSellerProfile{
			UserId:            42,
			Status:            "active",
			MerchantAssetType: "COIN_SELLER_COIN",
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-identity")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 || hostClient.lastHost.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-host-identity") {
		t.Fatalf("host identity host request mismatch: %+v", hostClient.lastHost)
	}
	if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 {
		t.Fatalf("host identity bd request mismatch: %+v", hostClient.lastBD)
	}
	if hostClient.last == nil || hostClient.last.GetUserId() != 42 {
		t.Fatalf("host identity coin seller request mismatch: %+v", hostClient.last)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok ||
		data["is_host"] != true ||
		data["is_agent"] != true ||
		data["is_bd"] != true ||
		data["is_bd_leader"] != true ||
		data["is_coin_seller"] != true {
		t.Fatalf("host identity response mismatch: %+v", response)
	}
}

func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) {
	hostClient := &fakeUserHostClient{
		hostErr: xerr.ToGRPCError(xerr.New(xerr.NotFound, "host profile not found")),
		bdErr:   xerr.ToGRPCError(xerr.New(xerr.NotFound, "bd profile not found")),
		err:     xerr.ToGRPCError(xerr.New(xerr.NotFound, "coin seller profile not found")),
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/host-identity", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-identity-empty")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok ||
		data["is_host"] != false ||
		data["is_agent"] != false ||
		data["is_bd"] != false ||
		data["is_bd_leader"] != false ||
		data["is_coin_seller"] != false {
		t.Fatalf("empty host identity response mismatch: %+v", response)
	}
}

func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
	client := &fakeRoomClient{}
	router := NewHandler(client).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-1","command_id":"cmd-confirm","target_user_id":43,"mic_session_id":"mic-session-1","room_version":12,"event_time_ms":1700000001234,"source":"client"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/publishing/confirm", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-confirm-mic")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-confirm-mic")
	if client.lastConfirmMic == nil {
		t.Fatal("ConfirmMicPublishing request was not sent")
	}
	if client.lastConfirmMic.GetTargetUserId() != 43 ||
		client.lastConfirmMic.GetMicSessionId() != "mic-session-1" ||
		client.lastConfirmMic.GetRoomVersion() != 12 ||
		client.lastConfirmMic.GetEventTimeMs() != 1700000001234 ||
		client.lastConfirmMic.GetSource() != "client" {
		t.Fatalf("confirm fields mismatch: %+v", client.lastConfirmMic)
	}
}

func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","username":"hy","gender":"male","country":"CN","invite_code":"INV-1","device_id":"dev-1","device":"iPhone 15","os_version":"iOS 18.1","avatar":"https://cdn.example/avatar.png","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-auth-third")
	request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1")
	request.Header.Set("User-Agent", "hyapp-test/1.0")
	request.Header.Set("CF-IPCountry", "sg")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	generatedRequestID := assertGeneratedRequestID(t, recorder, "req-auth-third")
	if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	req := userClient.lastLoginThirdParty
	if req == nil {
		t.Fatal("LoginThirdParty request was not sent")
	}
	if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" {
		t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
	}
	if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" {
		t.Fatalf("third-party registration fields were not propagated: %+v", req)
	}
	if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" {
		t.Fatalf("Firebase provider and ID token credential were not propagated: %+v", req)
	}
	if req.GetDevice() != "iPhone 15" || req.GetOsVersion() != "iOS 18.1" || req.GetAvatar() == "" || req.GetBirth() != "2000-01-02" || req.GetAppVersion() != "1.2.3" || req.GetBuildNumber() != "123" {
		t.Fatalf("third-party device/profile fields were not propagated: %+v", req)
	}
	if req.GetSource() != "campaign-a" || req.GetInstallChannel() != "app_store" || req.GetCampaign() != "spring_launch" || req.GetPlatform() != "ios" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" {
		t.Fatalf("third-party profile fields were not propagated: %+v", req)
	}
}

func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	handler := NewHandler(&fakeRoomClient{}, userClient)
	handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
		config.ProviderIPLimit = 1
	}))
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","platform":"ios"}`)

	for i := range 2 {
		request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
		request.Header.Set("X-Request-ID", "req-third-rate")
		request.Header.Set("X-Forwarded-For", "203.0.113.10")
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if i == 0 && recorder.Code != http.StatusOK {
			t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
		}
		if i == 1 {
			assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-third-rate")
		}
	}
	if userClient.loginThirdCount != 1 {
		t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.loginThirdCount)
	}
}

func TestAccountLoginRateLimitByAccount(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	handler := NewHandler(&fakeRoomClient{}, userClient)
	handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
		config.DisplayUserIDIPLimit = 1
	}))
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"account":"100001","password":"bad","device_id":"dev-1"}`)

	for i := range 2 {
		request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
		request.Header.Set("X-Request-ID", "req-password-rate")
		request.Header.Set("X-Forwarded-For", "203.0.113.20")
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if i == 0 && recorder.Code != http.StatusOK {
			t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
		}
		if i == 1 {
			assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-password-rate")
		}
	}
	if userClient.loginPasswordCount != 1 {
		t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.loginPasswordCount)
	}
}

func TestAccountLoginAcceptsAccountField(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-account-login")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-account-login")
	if userClient.lastLoginPassword == nil || userClient.lastLoginPassword.GetDisplayUserId() != "123456" {
		t.Fatalf("account was not propagated as display_user_id: %+v", userClient.lastLoginPassword)
	}
	if userClient.lastLoginPassword.GetPassword() != "1234567" || userClient.lastLoginPassword.GetMeta().GetDeviceId() != "ios" {
		t.Fatalf("password login payload mismatch: %+v", userClient.lastLoginPassword)
	}
	if userClient.lastLoginPassword.GetMeta().GetPlatform() != "ios" || userClient.lastLoginPassword.GetMeta().GetLanguage() != "en-US" || userClient.lastLoginPassword.GetMeta().GetTimezone() != "America/Los_Angeles" {
		t.Fatalf("password login risk meta mismatch: %+v", userClient.lastLoginPassword.GetMeta())
	}
}

func TestLoginRiskFastGuardBlocksLanguage(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	handler := NewHandler(&fakeRoomClient{}, userClient)
	handler.SetLoginRisk(LoginRiskConfig{
		Enabled: true,
		FastGuard: LoginRiskFastGuardConfig{
			BlockedLanguagePrimaryTags: []string{"zh"},
		},
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"zh-CN","timezone":"America/Los_Angeles"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-login-risk-language")
	request.Header.Set("X-Forwarded-For", "203.0.113.50")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "req-login-risk-language")
	if userClient.loginPasswordCount != 0 {
		t.Fatalf("blocked login must not reach LoginPassword, count=%d", userClient.loginPasswordCount)
	}
	if userClient.blockedCount != 1 || userClient.lastBlocked.GetBlockReason() != loginRiskBlockLanguage {
		t.Fatalf("blocked audit mismatch: count=%d req=%+v", userClient.blockedCount, userClient.lastBlocked)
	}
}

func TestLoginRiskIPCacheBlocksLogin(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	cache := newMemoryLoginRiskCache()
	cache.setIPDecision("lalu", "203.0.113.51", loginRiskDecision{Decision: "blocked", Country: "CN"})
	handler := NewHandler(&fakeRoomClient{}, userClient)
	handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"provider":"google","credential":"token","device_id":"dev-1","platform":"ios","language":"en-US","timezone":"America/Los_Angeles"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-login-risk-cache")
	request.Header.Set("X-Forwarded-For", "203.0.113.51")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "req-login-risk-cache")
	if userClient.loginThirdCount != 0 {
		t.Fatalf("blocked login must not reach LoginThirdParty, count=%d", userClient.loginThirdCount)
	}
	if userClient.lastBlocked.GetBlockReason() != loginRiskBlockIPCache {
		t.Fatalf("blocked audit reason mismatch: %+v", userClient.lastBlocked)
	}
}

func TestSessionDenylistRejectsAuthenticatedRequest(t *testing.T) {
	cache := newMemoryLoginRiskCache()
	cache.setRevokedSession("lalu", "sess-test")
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-session-revoked")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.SessionRevoked), "req-session-revoked")
}

func TestRefreshRateLimitByDeviceID(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	handler := NewHandler(&fakeRoomClient{}, userClient)
	handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
		config.DeviceIDLimit = 1
	}))
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1"}`)

	for i := range 2 {
		request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body))
		request.Header.Set("X-Request-ID", "req-refresh-rate")
		request.Header.Set("X-Forwarded-For", "203.0.113.30")
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if i == 0 && recorder.Code != http.StatusOK {
			t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
		}
		if i == 1 {
			assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-refresh-rate")
		}
	}
	if userClient.refreshCount != 1 {
		t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.refreshCount)
	}
}

func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	handler := NewHandler(&fakeRoomClient{}, userClient)
	handler.SetAuthRateLimit(authRateLimitForTest(func(config *AuthRateLimitConfig) {
		config.SessionIDIPLimit = 1
	}))
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"session_id":"sess-1","refresh_token":"refresh-1"}`)

	for i := range 2 {
		request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", bytes.NewReader(body))
		request.Header.Set("X-Request-ID", "req-logout-rate")
		request.Header.Set("X-Forwarded-For", "203.0.113.40")
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if i == 0 && recorder.Code != http.StatusOK {
			t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
		}
		if i == 1 {
			assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-logout-rate")
		}
	}
	if userClient.logoutCount != 1 {
		t.Fatalf("rate limited request must not reach user-service, count=%d", userClient.logoutCount)
	}
}

func TestSetPasswordRequiresTokenAndPropagatesActorUserID(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"password":"secret-pass"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/set", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-set-password")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if userClient.lastSetPassword == nil || userClient.lastSetPassword.GetUserId() != 42 {
		t.Fatalf("authenticated user_id was not propagated: %+v", userClient.lastSetPassword)
	}
	if userClient.lastSetPassword.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-set-password") {
		t.Fatalf("request_id was not propagated: %+v", userClient.lastSetPassword.GetMeta())
	}
}

func TestUpdateProfileUsesAuthenticatedUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"female","birth":"2000-01-02"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile/update", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastUpdate == nil || profileClient.lastUpdate.GetUserId() != 42 {
		t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastUpdate)
	}
	if profileClient.lastUpdate.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile") || valueOfString(profileClient.lastUpdate.Username) != "hy" || valueOfString(profileClient.lastUpdate.Gender) != "female" || valueOfString(profileClient.lastUpdate.Birth) != "2000-01-02" {
		t.Fatalf("profile update request mismatch: %+v", profileClient.lastUpdate)
	}
}

func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile-get")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
		t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastGet)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["gender"] != "female" {
		t.Fatalf("profile response mismatch: %+v", response)
	}
}

func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
	messageClient := &fakeMessageInboxClient{tabsResp: &activityv1.ListMessageTabsResponse{Sections: []*activityv1.MessageTabSection{
		{Section: "user", Title: "用户", UnreadCount: 0, Source: "tencent_im_sdk"},
		{Section: "system", Title: "系统", UnreadCount: 2, Source: "backend"},
		{Section: "activity", Title: "活动", UnreadCount: 1, Source: "backend"},
	}}, listResp: &activityv1.ListInboxMessagesResponse{
		Section: "system",
		Items: []*activityv1.InboxMessage{{
			MessageId:   "msg-1",
			Title:       "提现审核通过",
			Summary:     "你的提现申请已通过。",
			ActionType:  "wallet_withdraw_detail",
			ActionParam: "withdrawal_id=wd_01",
			SentAtMs:    1777996800000,
		}},
		NextPageToken: "cursor-2",
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetMessageInboxClient(messageClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/messages/tabs", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-message-tabs")
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if messageClient.lastTabs == nil || messageClient.lastTabs.GetUserId() != 42 || messageClient.lastTabs.GetMeta().GetAppCode() != "lalu" || messageClient.lastTabs.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-message-tabs") {
		t.Fatalf("message tabs request mismatch: %+v", messageClient.lastTabs)
	}

	request = httptest.NewRequest(http.MethodGet, "/api/v1/messages?section=system&page_size=20&page_token=cursor-1", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-message-list")
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("list status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if messageClient.lastList == nil || messageClient.lastList.GetUserId() != 42 || messageClient.lastList.GetSection() != "system" || messageClient.lastList.GetPageSize() != 20 || messageClient.lastList.GetPageToken() != "cursor-1" {
		t.Fatalf("message list request mismatch: %+v", messageClient.lastList)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode list response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" {
		t.Fatalf("message list envelope mismatch: %+v", response)
	}
}

func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) {
	taskClient := &fakeTaskClient{listResp: &activityv1.ListUserTasksResponse{
		Sections: []*activityv1.TaskSection{{
			Section: "daily",
			Items: []*activityv1.TaskItem{{
				TaskId:           "daily-send-message",
				TaskType:         "daily",
				Category:         "interaction",
				MetricType:       "send_message",
				Title:            "发消息",
				TargetValue:      1,
				TargetUnit:       "次",
				ProgressValue:    1,
				RewardCoinAmount: 10,
				Status:           "claimable",
				Claimable:        true,
				TaskDay:          "2026-05-09",
				ServerTimeMs:     1778256000000,
				NextRefreshAtMs:  1778342400000,
				SortOrder:        10,
				Version:          2,
			}},
			ServerTimeMs:    1778256000000,
			NextRefreshAtMs: 1778342400000,
		}},
		ServerTimeMs:    1778256000000,
		NextRefreshAtMs: 1778342400000,
	}, claimResp: &activityv1.ClaimTaskRewardResponse{
		ClaimId:             "claim-1",
		TaskId:              "daily-send-message",
		TaskType:            "daily",
		TaskDay:             "2026-05-09",
		RewardCoinAmount:    10,
		Status:              "granted",
		WalletTransactionId: "wtx-1",
		GrantedAtMs:         1778256000100,
		Claimed:             true,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetTaskClient(taskClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/tabs", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-task-tabs")
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("task tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if taskClient.lastList == nil || taskClient.lastList.GetUserId() != 42 || taskClient.lastList.GetMeta().GetAppCode() != "lalu" || taskClient.lastList.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-task-tabs") {
		t.Fatalf("task tabs request mismatch: %+v", taskClient.lastList)
	}

	request = httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim", bytes.NewReader([]byte(`{"task_id":"daily-send-message","task_type":"daily","task_day":"2026-05-09","command_id":"cmd-task-claim"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-task-claim")
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("task claim status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if taskClient.lastClaim == nil || taskClient.lastClaim.GetUserId() != 42 || taskClient.lastClaim.GetTaskId() != "daily-send-message" || taskClient.lastClaim.GetTaskType() != "daily" || taskClient.lastClaim.GetTaskDay() != "2026-05-09" || taskClient.lastClaim.GetCommandId() != "cmd-task-claim" {
		t.Fatalf("task claim request mismatch: %+v", taskClient.lastClaim)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode task claim response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["task_id"] != "daily-send-message" || data["status"] != "granted" || data["wallet_transaction_id"] != "wtx-1" {
		t.Fatalf("task claim envelope mismatch: %+v", response)
	}
}

func TestMessageReadAllReadAndDeleteRoutes(t *testing.T) {
	messageClient := &fakeMessageInboxClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetMessageInboxClient(messageClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/read-all", bytes.NewReader([]byte(`{"section":"activity"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-message-read-all")
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK || messageClient.lastReadAll == nil || messageClient.lastReadAll.GetUserId() != 42 || messageClient.lastReadAll.GetSection() != "activity" {
		t.Fatalf("read-all mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastReadAll)
	}

	request = httptest.NewRequest(http.MethodPost, "/api/v1/messages/msg-1/read", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-message-read")
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK || messageClient.lastRead == nil || messageClient.lastRead.GetUserId() != 42 || messageClient.lastRead.GetMessageId() != "msg-1" {
		t.Fatalf("read mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastRead)
	}

	request = httptest.NewRequest(http.MethodDelete, "/api/v1/messages/msg-1", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-message-delete")
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK || messageClient.lastDelete == nil || messageClient.lastDelete.GetUserId() != 42 || messageClient.lastDelete.GetMessageId() != "msg-1" {
		t.Fatalf("delete mismatch: status=%d body=%s req=%+v", recorder.Code, recorder.Body.String(), messageClient.lastDelete)
	}
}

func TestBatchUserProfilesReturnsOnlySafeDisplayFields(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/profiles:batch?user_ids=42,43", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 7))
	request.Header.Set("X-Request-ID", "req-profile-batch")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastBatch == nil || profileClient.lastBatch.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-batch") || len(profileClient.lastBatch.GetUserIds()) != 2 {
		t.Fatalf("batch user request mismatch: %+v", profileClient.lastBatch)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode profile batch failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	profiles, profilesOK := data["profiles"].([]any)
	if response.Code != httpkit.CodeOK || !ok || !profilesOK || len(profiles) != 2 {
		t.Fatalf("profile batch response mismatch: %+v", response)
	}
	first, ok := profiles[0].(map[string]any)
	if !ok || first["user_id"] != "42" || first["display_user_id"] == "" || first["username"] == "" || first["app_code"] != "lalu" {
		t.Fatalf("profile batch item mismatch: %+v", first)
	}
	if _, exists := first["birth"]; exists {
		t.Fatalf("profile batch must not expose birth or other non-conversation fields: %+v", first)
	}
}

func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
	walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{
		Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 120, FrozenAmount: 3, Version: 2}},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/me/balances", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-balances")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if walletClient.last == nil || walletClient.last.GetUserId() != 42 || walletClient.last.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-balances") {
		t.Fatalf("wallet request mismatch: %+v", walletClient.last)
	}
	if len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "COIN" {
		t.Fatalf("wallet default asset types mismatch: %+v", walletClient.last)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	balances, ok := data["balances"].([]any)
	if !ok || len(balances) != 1 {
		t.Fatalf("balances response shape mismatch: %+v", data)
	}
	balance, ok := balances[0].(map[string]any)
	if !ok || balance["asset_type"] != "COIN" || balance["available_amount"] != float64(120) {
		t.Fatalf("balance response mismatch: %+v", balance)
	}
}

func TestListCoinTransactionsForcesCoinAndDefaultsPageSize30(t *testing.T) {
	walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
		Total: 1,
		Transactions: []*walletv1.WalletTransaction{{
			EntryId:        7,
			TransactionId:  "wtx-coin-1",
			BizType:        "coin_seller_transfer",
			AssetType:      "COIN",
			AvailableDelta: 300,
			AvailableAfter: 1300,
			CreatedAtMs:    1_700_000_000_000,
		}},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-transactions?page=2", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-coin-ledger")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if walletClient.lastTransactions == nil || walletClient.lastTransactions.GetUserId() != 42 || walletClient.lastTransactions.GetAssetType() != "COIN" {
		t.Fatalf("wallet transaction request mismatch: %+v", walletClient.lastTransactions)
	}
	if walletClient.lastTransactions.GetPage() != 2 || walletClient.lastTransactions.GetPageSize() != 30 {
		t.Fatalf("coin transaction pagination mismatch: %+v", walletClient.lastTransactions)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	items, itemsOK := data["items"].([]any)
	if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 || data["page_size"] != float64(30) {
		t.Fatalf("coin transaction response mismatch: %+v", response)
	}
}

func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) {
	walletClient := &fakeWalletClient{rechargeProductsResp: &walletv1.ListRechargeProductsResponse{
		Channels: []string{"google"},
		Products: []*walletv1.RechargeProduct{{
			ProductId:         11,
			ProductCode:       "iap_android_11",
			ProductName:       "1500 Coins",
			Description:       "coin pack",
			Platform:          "android",
			Channel:           "google",
			CurrencyCode:      "USDT",
			CoinAmount:        1500,
			AmountMicro:       1500000,
			AmountMinor:       1500000,
			RegionIds:         []int64{2002},
			ResourceAssetType: "COIN",
			Status:            "active",
			Enabled:           true,
		}},
	}}
	profileClient := &fakeUserProfileClient{regionID: 2002}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/recharge/products", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-recharge-products")
	request.Header.Set("X-App-Platform", "android")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
		t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet)
	}
	if walletClient.lastRechargeProducts == nil ||
		walletClient.lastRechargeProducts.GetUserId() != 42 ||
		walletClient.lastRechargeProducts.GetRegionId() != 2002 ||
		walletClient.lastRechargeProducts.GetPlatform() != "android" ||
		walletClient.lastRechargeProducts.GetAppCode() != "lalu" ||
		walletClient.lastRechargeProducts.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-recharge-products") {
		t.Fatalf("recharge products request mismatch: %+v", walletClient.lastRechargeProducts)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	products, productsOK := data["products"].([]any)
	channels, channelsOK := data["channels"].([]any)
	if response.Code != httpkit.CodeOK || !ok || !productsOK || len(products) != 1 || !channelsOK || len(channels) != 1 {
		t.Fatalf("recharge response shape mismatch: %+v", response)
	}
	product, ok := products[0].(map[string]any)
	if !ok ||
		product["product_name"] != "1500 Coins" ||
		product["platform"] != "android" ||
		product["channel"] != "google" ||
		product["currency_code"] != "USDT" ||
		product["coin_amount"] != float64(1500) ||
		product["amount_micro"] != float64(1500000) ||
		product["resource_asset_type"] != "COIN" ||
		product["enabled"] != true {
		t.Fatalf("recharge product dto mismatch: %+v", product)
	}
}

func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
	walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
		TransactionId:                "wtx-coin-seller",
		SellerBalanceAfter:           80000,
		TargetBalanceAfter:           80000,
		Amount:                       80000,
		RechargeUsdMinor:             100,
		RechargeCurrencyCode:         "USD",
		RechargePolicyId:             7,
		RechargePolicyVersion:        "recharge-v1",
		RechargePolicyCoinAmount:     80000,
		RechargePolicyUsdMinorAmount: 100,
	}}
	hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{
		UserId:            42,
		Status:            "active",
		MerchantAssetType: "COIN_SELLER_COIN",
	}}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163002": 900002}}
	profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 1001}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"commandId":"cmd-seller-transfer","targetUserId":163002,"amount":80000,"reason":"player recharge"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-coin-seller")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if hostClient.last == nil || hostClient.last.GetUserId() != 42 {
		t.Fatalf("coin seller identity request mismatch: %+v", hostClient.last)
	}
	if identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163002" {
		t.Fatalf("target display user id was not resolved: %+v", identityClient.lastResolve)
	}
	if walletClient.lastTransfer == nil ||
		walletClient.lastTransfer.GetSellerUserId() != 42 ||
		walletClient.lastTransfer.GetTargetUserId() != 900002 ||
		walletClient.lastTransfer.GetSellerRegionId() != 1001 ||
		walletClient.lastTransfer.GetTargetRegionId() != 1001 ||
		walletClient.lastTransfer.GetAmount() != 80000 ||
		walletClient.lastTransfer.GetCommandId() != "cmd-seller-transfer" {
		t.Fatalf("wallet transfer request mismatch: %+v", walletClient.lastTransfer)
	}
	if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 {
		t.Fatalf("coin seller region lookup mismatch: %+v", profileClient.getRequests)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["transaction_id"] != "wtx-coin-seller" || data["seller_balance_after"] != float64(80000) || data["recharge_usd_minor"] != float64(100) {
		t.Fatalf("coin seller transfer response mismatch: %+v", response)
	}
}

func TestCoinSellerTransferRejectsCrossRegionBeforeWallet(t *testing.T) {
	walletClient := &fakeWalletClient{}
	hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{
		UserId:            42,
		Status:            "active",
		MerchantAssetType: "COIN_SELLER_COIN",
	}}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163002": 900002}}
	profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 1002}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"command_id":"cmd-seller-cross-region","target_user_id":163002,"amount":80000,"reason":"player recharge"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-coin-seller-cross-region")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusConflict {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if walletClient.lastTransfer != nil {
		t.Fatalf("cross-region transfer must not reach wallet-service: %+v", walletClient.lastTransfer)
	}
}

func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"country":"US"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-country")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastCountry == nil || profileClient.lastCountry.GetUserId() != 42 || profileClient.lastCountry.GetCountry() != "US" {
		t.Fatalf("country change request mismatch: %+v", profileClient.lastCountry)
	}

	profileClient = &fakeUserProfileClient{countryErr: xerr.ToGRPCError(xerr.New(xerr.CountryChangeCooldown, "country change is cooling down"))}
	router = NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	request = httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-country-cooldown")
	recorder = httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "req-country-cooldown")
}

func TestChangeCountryRemovesOldRegionBroadcastMember(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	broadcastClient := &fakeBroadcastClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetBroadcastClient(broadcastClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"country":"US"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/country/change", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-country-region")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if broadcastClient.lastRemove == nil {
		t.Fatalf("expected old region broadcast member removal")
	}
	if broadcastClient.lastRemove.GetUserId() != 42 || broadcastClient.lastRemove.GetRegionId() != 1001 || broadcastClient.lastRemove.GetMeta().GetAppCode() != "lalu" {
		t.Fatalf("remove request mismatch: %+v", broadcastClient.lastRemove)
	}
}

func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","gender":"male","country":"SG","invite_code":"A1B2C3"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
	request.Header.Set("X-Request-ID", "req-onboarding")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 {
		t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete)
	}
	if profileClient.lastComplete.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-onboarding") || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetGender() != "male" || profileClient.lastComplete.GetCountry() != "SG" || profileClient.lastComplete.GetInviteCode() != "A1B2C3" {
		t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete)
	}
	if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" {
		t.Fatalf("complete onboarding must propagate current session_id: %+v", profileClient.lastComplete.GetMeta())
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" {
		t.Fatalf("unexpected onboarding response: %+v", response)
	}
	token, ok := data["token"].(map[string]any)
	if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true {
		t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"])
	}
	invite, ok := data["invite"].(map[string]any)
	if !ok || invite["bound"] != true || invite["invite_code"] != "A1B2C3" || invite["inviter_user_id"] != "10001" {
		t.Fatalf("complete onboarding invite response mismatch: %+v", data["invite"])
	}
}

func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) {
	tests := []struct {
		name string
		path string
		body string
	}{
		{name: "profile_update", path: "/api/v1/users/me/profile/update", body: `{"username":"hy"}`},
		{name: "country_change", path: "/api/v1/users/me/country/change", body: `{"country":"US"}`},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			profileClient := &fakeUserProfileClient{}
			router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
			request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
			request.Header.Set("X-Request-ID", "req-profile-mutation")
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-profile-mutation")
			if profileClient.lastUpdate != nil || profileClient.lastCountry != nil {
				t.Fatalf("profile gate must stop mutation before user-service: update=%+v country=%+v", profileClient.lastUpdate, profileClient.lastCountry)
			}
		})
	}
}

func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testing.T) {
	tests := []struct {
		name   string
		method string
		path   string
		body   string
	}{
		{name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"},
		{name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`},
		{name: "profile_update", method: http.MethodPost, path: "/api/v1/rooms/profile/update", body: `{"room_id":"room-1"}`},
		{name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
		{name: "room_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
		{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
		{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
		{name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`},
		{name: "message_tabs", method: http.MethodGet, path: "/api/v1/messages/tabs"},
		{name: "message_list", method: http.MethodGet, path: "/api/v1/messages?section=system"},
		{name: "message_read", method: http.MethodPost, path: "/api/v1/messages/msg-1/read"},
		{name: "message_read_all", method: http.MethodPost, path: "/api/v1/messages/read-all", body: `{"section":"system"}`},
		{name: "message_delete", method: http.MethodDelete, path: "/api/v1/messages/msg-1"},
		{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			roomClient := &fakeRoomClient{}
			handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
				SDKAppID:   1400000000,
				SecretKey:  "secret",
				UserSigTTL: time.Hour,
			})
			handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour})
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body)))
			request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
			request.Header.Set("X-Request-ID", "req-profile-required")
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			assertEnvelope(t, recorder, http.StatusForbidden, httpkit.CodeProfileRequired, "req-profile-required")
			if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || roomClient.lastGift != nil {
				t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient)
			}
		})
	}
}

func TestVoiceRoomRoutesRejectWrongHTTPMethod(t *testing.T) {
	roomClient := &fakeRoomClient{}
	queryClient := &fakeRoomQueryClient{}
	guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}}
	handler := NewHandlerWithConfig(roomClient, guardClient, nil, nil, TencentIMConfig{
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: time.Hour,
	})
	handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	tests := []struct {
		name   string
		method string
		path   string
		body   string
	}{
		{name: "rooms_list", method: http.MethodPost, path: "/api/v1/rooms"},
		{name: "current_room", method: http.MethodPost, path: "/api/v1/rooms/current"},
		{name: "join_room", method: http.MethodGet, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
		{name: "heartbeat", method: http.MethodGet, path: "/api/v1/rooms/heartbeat", body: `{"room_id":"room-1"}`},
		{name: "im_usersig", method: http.MethodPost, path: "/api/v1/im/usersig"},
		{name: "rtc_token", method: http.MethodGet, path: "/api/v1/rtc/token"},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body)))
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-method-guard")
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			assertEnvelope(t, recorder, http.StatusMethodNotAllowed, httpkit.CodeInvalidArgument, "req-method-guard")
		})
	}

	if roomClient.lastJoin != nil || roomClient.lastHeartbeat != nil || queryClient.lastList != nil || queryClient.lastCurrent != nil || guardClient.lastPresence != nil {
		t.Fatalf("method guard must stop voice-room calls before downstream clients: room=%+v query=%+v guard=%+v", roomClient, queryClient, guardClient)
	}
}

func TestUploadAvatarAllowsIncompleteProfileAndWritesObject(t *testing.T) {
	uploader := &fakeObjectUploader{}
	handler := NewHandler(&fakeRoomClient{})
	handler.SetObjectUploader(uploader)
	router := handler.Routes(auth.NewVerifier("secret"))
	payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 32)...)
	body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", payload)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body)
	request.Header.Set("Content-Type", contentType)
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
	request.Header.Set("X-Request-ID", "req-upload-avatar")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode upload response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected upload envelope: %+v", response)
	}
	if !strings.HasPrefix(uploader.lastKey, "app/avatars/42/") || !strings.HasSuffix(uploader.lastKey, ".png") {
		t.Fatalf("unexpected avatar object key: %q", uploader.lastKey)
	}
	if string(uploader.lastContent) != string(payload) || uploader.lastSizeBytes != int64(len(payload)) || uploader.lastContentType != "image/png" {
		t.Fatalf("uploaded object mismatch: key=%s size=%d type=%s body=%x", uploader.lastKey, uploader.lastSizeBytes, uploader.lastContentType, uploader.lastContent)
	}
	if data["url"] != "https://media.example.com/"+uploader.lastKey || data["object_key"] != uploader.lastKey || data["content_type"] != "image/png" {
		t.Fatalf("unexpected upload data: %+v", data)
	}
}

func TestUploadAvatarRejectsSpoofedImageContentType(t *testing.T) {
	uploader := &fakeObjectUploader{}
	handler := NewHandler(&fakeRoomClient{})
	handler.SetObjectUploader(uploader)
	router := handler.Routes(auth.NewVerifier("secret"))
	body, contentType := multipartUploadBody(t, "file", "avatar.png", "image/png", []byte("not an image"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/files/avatar/upload", body)
	request.Header.Set("Content-Type", contentType)
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
	request.Header.Set("X-Request-ID", "req-upload-avatar-invalid")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-upload-avatar-invalid")
	if uploader.lastKey != "" {
		t.Fatalf("invalid avatar must not be uploaded: %+v", uploader)
	}
}

func TestUploadFileRequiresObjectUploader(t *testing.T) {
	router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret"))
	body, contentType := multipartUploadBody(t, "file", "document.txt", "text/plain", []byte("hello"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/files/upload", body)
	request.Header.Set("Content-Type", contentType)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-upload-disabled")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-upload-disabled")
}

func TestAuthLoginMapsGRPCReason(t *testing.T) {
	userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"account":"100001","password":"bad","device_id":"ios"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-auth-failed")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.AuthFailed), "req-auth-failed")
}

func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) {
	// room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。
	statusCode, code, message := httpkit.MapReasonToHTTP(xerr.Conflict)
	if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "conflict" {
		t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message)
	}
}

func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) {
	router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`)))
	request.Header.Set("X-Request-ID", "req-auth")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusUnauthorized, httpkit.CodeUnauthorized, "req-auth")
}

func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
	router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-json")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidJSON, "req-json")
}

func TestCreateRoomIgnoresClientRoomIDAndGeneratesScopedRoomID(t *testing.T) {
	client := &fakeRoomClient{}
	router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-scoped","seat_count":10,"mode":"voice","room_name":"Lobby"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-generated-room-id")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if client.lastCreate == nil {
		t.Fatal("CreateRoom must be forwarded after gateway generates room_id")
	}
	if roomID := client.lastCreate.GetMeta().GetRoomId(); !strings.HasPrefix(roomID, "lalu_") || roomID == "room:1" {
		t.Fatalf("client room_id must not be forwarded: %q", roomID)
	}
}

func TestCreateRoomAllowsMissingSeatCount(t *testing.T) {
	client := &fakeRoomClient{}
	router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","command_id":"cmd-create-default-seat","mode":"voice","room_name":"Lobby"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-create-default-seat")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if client.lastCreate == nil || client.lastCreate.GetSeatCount() != 0 {
		t.Fatalf("missing seat_count should forward zero to room-service default config: %+v", client.lastCreate)
	}
}

func TestUpdateRoomProfileForwardsOptionalFields(t *testing.T) {
	client := &fakeRoomClient{}
	router := NewHandler(client).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-1","room_name":"  New Room  ","room_avatar":" https://cdn.example.com/new.png ","room_description":" new desc ","seat_count":20}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile-update")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	req := client.lastUpdateProfile
	if req == nil {
		t.Fatal("UpdateRoomProfile must be forwarded")
	}
	if req.GetMeta().GetRoomId() != "room-1" || req.GetMeta().GetCommandId() != "cmd-profile-1" || req.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-update") || req.GetMeta().GetActorUserId() != 42 {
		t.Fatalf("UpdateRoomProfile meta mismatch: %+v", req.GetMeta())
	}
	if req.RoomName == nil || req.GetRoomName() != "New Room" || req.RoomAvatar == nil || req.GetRoomAvatar() != "https://cdn.example.com/new.png" || req.RoomDescription == nil || req.GetRoomDescription() != "new desc" || req.SeatCount == nil || req.GetSeatCount() != 20 {
		t.Fatalf("UpdateRoomProfile optional fields mismatch: %+v", req)
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["server_time_ms"] == nil || data["room"] == nil || data["seats"] == nil {
		t.Fatalf("profile update response should include result room seats and server_time_ms: %+v", response.Data)
	}
}

func TestUpdateRoomProfileRPCErrorWritesEnvelope(t *testing.T) {
	client := &fakeRoomClient{updateProfileErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "removed seats must be empty and unlocked"))}
	router := NewHandler(client).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-1","command_id":"cmd-profile-conflict","seat_count":10}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/profile/update", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile-conflict")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-profile-conflict")
}

func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
	tests := []struct {
		name string
		body string
	}{
		{name: "command_id", body: `{"room_id":"room-1","seat_count":10,"mode":"voice","room_name":"Lobby"}`},
		{name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby"}`},
		{name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice"}`},
		{name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby"}`},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			client := &fakeRoomClient{}
			router := NewHandler(client).Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(test.body)))
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-create-required-"+test.name)
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-create-required-"+test.name)
			if client.lastCreate != nil {
				t.Fatalf("missing required create field must not be forwarded: %+v", client.lastCreate)
			}
		})
	}
}

func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
	router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-1","command_id":"cmd-create-upstream","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-upstream")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-upstream")
}

func TestCreateRoomOwnerConflictWritesConflictEnvelope(t *testing.T) {
	roomClient := &fakeRoomClient{
		createErr: xerr.ToGRPCError(xerr.New(xerr.Conflict, "owner already has room")),
	}
	router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-owner-2","command_id":"cmd-create-owner-conflict","seat_count":10,"mode":"voice","room_name":"Lobby"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-owner-conflict")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusConflict, string(xerr.Conflict), "req-owner-conflict")
}

func TestJoinRoomClosedWritesRoomClosedEnvelope(t *testing.T) {
	roomClient := &fakeRoomClient{
		joinErr: xerr.ToGRPCError(xerr.New(xerr.RoomClosed, "room closed")),
	}
	router := NewHandler(roomClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-closed","command_id":"cmd-join-closed"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-closed")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.RoomClosed), "room closed", "req-room-closed")
}

func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	router := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: time.Hour,
	}, profileClient).Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-usersig")
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["user_id"] != "42" || data["user_sig"] == "" {
		t.Fatalf("unexpected usersig response: %+v", response)
	}
	groups, ok := data["join_groups"].([]any)
	if !ok || len(groups) != 2 {
		t.Fatalf("join_groups mismatch: %+v", data["join_groups"])
	}
	regionGroup, ok := groups[1].(map[string]any)
	if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_1001" || regionGroup["type"] != "region_broadcast" {
		t.Fatalf("region join group mismatch: %+v", groups[1])
	}

	missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
		SDKAppID:   1400000000,
		UserSigTTL: time.Hour,
	}, profileClient).Routes(auth.NewVerifier("secret"))
	missingConfigRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
	missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	missingConfigRequest.Header.Set("X-Request-ID", "req-usersig-missing")
	missingConfigRecorder := httptest.NewRecorder()
	missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest)

	assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-missing")
}

func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
	previousNow := timeNow
	timeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
	defer func() { timeNow = previousNow }()

	guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}}
	handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{})
	handler.SetTencentRTC(TencentRTCConfig{
		Enabled:    true,
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: 2 * time.Hour,
		RoomIDType: "string",
		AppScene:   "voice_chat_room",
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-rtc-token")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	if data["user_id"] != "42" || data["rtc_user_id"] != "42" || data["room_id"] != "room_1001" || data["str_room_id"] != "room_1001" {
		t.Fatalf("unexpected rtc identity mapping: %+v", data)
	}
	if data["rtc_room_id_type"] != "string" || data["app_scene"] != "voice_chat_room" || data["role"] != "audience" {
		t.Fatalf("unexpected rtc policy fields: %+v", data)
	}
	if data["user_sig"] == "" || int64(data["expire_at_ms"].(float64)) != time.Unix(1_700_000_000, 0).Add(2*time.Hour).UnixMilli() {
		t.Fatalf("unexpected rtc usersig fields: %+v", data)
	}
	if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room_1001" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-token") {
		t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence)
	}
}

func TestTencentRTCTokenRejectsInvalidRoomIDBeforeGuard(t *testing.T) {
	guard := &fakeRoomGuardClient{}
	handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{})
	handler.SetTencentRTC(TencentRTCConfig{
		Enabled:    true,
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: time.Hour,
		RoomIDType: "string",
		AppScene:   "voice_chat_room",
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room:1001"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-rtc-invalid")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-rtc-invalid")
	if guard.lastPresence != nil {
		t.Fatalf("invalid room_id must not call presence guard: %+v", guard.lastPresence)
	}
}

func TestTencentRTCTokenMapsPresenceDenialReasons(t *testing.T) {
	tests := []struct {
		name       string
		reason     string
		statusCode int
		code       string
	}{
		{name: "not_in_room", reason: "not_in_room", statusCode: http.StatusForbidden, code: httpkit.CodePermissionDenied},
		{name: "user_banned", reason: "user_banned", statusCode: http.StatusForbidden, code: httpkit.CodePermissionDenied},
		{name: "room_not_found", reason: "room_not_found", statusCode: http.StatusNotFound, code: httpkit.CodeNotFound},
		{name: "room_closed", reason: "room_closed", statusCode: http.StatusConflict, code: string(xerr.RoomClosed)},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			guard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: test.reason, RoomVersion: 3}}
			handler := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{})
			handler.SetTencentRTC(TencentRTCConfig{
				Enabled:    true,
				SDKAppID:   1400000000,
				SecretKey:  "secret",
				UserSigTTL: time.Hour,
				RoomIDType: "string",
				AppScene:   "voice_chat_room",
			})
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`)))
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-rtc-"+test.name)
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			assertEnvelope(t, recorder, test.statusCode, test.code, "req-rtc-"+test.name)
		})
	}
}

func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) {
	missingConfigGuard := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}}
	missingConfigHandler := NewHandlerWithConfig(&fakeRoomClient{}, missingConfigGuard, nil, nil, TencentIMConfig{})
	missingConfigHandler.SetTencentRTC(TencentRTCConfig{
		Enabled:    true,
		SDKAppID:   1400000000,
		UserSigTTL: time.Hour,
		RoomIDType: "string",
		AppScene:   "voice_chat_room",
	})
	missingConfigRouter := missingConfigHandler.Routes(auth.NewVerifier("secret"))
	missingConfigRequest := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`)))
	missingConfigRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	missingConfigRequest.Header.Set("X-Request-ID", "req-rtc-config")
	missingConfigRecorder := httptest.NewRecorder()

	missingConfigRouter.ServeHTTP(missingConfigRecorder, missingConfigRequest)

	assertEnvelope(t, missingConfigRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-rtc-config")
	if missingConfigGuard.lastPresence != nil {
		t.Fatalf("missing rtc config must fail before room-service guard: %+v", missingConfigGuard.lastPresence)
	}

	upstreamGuard := &fakeRoomGuardClient{err: errors.New("room-service unavailable")}
	upstreamHandler := NewHandlerWithConfig(&fakeRoomClient{}, upstreamGuard, nil, nil, TencentIMConfig{})
	upstreamHandler.SetTencentRTC(TencentRTCConfig{
		Enabled:    true,
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: time.Hour,
		RoomIDType: "string",
		AppScene:   "voice_chat_room",
	})
	upstreamRouter := upstreamHandler.Routes(auth.NewVerifier("secret"))
	upstreamRequest := httptest.NewRequest(http.MethodPost, "/api/v1/rtc/token", bytes.NewReader([]byte(`{"room_id":"room_1001"}`)))
	upstreamRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	upstreamRequest.Header.Set("X-Request-ID", "req-rtc-upstream")
	upstreamRecorder := httptest.NewRecorder()

	upstreamRouter.ServeHTTP(upstreamRecorder, upstreamRequest)

	assertEnvelope(t, upstreamRecorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-rtc-upstream")
}

func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) {
	roomClient := &fakeRoomClient{}
	handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{})
	handler.SetTencentRTC(TencentRTCConfig{
		SDKAppID:        1400000000,
		CallbackSignKey: "callback-key",
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"EventGroupId":2,"EventType":203,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback?app_code=lalu", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-rtc-callback")
	request.Header.Set("SdkAppId", "1400000000")
	request.Header.Set("Sign", signTencentRTCCallback("callback-key", body))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertTencentRTCCallback(t, recorder, http.StatusOK, 0)
	if roomClient.lastRTCEvent == nil {
		t.Fatal("RTC callback must forward known events to room-service")
	}
	if roomClient.lastRTCEvent.GetEventType() != "audio_started" || roomClient.lastRTCEvent.GetTargetUserId() != 42 || roomClient.lastRTCEvent.GetEventTimeMs() != 1700000001000 {
		t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent)
	}
	meta := roomClient.lastRTCEvent.GetMeta()
	if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-callback") || meta.GetAppCode() != "lalu" {
		t.Fatalf("RTC event meta mismatch: %+v", meta)
	}
	if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") {
		t.Fatalf("RTC callback must derive stable ids: meta=%+v event=%+v", meta, roomClient.lastRTCEvent)
	}
}

func TestTencentRTCCallbackRejectsInvalidSignatureAndIgnoresUnknownEvents(t *testing.T) {
	roomClient := &fakeRoomClient{}
	handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{})
	handler.SetTencentRTC(TencentRTCConfig{
		SDKAppID:        1400000000,
		CallbackSignKey: "callback-key",
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"EventGroupId":2,"EventType":204,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42","Reason":0}}`)
	badRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(body))
	badRequest.Header.Set("SdkAppId", "1400000000")
	badRequest.Header.Set("Sign", "bad-sign")
	badRecorder := httptest.NewRecorder()

	router.ServeHTTP(badRecorder, badRequest)

	assertTencentRTCCallback(t, badRecorder, http.StatusForbidden, 1)
	if roomClient.lastRTCEvent != nil {
		t.Fatalf("invalid signature must not reach room-service: %+v", roomClient.lastRTCEvent)
	}

	unknownBody := []byte(`{"EventGroupId":2,"EventType":201,"CallbackTs":1700000001001,"EventInfo":{"RoomId":"room_1001","EventMsTs":1700000001000,"UserId":"42"}}`)
	unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-rtc/callback", bytes.NewReader(unknownBody))
	unknownRequest.Header.Set("SdkAppId", "1400000000")
	unknownRequest.Header.Set("Sign", signTencentRTCCallback("callback-key", unknownBody))
	unknownRecorder := httptest.NewRecorder()
	router.ServeHTTP(unknownRecorder, unknownRequest)

	assertTencentRTCCallback(t, unknownRecorder, http.StatusOK, 0)
	if roomClient.lastRTCEvent != nil {
		t.Fatalf("unknown RTC event must be acknowledged without room-service call: %+v", roomClient.lastRTCEvent)
	}
}

func TestTencentIMJoinCallbackUsesPresenceGuard(t *testing.T) {
	guard := &fakeRoomGuardClient{}
	router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
		SDKAppID:          1400000000,
		CallbackAuthToken: "callback-token",
	}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"room-1","Requestor_Account":"42"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-im-join")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertTencentCallback(t, recorder, http.StatusOK, 0)
	if guard.lastPresence == nil || guard.lastPresence.GetRoomId() != "room-1" || guard.lastPresence.GetUserId() != 42 || guard.lastPresence.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-im-join") {
		t.Fatalf("presence guard request mismatch: %+v", guard.lastPresence)
	}

	guard.presenceResp = &roomv1.VerifyRoomPresenceResponse{Present: false, Reason: "not_in_room"}
	deniedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&CallbackAuthToken=callback-token", bytes.NewReader(body))
	deniedRecorder := httptest.NewRecorder()
	router.ServeHTTP(deniedRecorder, deniedRequest)

	assertTencentCallback(t, deniedRecorder, http.StatusOK, 1)
}

func TestTencentIMSendCallbackUsesSpeakGuard(t *testing.T) {
	guard := &fakeRoomGuardClient{
		speakResp: &roomv1.CheckSpeakPermissionResponse{Allowed: false, Reason: "user_muted"},
	}
	router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
		SDKAppID: 1400000000,
	}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"42"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(body))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertTencentCallback(t, recorder, http.StatusOK, 1)
	if guard.lastSpeak == nil || guard.lastSpeak.GetRoomId() != "room-1" || guard.lastSpeak.GetUserId() != 42 {
		t.Fatalf("speak guard request mismatch: %+v", guard.lastSpeak)
	}
}

func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	guard := &fakeRoomGuardClient{}
	router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
		SDKAppID:        1400000000,
		AdminIdentifier: "administrator",
	}, profileClient).Routes(auth.NewVerifier("secret"))

	joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
	joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(joinBody))
	joinRecorder := httptest.NewRecorder()
	router.ServeHTTP(joinRecorder, joinRequest)

	assertTencentCallback(t, joinRecorder, http.StatusOK, 0)
	if guard.lastPresence != nil {
		t.Fatalf("broadcast join must not call room guard: %+v", guard.lastPresence)
	}

	mismatchBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1002","Requestor_Account":"42"}`)
	mismatchRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(mismatchBody))
	mismatchRecorder := httptest.NewRecorder()
	router.ServeHTTP(mismatchRecorder, mismatchRequest)
	assertTencentCallback(t, mismatchRecorder, http.StatusOK, 1)

	userSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"42"}`)
	userSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(userSendBody))
	userSendRecorder := httptest.NewRecorder()
	router.ServeHTTP(userSendRecorder, userSendRequest)
	assertTencentCallback(t, userSendRecorder, http.StatusOK, 1)

	adminSendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"hy_lalu_bc_r_1001","From_Account":"administrator"}`)
	adminSendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg", bytes.NewReader(adminSendBody))
	adminSendRecorder := httptest.NewRecorder()
	router.ServeHTTP(adminSendRecorder, adminSendRequest)
	assertTencentCallback(t, adminSendRecorder, http.StatusOK, 0)
}

func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {
	router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
		SDKAppID:          1400000000,
		CallbackAuthToken: "callback-token",
	}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"42"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&CallbackAuthToken=bad-token", bytes.NewReader(body))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertTencentCallback(t, recorder, http.StatusOK, 1)

	unknownBody := []byte(`{"CallbackCommand":"Group.CallbackAfterSendMsg","GroupId":"room-1","From_Account":"42"}`)
	unknownRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&CallbackAuthToken=callback-token", bytes.NewReader(unknownBody))
	unknownRecorder := httptest.NewRecorder()
	router.ServeHTTP(unknownRecorder, unknownRequest)

	assertTencentCallback(t, unknownRecorder, http.StatusOK, 0)
}

func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, requestID string) {
	t.Helper()

	if recorder.Code != statusCode {
		t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	generatedRequestID := assertGeneratedRequestID(t, recorder, requestID)
	if response.Code != code || response.RequestID != generatedRequestID {
		t.Fatalf("unexpected envelope: %+v", response)
	}
}

func assertEnvelopeMessage(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, message string, requestID string) {
	t.Helper()

	if recorder.Code != statusCode {
		t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	generatedRequestID := assertGeneratedRequestID(t, recorder, requestID)
	if response.Code != code || response.Message != message || response.RequestID != generatedRequestID {
		t.Fatalf("unexpected envelope: %+v", response)
	}
}

func assertGeneratedRequestID(t *testing.T, recorder *httptest.ResponseRecorder, inboundRequestID string) string {
	t.Helper()

	requestID := recorder.Header().Get("X-Request-ID")
	if requestID == "" || requestID == inboundRequestID {
		t.Fatalf("request_id must be generated by gateway, got %q", requestID)
	}
	return requestID
}

func assertTencentCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, errorCode int) {
	t.Helper()

	if recorder.Code != statusCode {
		t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
	}

	var response tencentIMCallbackResponse
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode tencent callback response failed: %v", err)
	}
	if response.ActionStatus != "OK" || response.ErrorCode != errorCode {
		t.Fatalf("unexpected tencent callback response: %+v", response)
	}
}

func assertTencentRTCCallback(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code int) {
	t.Helper()

	if recorder.Code != statusCode {
		t.Fatalf("status mismatch: got %d want %d body=%s", recorder.Code, statusCode, recorder.Body.String())
	}

	var response tencentRTCCallbackResponse
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode tencent rtc callback response failed: %v", err)
	}
	if response.Code != code {
		t.Fatalf("unexpected tencent rtc callback response: %+v", response)
	}
}

func signTencentRTCCallback(key string, body []byte) string {
	mac := hmac.New(sha256.New, []byte(key))
	_, _ = mac.Write(body)
	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

func int64SliceToStrings(values []int64) []string {
	items := make([]string, 0, len(values))
	for _, value := range values {
		items = append(items, strconv.FormatInt(value, 10))
	}
	return items
}

func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfig {
	config := AuthRateLimitConfig{
		Enabled:              true,
		Window:               time.Minute,
		IPLimit:              1000,
		DeviceIDLimit:        1000,
		ProviderIPLimit:      1000,
		DisplayUserIDIPLimit: 1000,
		DisplayUserIDLimit:   1000,
		SessionIDIPLimit:     1000,
	}
	mutator(&config)
	return config
}

func signGatewayToken(t *testing.T, secret string, userID int64) string {
	t.Helper()

	return signGatewayTokenWithProfile(t, secret, userID, true)
}

func signGatewayTokenWithProfile(t *testing.T, secret string, userID int64, profileCompleted bool) string {
	t.Helper()

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id":           strconv.FormatInt(userID, 10),
		"app_code":          "lalu",
		"sid":               "sess-test",
		"profile_completed": profileCompleted,
		"onboarding_status": map[bool]string{true: "completed", false: "profile_required"}[profileCompleted],
	})
	signed, err := token.SignedString([]byte(secret))
	if err != nil {
		t.Fatalf("sign token failed: %v", err)
	}

	return signed
}

func multipartUploadBody(t *testing.T, fieldName string, filename string, contentType string, payload []byte) (*bytes.Buffer, string) {
	t.Helper()

	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	header := make(textproto.MIMEHeader)
	header.Set("Content-Disposition", `form-data; name="`+fieldName+`"; filename="`+filename+`"`)
	header.Set("Content-Type", contentType)
	part, err := writer.CreatePart(header)
	if err != nil {
		t.Fatalf("create multipart part failed: %v", err)
	}
	if _, err := part.Write(payload); err != nil {
		t.Fatalf("write multipart payload failed: %v", err)
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("close multipart writer failed: %v", err)
	}

	return &body, writer.FormDataContentType()
}
