package http

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

	jwt "github.com/golang-jwt/jwt/v5"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	activityv1 "hyapp.local/api/proto/activity/v1"
	roomv1 "hyapp.local/api/proto/room/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/tencentim"
	"hyapp/pkg/xerr"
	"hyapp/services/gateway-service/internal/appconfig"
	"hyapp/services/gateway-service/internal/auth"
	gatewayclient "hyapp/services/gateway-service/internal/client"
	"hyapp/services/gateway-service/internal/financewithdrawal"
)

const testAppAccessTokenTTLSec int64 = 604800

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

type fakeRoomVIPClient struct {
	*fakeRoomClient
	lastApplyDecoration *roomv1.ApplyRoomDecorationRequest
}

func (f *fakeRoomVIPClient) ApplyRoomDecoration(_ context.Context, req *roomv1.ApplyRoomDecorationRequest) (*roomv1.ApplyRoomDecorationResponse, error) {
	f.lastApplyDecoration = req
	resource := &roomv1.RoomDecorationResource{
		ResourceId: req.GetResourceId(), ResourceCode: "vip-room-border-gold", ResourceType: "room_border",
		Name: "Gold Room Border", AssetUrl: "https://cdn.example.com/room-border/gold.webp", EntitlementId: req.GetEntitlementId(),
	}
	return &roomv1.ApplyRoomDecorationResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 5, ServerTimeMs: 1_700_000_000_005},
		Room: &roomv1.RoomSnapshot{
			RoomId: req.GetMeta().GetRoomId(), OwnerUserId: req.GetMeta().GetActorUserId(), Status: "active", RoomBorder: resource, Version: 5,
		},
		DecorationType: req.GetDecorationType(), Resource: resource, ServerTimeMs: 1_700_000_000_005,
	}, nil
}

func (f *fakeRoomVIPClient) SendRoomImage(_ context.Context, _ *roomv1.SendRoomImageRequest) (*roomv1.SendRoomImageResponse, error) {
	return &roomv1.SendRoomImageResponse{}, nil
}

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) SaveRoomBackground(_ context.Context, req *roomv1.SaveRoomBackgroundRequest) (*roomv1.SaveRoomBackgroundResponse, error) {
	f.lastSaveBackground = req
	return &roomv1.SaveRoomBackgroundResponse{
		Background: &roomv1.RoomBackgroundImage{
			BackgroundId:    101,
			RoomId:          req.GetRoomId(),
			ImageUrl:        req.GetImageUrl(),
			CreatedByUserId: req.GetMeta().GetActorUserId(),
			CreatedAtMs:     1_700_000_000_003,
			UpdatedAtMs:     1_700_000_000_003,
		},
		ServerTimeMs: 1_700_000_000_003,
	}, nil
}

func (f *fakeRoomClient) SetRoomBackground(_ context.Context, req *roomv1.SetRoomBackgroundRequest) (*roomv1.SetRoomBackgroundResponse, error) {
	f.lastSetBackground = req
	return &roomv1.SetRoomBackgroundResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 4, ServerTimeMs: 1_700_000_000_004},
		Room: &roomv1.RoomSnapshot{
			RoomId:      req.GetMeta().GetRoomId(),
			OwnerUserId: req.GetMeta().GetActorUserId(),
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			RoomExt: map[string]string{
				"background_url": "https://cdn.example.com/bg.png",
			},
			Version: 4,
		},
		Background: &roomv1.RoomBackgroundImage{
			BackgroundId:    req.GetBackgroundId(),
			RoomId:          req.GetMeta().GetRoomId(),
			ImageUrl:        "https://cdn.example.com/bg.png",
			CreatedByUserId: req.GetMeta().GetActorUserId(),
			CreatedAtMs:     1_700_000_000_003,
			UpdatedAtMs:     1_700_000_000_003,
		},
	}, 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
	if f.micUpErr != nil {
		return nil, f.micUpErr
	}
	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) MicHeartbeat(_ context.Context, req *roomv1.MicHeartbeatRequest) (*roomv1.MicHeartbeatResponse, error) {
	f.lastMicHeartbeat = req
	return &roomv1.MicHeartbeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}, SeatNo: 2, MicHeartbeatAtMs: 1_700_000_001_000}, 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) SetRoomPassword(_ context.Context, req *roomv1.SetRoomPasswordRequest) (*roomv1.SetRoomPasswordResponse, error) {
	f.lastRoomPassword = req
	return &roomv1.SetRoomPasswordResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9, ServerTimeMs: 1_700_000_000_009},
		Room: &roomv1.RoomSnapshot{
			RoomId:      req.GetMeta().GetRoomId(),
			OwnerUserId: req.GetMeta().GetActorUserId(),
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			Locked:      req.GetLocked(),
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}},
			RoomExt:     map[string]string{"title": "Room"},
			Version:     9,
		},
	}, 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(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
	f.lastGift = req
	if f.sendGiftWaitForContext {
		<-ctx.Done()
		return nil, status.FromContextError(ctx.Err()).Err()
	}
	if f.sendGiftResp != nil {
		return f.sendGiftResp, nil
	}
	return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
}

func (f *fakeRoomClient) FollowRoom(_ context.Context, req *roomv1.FollowRoomRequest) (*roomv1.FollowRoomResponse, error) {
	f.lastFollowRoom = req
	return &roomv1.FollowRoomResponse{RoomId: req.GetRoomId(), Followed: true, FollowedAtMs: 1_700_000_000_123, ServerTimeMs: 1_700_000_000_123}, nil
}

func (f *fakeRoomClient) UnfollowRoom(_ context.Context, req *roomv1.UnfollowRoomRequest) (*roomv1.UnfollowRoomResponse, error) {
	f.lastUnfollowRoom = req
	return &roomv1.UnfollowRoomResponse{RoomId: req.GetRoomId(), Followed: false, ServerTimeMs: 1_700_000_000_124}, nil
}

type fakeUserAuthClient struct {
	lastLoginThirdParty *userv1.LoginThirdPartyRequest
	lastRegisterThird   *userv1.RegisterThirdPartyRequest
	lastCheckThird      *userv1.CheckThirdPartyRegisteredRequest
	lastLoginPassword   *userv1.LoginPasswordRequest
	lastSetPassword     *userv1.SetPasswordRequest
	lastQuickCreate     *userv1.QuickCreateAccountRequest
	lastRefresh         *userv1.RefreshTokenRequest
	lastLogout          *userv1.LogoutRequest
	lastBlocked         *userv1.RecordLoginBlockedRequest
	lastWhitelist       *userv1.CheckLoginRiskIPWhitelistRequest
	lastAppHeartbeat    *userv1.AppHeartbeatRequest
	loginPasswordCount  int
	loginThirdCount     int
	refreshCount        int
	logoutCount         int
	blockedCount        int
	whitelisted         bool
	loginErr            error
	loginThirdResp      *userv1.AuthResponse
	checkRegistered     bool
}

type fakeUserProfileClient struct {
	lastGet             *userv1.GetUserRequest
	lastBusinessLookup  *userv1.BusinessUserLookupRequest
	lastStats           *userv1.GetMyProfileStatsRequest
	lastBatch           *userv1.BatchGetUsersRequest
	batchRequests       []*userv1.BatchGetUsersRequest
	lastRoomBasicBatch  *userv1.BatchGetRoomBasicUsersRequest
	roomBasicRequests   []*userv1.BatchGetRoomBasicUsersRequest
	getRequests         []*userv1.GetUserRequest
	lastComplete        *userv1.CompleteOnboardingRequest
	lastUpdate          *userv1.UpdateUserProfileRequest
	lastBackground      *userv1.UpdateUserProfileBackgroundRequest
	lastContact         *userv1.UpdateUserContactInfoRequest
	lastWithdrawAddress *userv1.UpdateUserWithdrawAddressRequest
	lastCountry         *userv1.ChangeUserCountryRequest
	lastAdminCountry    *userv1.AdminChangeUserCountryRequest
	lastCreateBlock     *userv1.CreateManagerUserBlockRequest
	lastListBlocks      *userv1.ListManagerUserBlocksRequest
	lastUnblock         *userv1.UnblockManagerUserRequest
	getErr              error
	statsResp           *userv1.GetMyProfileStatsResponse
	businessLookupResp  *userv1.BusinessUserLookupResponse
	businessLookupErr   error
	blocksResp          *userv1.ListManagerUserBlocksResponse
	createBlockResp     *userv1.CreateManagerUserBlockResponse
	unblockResp         *userv1.UnblockManagerUserResponse
	statsErr            error
	completeErr         error
	countryErr          error
	blockErr            error
	regionID            int64
	withdrawAddress     string
	regionByUserID      map[int64]int64
	usersByID           map[int64]*userv1.User
}

type fakeTencentIMAccountImporter struct {
	lastUserID   int64
	lastNickname string
	lastFaceURL  string
	userIDs      []int64
	err          error
}

func (f *fakeTencentIMAccountImporter) ImportAccount(_ context.Context, userID int64, nickname string, faceURL string) error {
	f.lastUserID = userID
	f.lastNickname = nickname
	f.lastFaceURL = faceURL
	f.userIDs = append(f.userIDs, userID)
	return f.err
}

type fakeTencentIMUserMessagePublisher struct {
	messages []tencentim.CustomUserMessage
	err      error
}

func (f *fakeTencentIMUserMessagePublisher) PublishUserCustomMessage(_ context.Context, message tencentim.CustomUserMessage) error {
	f.messages = append(f.messages, message)
	return f.err
}

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

type fakeRoomQueryClient struct {
	lastList             *roomv1.ListRoomsRequest
	lastListOwners       *roomv1.ListRoomsByOwnersRequest
	lastFeeds            *roomv1.ListRoomFeedsRequest
	lastMyRoom           *roomv1.GetMyRoomRequest
	lastCurrent          *roomv1.GetCurrentRoomRequest
	lastVoicePresence    *roomv1.BatchGetUserVoiceRoomPresencesRequest
	lastSnapshot         *roomv1.GetRoomSnapshotRequest
	lastBackgrounds      *roomv1.ListRoomBackgroundsRequest
	lastRocket           *roomv1.GetRoomRocketRequest
	lastOnline           *roomv1.ListRoomOnlineUsersRequest
	lastBannedUsers      *roomv1.ListRoomBannedUsersRequest
	lastContributionRank *roomv1.ListRoomContributionRankRequest
	resp                 *roomv1.ListRoomsResponse
	ownersResp           *roomv1.ListRoomsResponse
	feedsResp            *roomv1.ListRoomsResponse
	myRoomResp           *roomv1.GetMyRoomResponse
	currentResp          *roomv1.GetCurrentRoomResponse
	voicePresenceResp    *roomv1.BatchGetUserVoiceRoomPresencesResponse
	snapshotResp         *roomv1.GetRoomSnapshotResponse
	backgroundsResp      *roomv1.ListRoomBackgroundsResponse
	rocketResp           *roomv1.GetRoomRocketResponse
	onlineResp           *roomv1.ListRoomOnlineUsersResponse
	bannedUsersResp      *roomv1.ListRoomBannedUsersResponse
	contributionRankResp *roomv1.ListRoomContributionRankResponse
	err                  error
	ownersErr            error
	feedsErr             error
	myRoomErr            error
	currentErr           error
	voicePresenceErr     error
	snapshotErr          error
	backgroundsErr       error
	rocketErr            error
	onlineErr            error
	bannedUsersErr       error
	contributionRankErr  error
}

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

type fakeStatisticsClient struct {
	lastAppEvents []gatewayclient.AppTrackingEvent
	appResult     gatewayclient.AppTrackingReportResult
	appErr        error
	selfEvents    []gatewayclient.SelfGameH5Event
	selfErr       error
}

func (f *fakeStatisticsClient) ReportSelfGameH5Event(_ context.Context, event gatewayclient.SelfGameH5Event) error {
	f.selfEvents = append(f.selfEvents, event)
	return f.selfErr
}

func (f *fakeStatisticsClient) ReportAppTrackingEvents(_ context.Context, events []gatewayclient.AppTrackingEvent) (gatewayclient.AppTrackingReportResult, error) {
	f.lastAppEvents = append([]gatewayclient.AppTrackingEvent(nil), events...)
	if f.appErr != nil {
		return gatewayclient.AppTrackingReportResult{}, f.appErr
	}
	if f.appResult.ServerTimeMS == 0 {
		f.appResult = gatewayclient.AppTrackingReportResult{Accepted: true, Received: len(events), Stored: len(events), Duplicated: 0, ServerTimeMS: 1_778_000_005_000}
	}
	return f.appResult, nil
}

type fakeUserCountryQueryClient struct {
	last        *userv1.ListRegistrationCountriesRequest
	resp        *userv1.ListRegistrationCountriesResponse
	err         error
	lastBlocked *userv1.ListLoginRiskBlockedCountriesRequest
	blockedResp *userv1.ListLoginRiskBlockedCountriesResponse
	blockedErr  error
}

type fakeUserRegionClient struct {
	lastList *userv1.ListRegionsRequest
	listResp *userv1.ListRegionsResponse
	listErr  error
	last     *userv1.GetRegionRequest
	resp     *userv1.RegionResponse
	err      error
}

type fakeUserHostClient struct {
	lastSearchAgencies  *userv1.SearchAgenciesRequest
	searchAgenciesResp  *userv1.SearchAgenciesResponse
	searchAgenciesErr   error
	lastApplyAgency     *userv1.ApplyToAgencyRequest
	applyAgencyResp     *userv1.ApplyToAgencyResponse
	applyAgencyErr      error
	lastInviteAgency    *userv1.InviteAgencyRequest
	inviteAgencyResp    *userv1.InviteAgencyResponse
	inviteAgencyErr     error
	lastInviteBD        *userv1.InviteBDRequest
	inviteBDResp        *userv1.InviteBDResponse
	inviteBDErr         error
	lastInviteHost      *userv1.InviteHostRequest
	inviteHostResp      *userv1.InviteHostResponse
	inviteHostErr       error
	lastLeaderBDs       *userv1.ListBDLeaderBDsRequest
	leaderBDsResp       *userv1.ListBDLeaderBDsResponse
	leaderBDsErr        error
	lastLeaderAgencies  *userv1.ListBDLeaderAgenciesRequest
	leaderAgenciesResp  *userv1.ListBDLeaderAgenciesResponse
	leaderAgenciesErr   error
	lastBDAgencies      *userv1.ListBDAgenciesRequest
	bdAgenciesResp      *userv1.ListBDAgenciesResponse
	bdAgenciesErr       error
	lastManagerTeam     *userv1.ListManagerTeamAgenciesRequest
	managerTeamResp     *userv1.ListManagerTeamAgenciesResponse
	managerTeamErr      error
	lastRoleInvitations *userv1.ListRoleInvitationsRequest
	roleInvitationsResp *userv1.ListRoleInvitationsResponse
	roleInvitationsErr  error
	lastProcessInvite   *userv1.ProcessRoleInvitationRequest
	processInviteResp   *userv1.ProcessRoleInvitationResponse
	processInviteErr    error
	last                *userv1.GetCoinSellerProfileRequest
	profile             *userv1.CoinSellerProfile
	err                 error
	lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest
	listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
	listCoinSellersErr  error
	lastCreateSubSeller *userv1.CreateSubCoinSellerRequest
	createSubSellerResp *userv1.CreateSubCoinSellerResponse
	createSubSellerErr  error
	lastSubmitSubApp    *userv1.SubmitSubCoinSellerApplicationRequest
	submitSubAppResp    *userv1.SubmitSubCoinSellerApplicationResponse
	submitSubAppErr     error
	lastListSubSellers  *userv1.ListSubCoinSellersRequest
	listSubSellersResp  *userv1.ListSubCoinSellersResponse
	listSubSellersErr   error
	lastCheckSubSeller  *userv1.CheckCoinSellerSubRelationRequest
	checkSubSellerResp  *userv1.CheckCoinSellerSubRelationResponse
	checkSubSellerErr   error
	lastHost            *userv1.GetHostProfileRequest
	lastBatchHost       *userv1.BatchGetHostProfilesRequest
	hostCalls           int
	batchHostCalls      int
	hostProfile         *userv1.HostProfile
	hostProfiles        map[int64]*userv1.HostProfile
	hostErr             error
	batchHostErr        error
	lastBD              *userv1.GetBDProfileRequest
	bdProfile           *userv1.BDProfile
	bdErr               error
	lastRoleSummary     *userv1.GetUserRoleSummaryRequest
	roleSummary         *userv1.UserRoleSummary
	roleSummaryErr      error
	lastGetAgency       *userv1.GetAgencyRequest
	getAgencyResp       *userv1.GetAgencyResponse
	getAgencyErr        error
	lastUpdateAgency    *userv1.UpdateAgencyProfileRequest
	updateAgencyResp    *userv1.UpdateAgencyProfileResponse
	updateAgencyErr     error
	lastMembers         *userv1.GetAgencyMembersRequest
	membersResp         *userv1.GetAgencyMembersResponse
	membersErr          error
	lastApplications    *userv1.GetAgencyApplicationsRequest
	applicationsResp    *userv1.GetAgencyApplicationsResponse
	applicationsErr     error
	lastReview          *userv1.ReviewAgencyApplicationRequest
	reviewResp          *userv1.ReviewAgencyApplicationResponse
	reviewErr           error
	lastKick            *userv1.KickAgencyHostRequest
	kickResp            *userv1.KickAgencyHostResponse
	kickErr             error
	lastCapability      *userv1.CheckBusinessCapabilityRequest
	capabilities        []string
	capabilityAllowed   map[string]bool
	capabilityResp      *userv1.CheckBusinessCapabilityResponse
	capabilityErr       error
	lastRoleScopePolicy *userv1.GetRoleScopePolicyRequest
	roleScopePolicyResp *userv1.GetRoleScopePolicyResponse
	roleScopePolicyErr  error
}

type fakeUserHostAdminClient struct {
	lastCreateBDLeader *userv1.CreateBDLeaderRequest
	createBDLeaderResp *userv1.CreateBDLeaderResponse
	createBDLeaderErr  error
}

type fakeWalletClient struct {
	last                    *walletv1.GetBalancesRequest
	balanceRequests         []*walletv1.GetBalancesRequest
	resp                    *walletv1.GetBalancesResponse
	balancesByUserID        map[int64]*walletv1.GetBalancesResponse
	err                     error
	lastDirectGift          *walletv1.DebitDirectGiftRequest
	directGiftRequests      []*walletv1.DebitDirectGiftRequest
	directGiftResp          *walletv1.DebitGiftResponse
	directGiftErr           error
	lastHostSalaryPolicy    *walletv1.GetActiveHostSalaryPolicyRequest
	hostSalaryPolicyResp    *walletv1.GetActiveHostSalaryPolicyResponse
	hostSalaryPolicyErr     error
	lastHostSalaryProgress  *walletv1.GetHostSalaryProgressRequest
	hostSalaryProgressResp  *walletv1.GetHostSalaryProgressResponse
	hostSalaryProgressErr   error
	lastTeamSalaryStats     *walletv1.GetTeamHostSalaryStatsRequest
	teamSalaryStatsResp     *walletv1.GetTeamHostSalaryStatsResponse
	teamSalaryStatsErr      error
	lastOverview            *walletv1.GetWalletOverviewRequest
	overviewResp            *walletv1.GetWalletOverviewResponse
	overviewErr             error
	lastValueSummary        *walletv1.GetWalletValueSummaryRequest
	valueSummaryResp        *walletv1.GetWalletValueSummaryResponse
	valueSummaryErr         error
	lastGiftWall            *walletv1.GetUserGiftWallRequest
	giftWallResp            *walletv1.GetUserGiftWallResponse
	lastRechargeProducts    *walletv1.ListRechargeProductsRequest
	rechargeProductsResp    *walletv1.ListRechargeProductsResponse
	lastThirdPartyChannels  *walletv1.ListThirdPartyPaymentChannelsRequest
	thirdPartyChannelsResp  *walletv1.ListThirdPartyPaymentChannelsResponse
	lastH5Options           *walletv1.H5RechargeOptionsRequest
	h5OptionsResp           *walletv1.H5RechargeOptionsResponse
	lastH5CreateOrder       *walletv1.CreateH5RechargeOrderRequest
	h5CreateOrderResp       *walletv1.H5RechargeOrderResponse
	lastTemporaryCreate     *walletv1.CreateTemporaryRechargeOrderRequest
	temporaryCreateResp     *walletv1.H5RechargeOrderResponse
	lastTemporaryGet        *walletv1.GetTemporaryRechargeOrderRequest
	temporaryGetResp        *walletv1.H5RechargeOrderResponse
	lastTemporaryList       *walletv1.ListTemporaryRechargeOrdersRequest
	temporaryListResp       *walletv1.ListTemporaryRechargeOrdersResponse
	lastH5SubmitTx          *walletv1.SubmitH5RechargeTxRequest
	h5SubmitTxResp          *walletv1.H5RechargeOrderResponse
	lastH5GetOrder          *walletv1.GetH5RechargeOrderRequest
	h5GetOrderResp          *walletv1.H5RechargeOrderResponse
	lastMifaPayNotify       *walletv1.HandleMifapayNotifyRequest
	mifaPayNotifyResp       *walletv1.HandleMifapayNotifyResponse
	lastV5PayNotify         *walletv1.HandleV5PayNotifyRequest
	v5PayNotifyResp         *walletv1.HandleV5PayNotifyResponse
	lastGoogleConfirm       *walletv1.ConfirmGooglePaymentRequest
	googleConfirmResp       *walletv1.ConfirmGooglePaymentResponse
	lastDiamondExchange     *walletv1.GetDiamondExchangeConfigRequest
	diamondExchangeResp     *walletv1.GetDiamondExchangeConfigResponse
	lastPointExchange       *walletv1.ExchangePointToCoinRequest
	pointExchangeResp       *walletv1.ExchangePointToCoinResponse
	lastTransactions        *walletv1.ListWalletTransactionsRequest
	transactionsResp        *walletv1.ListWalletTransactionsResponse
	lastDiamondTransactions *walletv1.ListWalletTransactionsRequest
	diamondTransactionsResp *walletv1.ListWalletTransactionsResponse
	lastVipPackages         *walletv1.ListVipPackagesRequest
	vipPackagesResp         *walletv1.ListVipPackagesResponse
	lastMyVip               *walletv1.GetMyVipRequest
	myVipResp               *walletv1.GetMyVipResponse
	lastUpdateVIPSettings   *walletv1.UpdateMyVipSettingsRequest
	updateVIPSettingsResp   *walletv1.UpdateMyVipSettingsResponse
	vipErr                  error
	lastVipProgram          *walletv1.GetVipProgramConfigRequest
	vipProgramResp          *walletv1.GetVipProgramConfigResponse
	lastBenefitCheck        *walletv1.CheckVipBenefitRequest
	benefitCheckResp        *walletv1.CheckVipBenefitResponse
	lastPurchaseVip         *walletv1.PurchaseVipRequest
	purchaseVipResp         *walletv1.PurchaseVipResponse
	lastGrantVip            *walletv1.GrantVipRequest
	grantVipResp            *walletv1.GrantVipResponse
	lastTrialGrant          *walletv1.GrantVipTrialCardRequest
	trialGrantResp          *walletv1.GrantVipTrialCardResponse
	lastTrialEquip          *walletv1.EquipVipTrialCardRequest
	trialEquipResp          *walletv1.EquipVipTrialCardResponse
	lastTrialUnequip        *walletv1.UnequipVipTrialCardRequest
	trialUnequipResp        *walletv1.UnequipVipTrialCardResponse
	lastCurrentVIPRebate    *walletv1.GetMyCurrentVipDailyCoinRebateRequest
	currentVIPRebateResp    *walletv1.GetMyCurrentVipDailyCoinRebateResponse
	lastVIPRebateStatuses   *walletv1.ListMyVipDailyCoinRebateStatusesRequest
	vipRebateStatusesResp   *walletv1.ListMyVipDailyCoinRebateStatusesResponse
	lastClaimVIPRebate      *walletv1.ClaimVipDailyCoinRebateRequest
	claimVIPRebateResp      *walletv1.ClaimVipDailyCoinRebateResponse
	lastTransfer            *walletv1.TransferCoinFromSellerRequest
	transferResp            *walletv1.TransferCoinFromSellerResponse
	transferErr             error
	lastSubTransfer         *walletv1.TransferCoinSellerStockToChildRequest
	subTransferResp         *walletv1.TransferCoinSellerStockToChildResponse
	subTransferErr          error
	lastListExchangeTiers   *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest
	listExchangeTiersResp   *walletv1.ListCoinSellerSalaryExchangeRateTiersResponse
	lastExchangeSalary      *walletv1.ExchangeSalaryToCoinRequest
	exchangeSalaryResp      *walletv1.ExchangeSalaryToCoinResponse
	exchangeSalaryErr       error
	lastTransferSalary      *walletv1.TransferSalaryToCoinSellerRequest
	transferSalaryResp      *walletv1.TransferSalaryToCoinSellerResponse
	transferSalaryErr       error
	lastFreezeSalary        *walletv1.FreezeSalaryWithdrawalRequest
	freezeSalaryResp        *walletv1.FreezeSalaryWithdrawalResponse
	freezeSalaryErr         error
	lastReleaseSalary       *walletv1.ReleaseSalaryWithdrawalRequest
	releaseSalaryResp       *walletv1.ReleaseSalaryWithdrawalResponse
	releaseSalaryErr        error
	lastListResources       *walletv1.ListResourcesRequest
	listResourcesResp       *walletv1.ListResourcesResponse
	lastGetResource         *walletv1.GetResourceRequest
	resourcesByID           map[int64]*walletv1.Resource
	lastGetResourceGroup    *walletv1.GetResourceGroupRequest
	resourceGroupsByID      map[int64]*walletv1.ResourceGroup
	lastResourceShop        *walletv1.ListResourceShopItemsRequest
	resourceShopResp        *walletv1.ListResourceShopItemsResponse
	lastPurchaseShop        *walletv1.PurchaseResourceShopItemRequest
	purchaseShopResp        *walletv1.PurchaseResourceShopItemResponse
	lastGetGiftCatalog      *walletv1.GetGiftCatalogVersionRequest
	getGiftCatalogCalls     int
	giftCatalogVersion      int64
	giftCatalogVersionResp  *walletv1.GetGiftCatalogVersionResponse
	giftCatalogVersionErr   error
	lastListGiftConfigs     *walletv1.ListGiftConfigsRequest
	listGiftConfigsCalls    int
	listGiftConfigsResp     *walletv1.ListGiftConfigsResponse
	lastListGiftTypes       *walletv1.ListGiftTypeConfigsRequest
	listGiftTypesCalls      int
	listGiftTypesResp       *walletv1.ListGiftTypeConfigsResponse
	lastListUserResources   *walletv1.ListUserResourcesRequest
	listUserResourcesCalls  int
	listUserResourcesResp   *walletv1.ListUserResourcesResponse
	lastEquipUserResource   *walletv1.EquipUserResourceRequest
	lastGrantResource       *walletv1.GrantResourceRequest
	grantResourceRequests   []*walletv1.GrantResourceRequest
	grantResourceResp       *walletv1.ResourceGrantResponse
	grantResourceErr        error
	lastBatchEquipped       *walletv1.BatchGetUserEquippedResourcesRequest
	batchEquippedRequests   []*walletv1.BatchGetUserEquippedResourcesRequest
	batchEquippedResp       *walletv1.BatchGetUserEquippedResourcesResponse
	batchEquippedBudget     time.Duration
	lastListRedPackets      *walletv1.ListRedPacketsRequest
	listRedPacketsResp      *walletv1.ListRedPacketsResponse
	lastGetRedPacket        *walletv1.GetRedPacketRequest
	getRedPacketResp        *walletv1.GetRedPacketResponse
	lastClaimRedPacket      *walletv1.ClaimRedPacketRequest
	claimRedPacketResp      *walletv1.ClaimRedPacketResponse
}

type fakeWithdrawalApplicationWriter struct {
	last  financewithdrawal.CreateApplicationCommand
	calls int
	resp  financewithdrawal.Application
	err   error
}

type fakeWithdrawalApplicationNotifier struct {
	last  financewithdrawal.Application
	calls int
	err   error
}

func (f *fakeWithdrawalApplicationNotifier) NotifyApplicationCreated(_ context.Context, application financewithdrawal.Application) error {
	f.last = application
	f.calls++
	return f.err
}

func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, command financewithdrawal.CreateApplicationCommand) (financewithdrawal.Application, error) {
	f.last = command
	f.calls++
	if f.err != nil {
		return financewithdrawal.Application{}, f.err
	}
	resp := f.resp
	if resp.ID == 0 {
		resp.ID = 77
	}
	if resp.AppCode == "" {
		resp.AppCode = command.AppCode
	}
	if resp.UserID == 0 {
		resp.UserID = command.UserID
	}
	if resp.SalaryAssetType == "" {
		resp.SalaryAssetType = command.SalaryAssetType
	}
	if resp.WithdrawAmount == "" {
		resp.WithdrawAmount = command.WithdrawAmount
	}
	if resp.WithdrawAmountMinor == 0 {
		resp.WithdrawAmountMinor = command.WithdrawAmountMinor
	}
	if resp.WithdrawMethod == "" {
		resp.WithdrawMethod = command.WithdrawMethod
	}
	if resp.WithdrawAddress == "" {
		resp.WithdrawAddress = command.WithdrawAddress
	}
	if resp.FreezeCommandID == "" {
		resp.FreezeCommandID = command.FreezeCommandID
	}
	if resp.FreezeTransactionID == "" {
		resp.FreezeTransactionID = command.FreezeTransactionID
	}
	if resp.Status == "" {
		resp.Status = financewithdrawal.StatusPending
	}
	if resp.OperationsStatus == "" {
		resp.OperationsStatus = financewithdrawal.OperationsStatusPending
	}
	if resp.CreatedAtMS == 0 {
		resp.CreatedAtMS = command.CreatedAtMS
	}
	return resp, nil
}

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 fakeAchievementClient struct {
	lastList       *activityv1.ListAchievementsRequest
	lastBadges     *activityv1.ListMyBadgesRequest
	lastSetDisplay *activityv1.SetBadgeDisplayRequest
	listResp       *activityv1.ListAchievementsResponse
	badgesResp     *activityv1.ListMyBadgesResponse
	setResp        *activityv1.SetBadgeDisplayResponse
	err            error
}

type fakeWeeklyStarClient struct {
	lastCurrent     *activityv1.GetWeeklyStarCurrentRequest
	currentResp     *activityv1.GetWeeklyStarCurrentResponse
	lastLeaderboard *activityv1.ListWeeklyStarLeaderboardRequest
	leaderboardResp *activityv1.ListWeeklyStarLeaderboardResponse
	lastHistory     *activityv1.ListWeeklyStarHistoryRequest
	historyResp     *activityv1.ListWeeklyStarHistoryResponse
	err             error
}

type fakeCPWeeklyRankClient struct {
	lastStatus *activityv1.GetCPWeeklyRankStatusRequest
	statusResp *activityv1.GetCPWeeklyRankStatusResponse
	err        error
}

type fakeUserCPClient struct {
	listIntimacyLeaderboardCalls int
}

type fakeBroadcastClient struct {
	lastGlobal *activityv1.PublishGlobalBroadcastRequest
	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,
		DisplayUserId:    "100001",
		SessionId:        "sess-password",
		AccessToken:      "access-password",
		RefreshToken:     "refresh-password",
		ExpiresInSec:     testAppAccessTokenTTLSec,
		TokenType:        "Bearer",
		ProfileCompleted: true,
		OnboardingStatus: "completed",
	}}, nil
}

func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) {
	f.lastLoginThirdParty = req
	f.loginThirdCount++
	if f.loginErr != nil {
		return nil, f.loginErr
	}
	if f.loginThirdResp != nil {
		return f.loginThirdResp, nil
	}

	return &userv1.AuthResponse{
		Token: &userv1.AuthToken{
			UserId:           10001,
			DisplayUserId:    "100001",
			SessionId:        "sess-1",
			AccessToken:      "access-1",
			RefreshToken:     "refresh-1",
			ExpiresInSec:     testAppAccessTokenTTLSec,
			TokenType:        "Bearer",
			ProfileCompleted: false,
			OnboardingStatus: "profile_required",
		},
		IsNewUser:        true,
		ProfileCompleted: false,
		OnboardingStatus: "profile_required",
	}, nil
}

func (f *fakeUserAuthClient) RegisterThirdParty(_ context.Context, req *userv1.RegisterThirdPartyRequest) (*userv1.AuthResponse, error) {
	f.lastRegisterThird = req
	f.loginThirdCount++
	if f.loginErr != nil {
		return nil, f.loginErr
	}

	return &userv1.AuthResponse{
		Token: &userv1.AuthToken{
			UserId:           10001,
			DisplayUserId:    "100001",
			SessionId:        "sess-register",
			AccessToken:      "access-register",
			RefreshToken:     "refresh-register",
			ExpiresInSec:     testAppAccessTokenTTLSec,
			TokenType:        "Bearer",
			ProfileCompleted: true,
			OnboardingStatus: "completed",
		},
		IsNewUser:        true,
		ProfileCompleted: true,
		OnboardingStatus: "completed",
	}, nil
}

func (f *fakeUserAuthClient) CheckThirdPartyRegistered(_ context.Context, req *userv1.CheckThirdPartyRegisteredRequest) (*userv1.CheckThirdPartyRegisteredResponse, error) {
	f.lastCheckThird = req
	if f.loginErr != nil {
		return nil, f.loginErr
	}
	return &userv1.CheckThirdPartyRegisteredResponse{Registered: f.checkRegistered}, 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) QuickCreateAccount(_ context.Context, req *userv1.QuickCreateAccountRequest) (*userv1.QuickCreateAccountResponse, error) {
	f.lastQuickCreate = req
	return &userv1.QuickCreateAccountResponse{Token: &userv1.AuthToken{
		UserId:           10001,
		DisplayUserId:    "100001",
		SessionId:        "sess-quick",
		AccessToken:      "access-quick",
		RefreshToken:     "refresh-quick",
		ExpiresInSec:     testAppAccessTokenTTLSec,
		TokenType:        "Bearer",
		ProfileCompleted: true,
		OnboardingStatus: "completed",
	}, 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: testAppAccessTokenTTLSec, 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 *fakeUserAuthClient) CheckLoginRiskIPWhitelist(_ context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error) {
	f.lastWhitelist = req
	return &userv1.CheckLoginRiskIPWhitelistResponse{Whitelisted: f.whitelisted}, nil
}

func (f *fakeUserAuthClient) AppHeartbeat(_ context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error) {
	f.lastAppHeartbeat = req
	return &userv1.AppHeartbeatResponse{Accepted: true, HeartbeatAtMs: 1_778_000_005_000, ServerTimeMs: 1_778_000_005_000, Token: &userv1.AuthToken{
		UserId:           req.GetUserId(),
		SessionId:        req.GetSessionId(),
		AccessToken:      "access-heartbeat",
		ExpiresInSec:     testAppAccessTokenTTLSec,
		TokenType:        "Bearer",
		ProfileCompleted: true,
		OnboardingStatus: "completed",
		AppCode:          req.GetMeta().GetAppCode(),
	}}, 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
	}
	if f.usersByID != nil {
		if user := f.usersByID[req.GetUserId()]; user != nil {
			return &userv1.GetUserResponse{User: user}, nil
		}
	}
	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",
		CountryId:                840,
		Country:                  "US",
		CountryName:              "United States",
		RegionId:                 regionID,
		WithdrawUsdtTrc20Address: f.withdrawAddress,
		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
	f.batchRequests = append(f.batchRequests, req)
	users := make(map[int64]*userv1.User, len(req.GetUserIds()))
	for _, userID := range req.GetUserIds() {
		if f.usersByID != nil {
			if user, ok := f.usersByID[userID]; ok {
				users[userID] = user
				continue
			}
		}
		users[userID] = &userv1.User{
			UserId:             userID,
			Status:             userv1.UserStatus_USER_STATUS_ACTIVE,
			DisplayUserId:      strconv.FormatInt(100000+userID, 10),
			Username:           "user-" + strconv.FormatInt(userID, 10),
			Gender:             "female",
			Birth:              "2000-01-02",
			CountryId:          840,
			Country:            "US",
			CountryName:        "United States",
			CountryDisplayName: "United States",
			Avatar:             "https://cdn.example/avatar.png",
			RegionId:           1001,
			AppCode:            req.GetMeta().GetAppCode(),
		}
	}
	return &userv1.BatchGetUsersResponse{Users: users}, nil
}

func (f *fakeUserProfileClient) BatchGetRoomBasicUsers(_ context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
	f.lastRoomBasicBatch = req
	f.roomBasicRequests = append(f.roomBasicRequests, req)
	users := make(map[int64]*userv1.RoomBasicUser, len(req.GetUserIds()))
	for _, userID := range req.GetUserIds() {
		if f.usersByID != nil {
			if user, ok := f.usersByID[userID]; ok {
				users[userID] = &userv1.RoomBasicUser{
					UserId:        user.GetUserId(),
					Username:      user.GetUsername(),
					Avatar:        user.GetAvatar(),
					DisplayUserId: user.GetDisplayUserId(),
				}
				continue
			}
		}
		users[userID] = &userv1.RoomBasicUser{
			UserId:        userID,
			Username:      "user-" + strconv.FormatInt(userID, 10),
			Avatar:        "https://cdn.example/avatar.png",
			DisplayUserId: strconv.FormatInt(100000+userID, 10),
		}
	}
	return &userv1.BatchGetRoomBasicUsersResponse{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:     testAppAccessTokenTTLSec,
		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) UpdateUserProfileBackground(_ context.Context, req *userv1.UpdateUserProfileBackgroundRequest) (*userv1.UpdateUserProfileBackgroundResponse, error) {
	f.lastBackground = req
	return &userv1.UpdateUserProfileBackgroundResponse{User: &userv1.User{
		UserId:       req.GetUserId(),
		ProfileBgImg: req.GetProfileBgImg(),
		UpdatedAtMs:  2100,
	}}, nil
}

func (f *fakeUserProfileClient) UpdateUserContactInfo(_ context.Context, req *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
	f.lastContact = req
	return &userv1.UpdateUserContactInfoResponse{User: &userv1.User{
		UserId:      req.GetUserId(),
		ContactInfo: req.GetContactInfo(),
		UpdatedAtMs: 2200,
	}}, nil
}

func (f *fakeUserProfileClient) UpdateUserWithdrawAddress(_ context.Context, req *userv1.UpdateUserWithdrawAddressRequest) (*userv1.UpdateUserWithdrawAddressResponse, error) {
	f.lastWithdrawAddress = req
	f.withdrawAddress = req.GetUsdtTrc20Address()
	return &userv1.UpdateUserWithdrawAddressResponse{User: &userv1.User{
		UserId:                   req.GetUserId(),
		WithdrawUsdtTrc20Address: req.GetUsdtTrc20Address(),
		UpdatedAtMs:              2300,
	}}, 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 *fakeUserProfileClient) AdminChangeUserCountry(_ context.Context, req *userv1.AdminChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
	f.lastAdminCountry = req
	if f.countryErr != nil {
		return nil, f.countryErr
	}
	return &userv1.ChangeUserCountryResponse{User: &userv1.User{
		UserId:        req.GetTargetUserId(),
		DisplayUserId: "100001",
		Country:       req.GetCountry(),
		RegionId:      2002,
		Status:        userv1.UserStatus_USER_STATUS_ACTIVE,
		UpdatedAtMs:   3000,
	}, OldRegionId: 1001, NewRegionId: 2002, RegionChanged: true}, nil
}

func (f *fakeUserProfileClient) CreateManagerUserBlock(_ context.Context, req *userv1.CreateManagerUserBlockRequest) (*userv1.CreateManagerUserBlockResponse, error) {
	f.lastCreateBlock = req
	if f.blockErr != nil {
		return nil, f.blockErr
	}
	if f.createBlockResp != nil {
		return f.createBlockResp, nil
	}
	return &userv1.CreateManagerUserBlockResponse{Block: &userv1.ManagerUserBlock{
		BlockId:             "block-1",
		ManagerUserId:       req.GetManagerUserId(),
		TargetUserId:        req.GetTargetUserId(),
		TargetDisplayUserId: "163006",
		TargetUsername:      "blocked",
		Country:             "US",
		BlockedUntilMs:      req.GetBlockedUntilMs(),
		Status:              "active",
		Reason:              req.GetReason(),
	}}, nil
}

func (f *fakeUserProfileClient) ListManagerUserBlocks(_ context.Context, req *userv1.ListManagerUserBlocksRequest) (*userv1.ListManagerUserBlocksResponse, error) {
	f.lastListBlocks = req
	if f.blockErr != nil {
		return nil, f.blockErr
	}
	if f.blocksResp != nil {
		return f.blocksResp, nil
	}
	return &userv1.ListManagerUserBlocksResponse{}, nil
}

func (f *fakeUserProfileClient) UnblockManagerUser(_ context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error) {
	f.lastUnblock = req
	if f.blockErr != nil {
		return nil, f.blockErr
	}
	if f.unblockResp != nil {
		return f.unblockResp, nil
	}
	return &userv1.UnblockManagerUserResponse{Block: &userv1.ManagerUserBlock{
		BlockId:       req.GetBlockId(),
		ManagerUserId: req.GetManagerUserId(),
		Status:        "released",
	}}, 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 *fakeUserIdentityClient) ListAvailablePrettyDisplayIDs(_ context.Context, req *userv1.ListAvailablePrettyDisplayIDsRequest) (*userv1.ListAvailablePrettyDisplayIDsResponse, error) {
	return &userv1.ListAvailablePrettyDisplayIDsResponse{
		Items: []*userv1.PrettyDisplayID{{
			PrettyId:      "pretty-test",
			DisplayUserId: "VIP2026",
			Status:        "available",
			Source:        "pool",
			AppCode:       req.GetMeta().GetAppCode(),
		}},
		Total: 1,
	}, nil
}

func (f *fakeUserIdentityClient) ApplyPrettyDisplayIDFromPool(_ context.Context, req *userv1.ApplyPrettyDisplayIDFromPoolRequest) (*userv1.ApplyPrettyDisplayIDFromPoolResponse, error) {
	return &userv1.ApplyPrettyDisplayIDFromPoolResponse{Identity: &userv1.UserIdentity{
		UserId:              req.GetUserId(),
		DisplayUserId:       "VIP2026",
		PrettyId:            req.GetPrettyId(),
		PrettyDisplayUserId: "VIP2026",
		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) ListRoomsByOwners(_ context.Context, req *roomv1.ListRoomsByOwnersRequest) (*roomv1.ListRoomsResponse, error) {
	f.lastListOwners = req
	if f.ownersErr != nil {
		return nil, f.ownersErr
	}
	if f.ownersResp != nil {
		return f.ownersResp, 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) ListRoomGiftLeaderboard(context.Context, *roomv1.ListRoomGiftLeaderboardRequest) (*roomv1.ListRoomGiftLeaderboardResponse, error) {
	return &roomv1.ListRoomGiftLeaderboardResponse{}, nil
}

func (f *fakeRoomQueryClient) ListRoomContributionRank(_ context.Context, req *roomv1.ListRoomContributionRankRequest) (*roomv1.ListRoomContributionRankResponse, error) {
	f.lastContributionRank = req
	if f.contributionRankErr != nil {
		return nil, f.contributionRankErr
	}
	if f.contributionRankResp != nil {
		return f.contributionRankResp, nil
	}
	return &roomv1.ListRoomContributionRankResponse{}, 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) BatchGetUserVoiceRoomPresences(_ context.Context, req *roomv1.BatchGetUserVoiceRoomPresencesRequest) (*roomv1.BatchGetUserVoiceRoomPresencesResponse, error) {
	f.lastVoicePresence = req
	if f.voicePresenceErr != nil {
		return nil, f.voicePresenceErr
	}
	if f.voicePresenceResp != nil {
		return f.voicePresenceResp, nil
	}
	return &roomv1.BatchGetUserVoiceRoomPresencesResponse{}, 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) ListRoomBackgrounds(_ context.Context, req *roomv1.ListRoomBackgroundsRequest) (*roomv1.ListRoomBackgroundsResponse, error) {
	f.lastBackgrounds = req
	if f.backgroundsErr != nil {
		return nil, f.backgroundsErr
	}
	if f.backgroundsResp != nil {
		return f.backgroundsResp, nil
	}
	return &roomv1.ListRoomBackgroundsResponse{}, nil
}

func (f *fakeRoomQueryClient) GetRoomRocket(_ context.Context, req *roomv1.GetRoomRocketRequest) (*roomv1.GetRoomRocketResponse, error) {
	f.lastRocket = req
	if f.rocketErr != nil {
		return nil, f.rocketErr
	}
	if f.rocketResp != nil {
		return f.rocketResp, nil
	}
	return &roomv1.GetRoomRocketResponse{}, 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 *fakeRoomQueryClient) ListRoomBannedUsers(_ context.Context, req *roomv1.ListRoomBannedUsersRequest) (*roomv1.ListRoomBannedUsersResponse, error) {
	f.lastBannedUsers = req
	if f.bannedUsersErr != nil {
		return nil, f.bannedUsersErr
	}
	if f.bannedUsersResp != nil {
		return f.bannedUsersResp, nil
	}
	return &roomv1.ListRoomBannedUsersResponse{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 *fakeUserCountryQueryClient) ListLoginRiskBlockedCountries(_ context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error) {
	f.lastBlocked = req
	if f.blockedErr != nil {
		return nil, f.blockedErr
	}
	if f.blockedResp != nil {
		return f.blockedResp, nil
	}

	return &userv1.ListLoginRiskBlockedCountriesResponse{}, nil
}

func (f *fakeUserRegionClient) ListRegions(_ context.Context, req *userv1.ListRegionsRequest) (*userv1.ListRegionsResponse, error) {
	f.lastList = req
	if f.listErr != nil {
		return nil, f.listErr
	}
	if f.listResp != nil {
		return f.listResp, nil
	}

	return &userv1.ListRegionsResponse{}, nil
}

func (f *fakeUserRegionClient) GetRegion(_ context.Context, req *userv1.GetRegionRequest) (*userv1.RegionResponse, error) {
	f.last = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resp != nil {
		return f.resp, nil
	}

	return &userv1.RegionResponse{Region: &userv1.Region{RegionId: req.GetRegionId(), Countries: []string{"US"}}}, 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 *fakeAchievementClient) ListAchievements(_ context.Context, req *activityv1.ListAchievementsRequest) (*activityv1.ListAchievementsResponse, error) {
	f.lastList = req
	if f.err != nil {
		return nil, f.err
	}
	if f.listResp != nil {
		return f.listResp, nil
	}
	return &activityv1.ListAchievementsResponse{}, nil
}

func (f *fakeAchievementClient) ListMyBadges(_ context.Context, req *activityv1.ListMyBadgesRequest) (*activityv1.ListMyBadgesResponse, error) {
	f.lastBadges = req
	if f.err != nil {
		return nil, f.err
	}
	if f.badgesResp != nil {
		return f.badgesResp, nil
	}
	return &activityv1.ListMyBadgesResponse{}, nil
}

func (f *fakeAchievementClient) SetBadgeDisplay(_ context.Context, req *activityv1.SetBadgeDisplayRequest) (*activityv1.SetBadgeDisplayResponse, error) {
	f.lastSetDisplay = req
	if f.err != nil {
		return nil, f.err
	}
	if f.setResp != nil {
		return f.setResp, nil
	}
	return &activityv1.SetBadgeDisplayResponse{}, nil
}

func (f *fakeWeeklyStarClient) GetWeeklyStarCurrent(_ context.Context, req *activityv1.GetWeeklyStarCurrentRequest) (*activityv1.GetWeeklyStarCurrentResponse, error) {
	f.lastCurrent = req
	if f.err != nil {
		return nil, f.err
	}
	if f.currentResp != nil {
		return f.currentResp, nil
	}
	return &activityv1.GetWeeklyStarCurrentResponse{}, nil
}

func (f *fakeWeeklyStarClient) ListWeeklyStarLeaderboard(_ context.Context, req *activityv1.ListWeeklyStarLeaderboardRequest) (*activityv1.ListWeeklyStarLeaderboardResponse, error) {
	f.lastLeaderboard = req
	if f.err != nil {
		return nil, f.err
	}
	if f.leaderboardResp != nil {
		return f.leaderboardResp, nil
	}
	return &activityv1.ListWeeklyStarLeaderboardResponse{}, nil
}

func (f *fakeWeeklyStarClient) ListWeeklyStarHistory(_ context.Context, req *activityv1.ListWeeklyStarHistoryRequest) (*activityv1.ListWeeklyStarHistoryResponse, error) {
	f.lastHistory = req
	if f.err != nil {
		return nil, f.err
	}
	if f.historyResp != nil {
		return f.historyResp, nil
	}
	return &activityv1.ListWeeklyStarHistoryResponse{}, nil
}

func (f *fakeCPWeeklyRankClient) GetCPWeeklyRankStatus(_ context.Context, req *activityv1.GetCPWeeklyRankStatusRequest) (*activityv1.GetCPWeeklyRankStatusResponse, error) {
	f.lastStatus = req
	if f.err != nil {
		return nil, f.err
	}
	if f.statusResp != nil {
		return f.statusResp, nil
	}
	return &activityv1.GetCPWeeklyRankStatusResponse{}, nil
}

func (f *fakeUserCPClient) ListCPApplications(context.Context, *userv1.ListCPApplicationsRequest) (*userv1.ListCPApplicationsResponse, error) {
	return &userv1.ListCPApplicationsResponse{}, nil
}

func (f *fakeUserCPClient) AcceptCPApplication(context.Context, *userv1.AcceptCPApplicationRequest) (*userv1.AcceptCPApplicationResponse, error) {
	return &userv1.AcceptCPApplicationResponse{}, nil
}

func (f *fakeUserCPClient) RejectCPApplication(context.Context, *userv1.RejectCPApplicationRequest) (*userv1.RejectCPApplicationResponse, error) {
	return &userv1.RejectCPApplicationResponse{}, nil
}

func (f *fakeUserCPClient) ListCPRelationships(context.Context, *userv1.ListCPRelationshipsRequest) (*userv1.ListCPRelationshipsResponse, error) {
	return &userv1.ListCPRelationshipsResponse{}, nil
}

func (f *fakeUserCPClient) ListCPIntimacyLeaderboard(context.Context, *userv1.ListCPIntimacyLeaderboardRequest) (*userv1.ListCPIntimacyLeaderboardResponse, error) {
	f.listIntimacyLeaderboardCalls++
	return nil, errors.New("ordinary cp intimacy leaderboard must not serve cp weekly activity")
}

func (f *fakeUserCPClient) PrepareBreakCPRelationship(context.Context, *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) {
	return &userv1.PrepareBreakCPRelationshipResponse{}, nil
}

func (f *fakeUserCPClient) ConfirmBreakCPRelationship(context.Context, *userv1.ConfirmBreakCPRelationshipRequest) (*userv1.ConfirmBreakCPRelationshipResponse, error) {
	return &userv1.ConfirmBreakCPRelationshipResponse{}, nil
}

func (f *fakeUserCPClient) CancelBreakCPRelationship(context.Context, *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error) {
	return &userv1.CancelBreakCPRelationshipResponse{}, nil
}

func (f *fakeBroadcastClient) PublishGlobalBroadcast(_ context.Context, req *activityv1.PublishGlobalBroadcastRequest) (*activityv1.PublishBroadcastResponse, error) {
	f.lastGlobal = req
	if f.err != nil {
		return nil, f.err
	}
	return &activityv1.PublishBroadcastResponse{EventId: req.GetEventId(), GroupId: "hy_fami_bc_g_v2", Status: "pending", Created: true}, 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) SearchAgencies(_ context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
	f.lastSearchAgencies = req
	if f.searchAgenciesErr != nil {
		return nil, f.searchAgenciesErr
	}
	if f.searchAgenciesResp != nil {
		return f.searchAgenciesResp, nil
	}
	return &userv1.SearchAgenciesResponse{}, nil
}

func (f *fakeUserHostClient) ApplyToAgency(_ context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
	f.lastApplyAgency = req
	if f.applyAgencyErr != nil {
		return nil, f.applyAgencyErr
	}
	if f.applyAgencyResp != nil {
		return f.applyAgencyResp, nil
	}
	return &userv1.ApplyToAgencyResponse{}, nil
}

func (f *fakeUserHostClient) InviteAgency(_ context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error) {
	f.lastInviteAgency = req
	if f.inviteAgencyErr != nil {
		return nil, f.inviteAgencyErr
	}
	if f.inviteAgencyResp != nil {
		return f.inviteAgencyResp, nil
	}
	return &userv1.InviteAgencyResponse{}, nil
}

func (f *fakeUserHostClient) InviteBD(_ context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, error) {
	f.lastInviteBD = req
	if f.inviteBDErr != nil {
		return nil, f.inviteBDErr
	}
	if f.inviteBDResp != nil {
		return f.inviteBDResp, nil
	}
	return &userv1.InviteBDResponse{}, nil
}

func (f *fakeUserHostClient) InviteHost(_ context.Context, req *userv1.InviteHostRequest) (*userv1.InviteHostResponse, error) {
	f.lastInviteHost = req
	if f.inviteHostErr != nil {
		return nil, f.inviteHostErr
	}
	if f.inviteHostResp != nil {
		return f.inviteHostResp, nil
	}
	return &userv1.InviteHostResponse{}, nil
}

func (f *fakeUserHostClient) ListBDLeaderBDs(_ context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
	f.lastLeaderBDs = req
	if f.leaderBDsErr != nil {
		return nil, f.leaderBDsErr
	}
	if f.leaderBDsResp != nil {
		return f.leaderBDsResp, nil
	}
	return &userv1.ListBDLeaderBDsResponse{}, nil
}

func (f *fakeUserHostClient) ListBDLeaderAgencies(_ context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error) {
	f.lastLeaderAgencies = req
	if f.leaderAgenciesErr != nil {
		return nil, f.leaderAgenciesErr
	}
	if f.leaderAgenciesResp != nil {
		return f.leaderAgenciesResp, nil
	}
	return &userv1.ListBDLeaderAgenciesResponse{}, nil
}

func (f *fakeUserHostClient) ListBDAgencies(_ context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error) {
	f.lastBDAgencies = req
	if f.bdAgenciesErr != nil {
		return nil, f.bdAgenciesErr
	}
	if f.bdAgenciesResp != nil {
		return f.bdAgenciesResp, nil
	}
	return &userv1.ListBDAgenciesResponse{}, nil
}

func (f *fakeUserHostClient) ListManagerTeamAgencies(_ context.Context, req *userv1.ListManagerTeamAgenciesRequest) (*userv1.ListManagerTeamAgenciesResponse, error) {
	f.lastManagerTeam = req
	if f.managerTeamErr != nil {
		return nil, f.managerTeamErr
	}
	if f.managerTeamResp != nil {
		return f.managerTeamResp, nil
	}
	return &userv1.ListManagerTeamAgenciesResponse{}, nil
}

func (f *fakeUserHostClient) ListRoleInvitations(_ context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
	f.lastRoleInvitations = req
	if f.roleInvitationsErr != nil {
		return nil, f.roleInvitationsErr
	}
	if f.roleInvitationsResp != nil {
		return f.roleInvitationsResp, nil
	}
	return &userv1.ListRoleInvitationsResponse{}, nil
}

func (f *fakeUserHostClient) ProcessRoleInvitation(_ context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
	f.lastProcessInvite = req
	if f.processInviteErr != nil {
		return nil, f.processInviteErr
	}
	if f.processInviteResp != nil {
		return f.processInviteResp, nil
	}
	return &userv1.ProcessRoleInvitationResponse{}, nil
}

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

	return &userv1.GetHostProfileResponse{}, nil
}

func (f *fakeUserHostClient) BatchGetHostProfiles(_ context.Context, req *userv1.BatchGetHostProfilesRequest) (*userv1.BatchGetHostProfilesResponse, error) {
	f.lastBatchHost = req
	f.batchHostCalls++
	if f.batchHostErr != nil {
		return nil, f.batchHostErr
	}
	profiles := make(map[int64]*userv1.HostProfile, len(req.GetUserIds()))
	for _, userID := range req.GetUserIds() {
		if f.hostProfiles != nil {
			if profile := f.hostProfiles[userID]; profile != nil {
				profiles[userID] = profile
			}
			continue
		}
		if f.hostProfile != nil && (f.hostProfile.GetUserId() == 0 || f.hostProfile.GetUserId() == userID) {
			profiles[userID] = f.hostProfile
		}
	}
	return &userv1.BatchGetHostProfilesResponse{HostProfiles: profiles}, 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) ListActiveCoinSellersInMyRegion(_ context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
	f.lastListCoinSellers = req
	if f.listCoinSellersErr != nil {
		return nil, f.listCoinSellersErr
	}
	if f.listCoinSellersResp != nil {
		return f.listCoinSellersResp, nil
	}
	return &userv1.ListActiveCoinSellersInMyRegionResponse{}, nil
}

func (f *fakeUserHostClient) CreateSubCoinSeller(_ context.Context, req *userv1.CreateSubCoinSellerRequest) (*userv1.CreateSubCoinSellerResponse, error) {
	f.lastCreateSubSeller = req
	if f.createSubSellerErr != nil {
		return nil, f.createSubSellerErr
	}
	if f.createSubSellerResp != nil {
		return f.createSubSellerResp, nil
	}
	return &userv1.CreateSubCoinSellerResponse{}, nil
}

func (f *fakeUserHostClient) SubmitSubCoinSellerApplication(_ context.Context, req *userv1.SubmitSubCoinSellerApplicationRequest) (*userv1.SubmitSubCoinSellerApplicationResponse, error) {
	f.lastSubmitSubApp = req
	if f.submitSubAppErr != nil {
		return nil, f.submitSubAppErr
	}
	if f.submitSubAppResp != nil {
		return f.submitSubAppResp, nil
	}
	return &userv1.SubmitSubCoinSellerApplicationResponse{}, nil
}

func (f *fakeUserHostClient) ListSubCoinSellers(_ context.Context, req *userv1.ListSubCoinSellersRequest) (*userv1.ListSubCoinSellersResponse, error) {
	f.lastListSubSellers = req
	if f.listSubSellersErr != nil {
		return nil, f.listSubSellersErr
	}
	if f.listSubSellersResp != nil {
		return f.listSubSellersResp, nil
	}
	return &userv1.ListSubCoinSellersResponse{}, nil
}

func (f *fakeUserHostClient) CheckCoinSellerSubRelation(_ context.Context, req *userv1.CheckCoinSellerSubRelationRequest) (*userv1.CheckCoinSellerSubRelationResponse, error) {
	f.lastCheckSubSeller = req
	if f.checkSubSellerErr != nil {
		return nil, f.checkSubSellerErr
	}
	if f.checkSubSellerResp != nil {
		return f.checkSubSellerResp, nil
	}
	return &userv1.CheckCoinSellerSubRelationResponse{}, 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) GetAgency(_ context.Context, req *userv1.GetAgencyRequest) (*userv1.GetAgencyResponse, error) {
	f.lastGetAgency = req
	if f.getAgencyErr != nil {
		return nil, f.getAgencyErr
	}
	if f.getAgencyResp != nil {
		return f.getAgencyResp, nil
	}
	return &userv1.GetAgencyResponse{}, nil
}

func (f *fakeUserHostClient) UpdateAgencyProfile(_ context.Context, req *userv1.UpdateAgencyProfileRequest) (*userv1.UpdateAgencyProfileResponse, error) {
	f.lastUpdateAgency = req
	if f.updateAgencyErr != nil {
		return nil, f.updateAgencyErr
	}
	if f.updateAgencyResp != nil {
		return f.updateAgencyResp, nil
	}
	return &userv1.UpdateAgencyProfileResponse{Agency: &userv1.Agency{
		AgencyId:    req.GetAgencyId(),
		OwnerUserId: req.GetOperatorUserId(),
		Name:        req.GetName(),
		Avatar:      req.GetAvatar(),
		Status:      "active",
	}}, nil
}

func (f *fakeUserHostClient) CheckBusinessCapability(_ context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error) {
	f.lastCapability = req
	f.capabilities = append(f.capabilities, req.GetCapability())
	if f.capabilityErr != nil {
		return nil, f.capabilityErr
	}
	if f.capabilityResp != nil {
		return f.capabilityResp, nil
	}
	if allowed, configured := f.capabilityAllowed[req.GetCapability()]; configured {
		return &userv1.CheckBusinessCapabilityResponse{Allowed: allowed}, nil
	}
	if req.GetCapability() == "manager_cross_region" {
		return &userv1.CheckBusinessCapabilityResponse{Allowed: false}, nil
	}
	return &userv1.CheckBusinessCapabilityResponse{Allowed: true}, nil
}

func (f *fakeUserHostClient) GetRoleScopePolicy(_ context.Context, req *userv1.GetRoleScopePolicyRequest) (*userv1.GetRoleScopePolicyResponse, error) {
	f.lastRoleScopePolicy = req
	if f.roleScopePolicyErr != nil {
		return nil, f.roleScopePolicyErr
	}
	if f.roleScopePolicyResp != nil {
		return f.roleScopePolicyResp, nil
	}
	return &userv1.GetRoleScopePolicyResponse{Policy: &userv1.RoleScopePolicy{
		Scene:                       "manager_operations",
		BaseScope:                   "country",
		ExpandedScope:               "region",
		RegionExpansionConfigurable: true,
	}}, nil
}

func (f *fakeUserHostClient) sawCapability(capability string) bool {
	for _, item := range f.capabilities {
		if item == capability {
			return true
		}
	}
	return false
}

func (f *fakeUserHostClient) GetAgencyMembers(_ context.Context, req *userv1.GetAgencyMembersRequest) (*userv1.GetAgencyMembersResponse, error) {
	f.lastMembers = req
	if f.membersErr != nil {
		return nil, f.membersErr
	}
	if f.membersResp != nil {
		return f.membersResp, nil
	}
	return &userv1.GetAgencyMembersResponse{}, nil
}

func (f *fakeUserHostClient) GetAgencyApplications(_ context.Context, req *userv1.GetAgencyApplicationsRequest) (*userv1.GetAgencyApplicationsResponse, error) {
	f.lastApplications = req
	if f.applicationsErr != nil {
		return nil, f.applicationsErr
	}
	if f.applicationsResp != nil {
		return f.applicationsResp, nil
	}
	return &userv1.GetAgencyApplicationsResponse{}, nil
}

func (f *fakeUserHostClient) ReviewAgencyApplication(_ context.Context, req *userv1.ReviewAgencyApplicationRequest) (*userv1.ReviewAgencyApplicationResponse, error) {
	f.lastReview = req
	if f.reviewErr != nil {
		return nil, f.reviewErr
	}
	if f.reviewResp != nil {
		return f.reviewResp, nil
	}
	return &userv1.ReviewAgencyApplicationResponse{}, nil
}

func (f *fakeUserHostClient) KickAgencyHost(_ context.Context, req *userv1.KickAgencyHostRequest) (*userv1.KickAgencyHostResponse, error) {
	f.lastKick = req
	if f.kickErr != nil {
		return nil, f.kickErr
	}
	if f.kickResp != nil {
		return f.kickResp, nil
	}
	return &userv1.KickAgencyHostResponse{}, nil
}

func (f *fakeUserHostAdminClient) CreateBDLeader(_ context.Context, req *userv1.CreateBDLeaderRequest) (*userv1.CreateBDLeaderResponse, error) {
	f.lastCreateBDLeader = req
	if f.createBDLeaderErr != nil {
		return nil, f.createBDLeaderErr
	}
	if f.createBDLeaderResp != nil {
		return f.createBDLeaderResp, nil
	}
	return &userv1.CreateBDLeaderResponse{BdProfile: &userv1.BDProfile{
		UserId:          req.GetTargetUserId(),
		Role:            "bd_leader",
		RegionId:        1001,
		Status:          "active",
		CreatedByUserId: req.GetAdminUserId(),
	}}, nil
}

func (f *fakeWalletClient) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	f.last = req
	f.balanceRequests = append(f.balanceRequests, req)
	if f.err != nil {
		return nil, f.err
	}
	if f.balancesByUserID != nil {
		if resp, ok := f.balancesByUserID[req.GetUserId()]; ok {
			return resp, nil
		}
	}
	if f.resp != nil {
		return f.resp, nil
	}

	return &walletv1.GetBalancesResponse{}, nil
}

func (f *fakeWalletClient) DebitDirectGift(_ context.Context, req *walletv1.DebitDirectGiftRequest) (*walletv1.DebitGiftResponse, error) {
	f.lastDirectGift = req
	f.directGiftRequests = append(f.directGiftRequests, req)
	if f.directGiftErr != nil {
		return nil, f.directGiftErr
	}
	if f.directGiftResp != nil {
		return f.directGiftResp, nil
	}
	return &walletv1.DebitGiftResponse{}, nil
}

func (f *fakeWalletClient) GetActiveHostSalaryPolicy(_ context.Context, req *walletv1.GetActiveHostSalaryPolicyRequest) (*walletv1.GetActiveHostSalaryPolicyResponse, error) {
	f.lastHostSalaryPolicy = req
	if f.hostSalaryPolicyErr != nil {
		return nil, f.hostSalaryPolicyErr
	}
	if f.hostSalaryPolicyResp != nil {
		return f.hostSalaryPolicyResp, nil
	}
	return &walletv1.GetActiveHostSalaryPolicyResponse{}, nil
}

func (f *fakeWalletClient) GetHostSalaryProgress(_ context.Context, req *walletv1.GetHostSalaryProgressRequest) (*walletv1.GetHostSalaryProgressResponse, error) {
	f.lastHostSalaryProgress = req
	if f.hostSalaryProgressErr != nil {
		return nil, f.hostSalaryProgressErr
	}
	if f.hostSalaryProgressResp != nil {
		return f.hostSalaryProgressResp, nil
	}
	return &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{HostUserId: req.GetHostUserId()}}, nil
}

func (f *fakeWalletClient) GetTeamHostSalaryStats(_ context.Context, req *walletv1.GetTeamHostSalaryStatsRequest) (*walletv1.GetTeamHostSalaryStatsResponse, error) {
	f.lastTeamSalaryStats = req
	if f.teamSalaryStatsErr != nil {
		return nil, f.teamSalaryStatsErr
	}
	if f.teamSalaryStatsResp != nil {
		return f.teamSalaryStatsResp, nil
	}
	return &walletv1.GetTeamHostSalaryStatsResponse{}, 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,
		},
	}, 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) GetUserGiftWall(_ context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error) {
	f.lastGiftWall = req
	if f.err != nil {
		return nil, f.err
	}
	if f.giftWallResp != nil {
		return f.giftWallResp, nil
	}
	return &walletv1.GetUserGiftWallResponse{}, 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) ListThirdPartyPaymentChannels(_ context.Context, req *walletv1.ListThirdPartyPaymentChannelsRequest) (*walletv1.ListThirdPartyPaymentChannelsResponse, error) {
	f.lastThirdPartyChannels = req
	if f.err != nil {
		return nil, f.err
	}
	if f.thirdPartyChannelsResp != nil {
		return f.thirdPartyChannelsResp, nil
	}
	return &walletv1.ListThirdPartyPaymentChannelsResponse{}, nil
}

func (f *fakeWalletClient) ListH5RechargeOptions(_ context.Context, req *walletv1.H5RechargeOptionsRequest) (*walletv1.H5RechargeOptionsResponse, error) {
	f.lastH5Options = req
	if f.err != nil {
		return nil, f.err
	}
	if f.h5OptionsResp != nil {
		return f.h5OptionsResp, nil
	}
	return &walletv1.H5RechargeOptionsResponse{}, nil
}

func (f *fakeWalletClient) CreateH5RechargeOrder(_ context.Context, req *walletv1.CreateH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
	f.lastH5CreateOrder = req
	if f.err != nil {
		return nil, f.err
	}
	if f.h5CreateOrderResp != nil {
		return f.h5CreateOrderResp, nil
	}
	return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: "h5-order-1", Status: "pending"}}, nil
}

func (f *fakeWalletClient) CreateTemporaryRechargeOrder(_ context.Context, req *walletv1.CreateTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
	f.lastTemporaryCreate = req
	if f.err != nil {
		return nil, f.err
	}
	if f.temporaryCreateResp != nil {
		return f.temporaryCreateResp, nil
	}
	return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: "temporary-order-1", Status: "pending"}}, nil
}

func (f *fakeWalletClient) GetTemporaryRechargeOrder(_ context.Context, req *walletv1.GetTemporaryRechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
	f.lastTemporaryGet = req
	if f.err != nil {
		return nil, f.err
	}
	if f.temporaryGetResp != nil {
		return f.temporaryGetResp, nil
	}
	return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), Status: "pending"}}, nil
}

func (f *fakeWalletClient) ListTemporaryRechargeOrders(_ context.Context, req *walletv1.ListTemporaryRechargeOrdersRequest) (*walletv1.ListTemporaryRechargeOrdersResponse, error) {
	f.lastTemporaryList = req
	if f.err != nil {
		return nil, f.err
	}
	if f.temporaryListResp != nil {
		return f.temporaryListResp, nil
	}
	return &walletv1.ListTemporaryRechargeOrdersResponse{}, nil
}

func (f *fakeWalletClient) SubmitH5RechargeTx(_ context.Context, req *walletv1.SubmitH5RechargeTxRequest) (*walletv1.H5RechargeOrderResponse, error) {
	f.lastH5SubmitTx = req
	if f.err != nil {
		return nil, f.err
	}
	if f.h5SubmitTxResp != nil {
		return f.h5SubmitTxResp, nil
	}
	return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), TxHash: req.GetTxHash(), Status: "pending"}}, nil
}

func (f *fakeWalletClient) GetH5RechargeOrder(_ context.Context, req *walletv1.GetH5RechargeOrderRequest) (*walletv1.H5RechargeOrderResponse, error) {
	f.lastH5GetOrder = req
	if f.err != nil {
		return nil, f.err
	}
	if f.h5GetOrderResp != nil {
		return f.h5GetOrderResp, nil
	}
	return &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{OrderId: req.GetOrderId(), Status: "pending"}}, nil
}

func (f *fakeWalletClient) HandleMifapayNotify(_ context.Context, req *walletv1.HandleMifapayNotifyRequest) (*walletv1.HandleMifapayNotifyResponse, error) {
	f.lastMifaPayNotify = req
	if f.err != nil {
		return nil, f.err
	}
	if f.mifaPayNotifyResp != nil {
		return f.mifaPayNotifyResp, nil
	}
	return &walletv1.HandleMifapayNotifyResponse{Accepted: true, ResponseText: "SUCCESS", OrderId: "h5-order-1"}, nil
}

func (f *fakeWalletClient) HandleV5PayNotify(_ context.Context, req *walletv1.HandleV5PayNotifyRequest) (*walletv1.HandleV5PayNotifyResponse, error) {
	f.lastV5PayNotify = req
	if f.err != nil {
		return nil, f.err
	}
	if f.v5PayNotifyResp != nil {
		return f.v5PayNotifyResp, nil
	}
	return &walletv1.HandleV5PayNotifyResponse{Accepted: true, ResponseText: "success", OrderId: "h5-order-1"}, nil
}

func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
	f.lastGoogleConfirm = req
	if f.err != nil {
		return nil, f.err
	}
	if f.googleConfirmResp != nil {
		return f.googleConfirmResp, nil
	}
	return &walletv1.ConfirmGooglePaymentResponse{}, 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) ExchangePointToCoin(_ context.Context, req *walletv1.ExchangePointToCoinRequest) (*walletv1.ExchangePointToCoinResponse, error) {
	f.lastPointExchange = req
	if f.err != nil {
		return nil, f.err
	}
	if f.pointExchangeResp != nil {
		return f.pointExchangeResp, nil
	}
	return &walletv1.ExchangePointToCoinResponse{}, 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) ListHostPeriodDiamondTransactions(_ context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error) {
	f.lastDiamondTransactions = req
	if f.err != nil {
		return nil, f.err
	}
	if f.diamondTransactionsResp != nil {
		return f.diamondTransactionsResp, nil
	}
	return &walletv1.ListWalletTransactionsResponse{}, 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) UpdateMyVipSettings(_ context.Context, req *walletv1.UpdateMyVipSettingsRequest) (*walletv1.UpdateMyVipSettingsResponse, error) {
	f.lastUpdateVIPSettings = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.updateVIPSettingsResp != nil {
		return f.updateVIPSettingsResp, nil
	}
	return &walletv1.UpdateMyVipSettingsResponse{}, nil
}

func (f *fakeWalletClient) GetVipProgramConfig(_ context.Context, req *walletv1.GetVipProgramConfigRequest) (*walletv1.GetVipProgramConfigResponse, error) {
	f.lastVipProgram = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.vipProgramResp != nil {
		return f.vipProgramResp, nil
	}
	// 多数既有 gateway 测试建模 Lalu；明确返回 legacy direct，避免测试桩用空配置掩盖真实路由分支。
	return &walletv1.GetVipProgramConfigResponse{Config: &walletv1.VipProgramConfig{
		AppCode: req.GetAppCode(), GrantMode: "direct_membership", Status: "active",
	}}, nil
}

func (f *fakeWalletClient) CheckVipBenefit(_ context.Context, req *walletv1.CheckVipBenefitRequest) (*walletv1.CheckVipBenefitResponse, error) {
	f.lastBenefitCheck = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.benefitCheckResp != nil {
		return f.benefitCheckResp, nil
	}
	return &walletv1.CheckVipBenefitResponse{}, 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) GetMyCurrentVipDailyCoinRebate(_ context.Context, req *walletv1.GetMyCurrentVipDailyCoinRebateRequest) (*walletv1.GetMyCurrentVipDailyCoinRebateResponse, error) {
	f.lastCurrentVIPRebate = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.currentVIPRebateResp != nil {
		return f.currentVIPRebateResp, nil
	}
	return &walletv1.GetMyCurrentVipDailyCoinRebateResponse{}, nil
}

func (f *fakeWalletClient) ListMyVipDailyCoinRebateStatuses(_ context.Context, req *walletv1.ListMyVipDailyCoinRebateStatusesRequest) (*walletv1.ListMyVipDailyCoinRebateStatusesResponse, error) {
	f.lastVIPRebateStatuses = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.vipRebateStatusesResp != nil {
		return f.vipRebateStatusesResp, nil
	}
	return &walletv1.ListMyVipDailyCoinRebateStatusesResponse{}, nil
}

func (f *fakeWalletClient) ClaimVipDailyCoinRebate(_ context.Context, req *walletv1.ClaimVipDailyCoinRebateRequest) (*walletv1.ClaimVipDailyCoinRebateResponse, error) {
	f.lastClaimVIPRebate = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.claimVIPRebateResp != nil {
		return f.claimVIPRebateResp, nil
	}
	return &walletv1.ClaimVipDailyCoinRebateResponse{}, nil
}

func (f *fakeWalletClient) GrantVipTrialCard(_ context.Context, req *walletv1.GrantVipTrialCardRequest) (*walletv1.GrantVipTrialCardResponse, error) {
	f.lastTrialGrant = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.trialGrantResp != nil {
		return f.trialGrantResp, nil
	}
	return &walletv1.GrantVipTrialCardResponse{}, nil
}

func (f *fakeWalletClient) EquipVipTrialCard(_ context.Context, req *walletv1.EquipVipTrialCardRequest) (*walletv1.EquipVipTrialCardResponse, error) {
	f.lastTrialEquip = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.trialEquipResp != nil {
		return f.trialEquipResp, nil
	}
	return &walletv1.EquipVipTrialCardResponse{}, nil
}

func (f *fakeWalletClient) UnequipVipTrialCard(_ context.Context, req *walletv1.UnequipVipTrialCardRequest) (*walletv1.UnequipVipTrialCardResponse, error) {
	f.lastTrialUnequip = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.trialUnequipResp != nil {
		return f.trialUnequipResp, nil
	}
	return &walletv1.UnequipVipTrialCardResponse{}, nil
}

func (f *fakeWalletClient) DebitCPBreakupFee(context.Context, *walletv1.DebitCPBreakupFeeRequest) (*walletv1.DebitCPBreakupFeeResponse, error) {
	return &walletv1.DebitCPBreakupFeeResponse{}, nil
}

func (f *fakeWalletClient) DebitWheelDraw(context.Context, *walletv1.DebitWheelDrawRequest) (*walletv1.DebitWheelDrawResponse, error) {
	return &walletv1.DebitWheelDrawResponse{}, nil
}

func (f *fakeWalletClient) GrantVip(_ context.Context, req *walletv1.GrantVipRequest) (*walletv1.GrantVipResponse, error) {
	f.lastGrantVip = req
	if f.vipErr != nil {
		return nil, f.vipErr
	}
	if f.grantVipResp != nil {
		return f.grantVipResp, nil
	}
	return &walletv1.GrantVipResponse{
		TransactionId: "vip-grant-tx",
		Vip:           &walletv1.UserVip{UserId: req.GetTargetUserId(), Level: req.GetLevel(), Active: true},
		ServerTimeMs:  1_780_000_000_000,
	}, 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) TransferCoinSellerStockToChild(_ context.Context, req *walletv1.TransferCoinSellerStockToChildRequest) (*walletv1.TransferCoinSellerStockToChildResponse, error) {
	f.lastSubTransfer = req
	if f.subTransferErr != nil {
		return nil, f.subTransferErr
	}
	if f.subTransferResp != nil {
		return f.subTransferResp, nil
	}
	return &walletv1.TransferCoinSellerStockToChildResponse{}, nil
}

func (f *fakeWalletClient) ListCoinSellerSalaryExchangeRateTiers(_ context.Context, req *walletv1.ListCoinSellerSalaryExchangeRateTiersRequest) (*walletv1.ListCoinSellerSalaryExchangeRateTiersResponse, error) {
	f.lastListExchangeTiers = req
	if f.err != nil {
		return nil, f.err
	}
	if f.listExchangeTiersResp != nil {
		return f.listExchangeTiersResp, nil
	}
	return &walletv1.ListCoinSellerSalaryExchangeRateTiersResponse{}, nil
}

func (f *fakeWalletClient) ExchangeSalaryToCoin(_ context.Context, req *walletv1.ExchangeSalaryToCoinRequest) (*walletv1.ExchangeSalaryToCoinResponse, error) {
	f.lastExchangeSalary = req
	if f.exchangeSalaryErr != nil {
		return nil, f.exchangeSalaryErr
	}
	if f.exchangeSalaryResp != nil {
		return f.exchangeSalaryResp, nil
	}
	return &walletv1.ExchangeSalaryToCoinResponse{}, nil
}

func (f *fakeWalletClient) TransferSalaryToCoinSeller(_ context.Context, req *walletv1.TransferSalaryToCoinSellerRequest) (*walletv1.TransferSalaryToCoinSellerResponse, error) {
	f.lastTransferSalary = req
	if f.transferSalaryErr != nil {
		return nil, f.transferSalaryErr
	}
	if f.transferSalaryResp != nil {
		return f.transferSalaryResp, nil
	}
	return &walletv1.TransferSalaryToCoinSellerResponse{}, nil
}

func (f *fakeWalletClient) FreezeSalaryWithdrawal(_ context.Context, req *walletv1.FreezeSalaryWithdrawalRequest) (*walletv1.FreezeSalaryWithdrawalResponse, error) {
	f.lastFreezeSalary = req
	if f.freezeSalaryErr != nil {
		return nil, f.freezeSalaryErr
	}
	if f.freezeSalaryResp != nil {
		return f.freezeSalaryResp, nil
	}
	availableAfter, frozenAfter := int64(0), req.GetSalaryUsdMinor()
	if f.resp != nil {
		for _, balance := range f.resp.GetBalances() {
			if balance.GetAssetType() == req.GetSalaryAssetType() {
				availableAfter = balance.GetAvailableAmount() - req.GetSalaryUsdMinor()
				frozenAfter = balance.GetFrozenAmount() + req.GetSalaryUsdMinor()
				break
			}
		}
	}
	return &walletv1.FreezeSalaryWithdrawalResponse{
		TransactionId:  "freeze-tx-1",
		SalaryUsdMinor: req.GetSalaryUsdMinor(),
		Balance: &walletv1.AssetBalance{
			AssetType:       req.GetSalaryAssetType(),
			AvailableAmount: availableAfter,
			FrozenAmount:    frozenAfter,
		},
	}, nil
}

func (f *fakeWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req *walletv1.ReleaseSalaryWithdrawalRequest) (*walletv1.ReleaseSalaryWithdrawalResponse, error) {
	f.lastReleaseSalary = req
	if f.releaseSalaryErr != nil {
		return nil, f.releaseSalaryErr
	}
	if f.releaseSalaryResp != nil {
		return f.releaseSalaryResp, nil
	}
	return &walletv1.ReleaseSalaryWithdrawalResponse{
		TransactionId:  "release-tx-1",
		SalaryUsdMinor: req.GetSalaryUsdMinor(),
		Balance: &walletv1.AssetBalance{
			AssetType:       req.GetSalaryAssetType(),
			AvailableAmount: req.GetSalaryUsdMinor(),
		},
	}, 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) GetResource(_ context.Context, req *walletv1.GetResourceRequest) (*walletv1.GetResourceResponse, error) {
	f.lastGetResource = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resourcesByID != nil {
		return &walletv1.GetResourceResponse{Resource: f.resourcesByID[req.GetResourceId()]}, nil
	}
	return &walletv1.GetResourceResponse{Resource: &walletv1.Resource{
		ResourceId:   req.GetResourceId(),
		ResourceType: "avatar_frame",
	}}, nil
}

func (f *fakeWalletClient) GetResourceGroup(_ context.Context, req *walletv1.GetResourceGroupRequest) (*walletv1.GetResourceGroupResponse, error) {
	f.lastGetResourceGroup = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resourceGroupsByID != nil {
		return &walletv1.GetResourceGroupResponse{Group: f.resourceGroupsByID[req.GetGroupId()]}, nil
	}
	return &walletv1.GetResourceGroupResponse{}, nil
}

func (f *fakeWalletClient) ListResourceShopItems(_ context.Context, req *walletv1.ListResourceShopItemsRequest) (*walletv1.ListResourceShopItemsResponse, error) {
	f.lastResourceShop = req
	if f.err != nil {
		return nil, f.err
	}
	if f.resourceShopResp != nil {
		return f.resourceShopResp, nil
	}
	return &walletv1.ListResourceShopItemsResponse{}, nil
}

func (f *fakeWalletClient) PurchaseResourceShopItem(_ context.Context, req *walletv1.PurchaseResourceShopItemRequest) (*walletv1.PurchaseResourceShopItemResponse, error) {
	f.lastPurchaseShop = req
	if f.err != nil {
		return nil, f.err
	}
	if f.purchaseShopResp != nil {
		return f.purchaseShopResp, nil
	}
	return &walletv1.PurchaseResourceShopItemResponse{}, nil
}

func (f *fakeWalletClient) GetGiftCatalogVersion(_ context.Context, req *walletv1.GetGiftCatalogVersionRequest) (*walletv1.GetGiftCatalogVersionResponse, error) {
	f.lastGetGiftCatalog = req
	f.getGiftCatalogCalls++
	if f.giftCatalogVersionErr != nil {
		return nil, f.giftCatalogVersionErr
	}
	if f.err != nil {
		return nil, f.err
	}
	if f.giftCatalogVersionResp != nil {
		return f.giftCatalogVersionResp, nil
	}
	return &walletv1.GetGiftCatalogVersionResponse{Version: f.giftCatalogVersion}, nil
}

func (f *fakeWalletClient) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
	f.lastListGiftConfigs = req
	f.listGiftConfigsCalls++
	if f.err != nil {
		return nil, f.err
	}
	if f.listGiftConfigsResp != nil {
		return f.listGiftConfigsResp, nil
	}
	return &walletv1.ListGiftConfigsResponse{}, nil
}

func (f *fakeWalletClient) ListGiftTypeConfigs(_ context.Context, req *walletv1.ListGiftTypeConfigsRequest) (*walletv1.ListGiftTypeConfigsResponse, error) {
	f.lastListGiftTypes = req
	f.listGiftTypesCalls++
	if f.err != nil {
		return nil, f.err
	}
	if f.listGiftTypesResp != nil {
		return f.listGiftTypesResp, nil
	}
	return &walletv1.ListGiftTypeConfigsResponse{}, nil
}

func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) {
	f.lastGrantResource = req
	f.grantResourceRequests = append(f.grantResourceRequests, 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, req *walletv1.ListUserResourcesRequest) (*walletv1.ListUserResourcesResponse, error) {
	f.lastListUserResources = req
	f.listUserResourcesCalls++
	if f.listUserResourcesResp != nil {
		return f.listUserResourcesResp, nil
	}
	return &walletv1.ListUserResourcesResponse{}, nil
}

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

func (f *fakeWalletClient) UnequipUserResource(context.Context, *walletv1.UnequipUserResourceRequest) (*walletv1.UnequipUserResourceResponse, error) {
	return &walletv1.UnequipUserResourceResponse{}, nil
}

func (f *fakeWalletClient) BatchGetUserEquippedResources(ctx context.Context, req *walletv1.BatchGetUserEquippedResourcesRequest) (*walletv1.BatchGetUserEquippedResourcesResponse, error) {
	f.lastBatchEquipped = req
	f.batchEquippedRequests = append(f.batchEquippedRequests, req)
	if deadline, ok := ctx.Deadline(); ok {
		f.batchEquippedBudget = time.Until(deadline)
	}
	if f.err != nil {
		return nil, f.err
	}
	if f.batchEquippedResp != nil {
		return f.batchEquippedResp, nil
	}
	return &walletv1.BatchGetUserEquippedResourcesResponse{}, nil
}

func (f *fakeWalletClient) GetRedPacketConfig(context.Context, *walletv1.GetRedPacketConfigRequest) (*walletv1.GetRedPacketConfigResponse, error) {
	return &walletv1.GetRedPacketConfigResponse{}, nil
}

func (f *fakeWalletClient) CreateRedPacket(context.Context, *walletv1.CreateRedPacketRequest) (*walletv1.CreateRedPacketResponse, error) {
	return &walletv1.CreateRedPacketResponse{}, nil
}

func (f *fakeWalletClient) ClaimRedPacket(_ context.Context, req *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) {
	f.lastClaimRedPacket = req
	if f.err != nil {
		return nil, f.err
	}
	if f.claimRedPacketResp != nil {
		return f.claimRedPacketResp, nil
	}
	return &walletv1.ClaimRedPacketResponse{}, nil
}

func (f *fakeWalletClient) ListRedPackets(_ context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) {
	f.lastListRedPackets = req
	if f.listRedPacketsResp != nil {
		return f.listRedPacketsResp, nil
	}
	return &walletv1.ListRedPacketsResponse{}, nil
}

func (f *fakeWalletClient) GetRedPacket(_ context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
	f.lastGetRedPacket = req
	if f.err != nil {
		return nil, f.err
	}
	if f.getRedPacketResp != nil {
		return f.getRedPacketResp, nil
	}
	return &walletv1.GetRedPacketResponse{}, nil
}

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

	return *value
}

// fakeRoomGuardClient 只覆盖腾讯云 IM 回调测试需要的守卫 RPC。
type fakeRoomGuardClient struct {
	speakResp    *roomv1.CheckSpeakPermissionResponse
	presenceResp *roomv1.VerifyRoomPresenceResponse
	roomAppResp  *roomv1.ResolveRoomAppCodeResponse
	lastSpeak    *roomv1.CheckSpeakPermissionRequest
	lastPresence *roomv1.VerifyRoomPresenceRequest
	lastRoomApp  *roomv1.ResolveRoomAppCodeRequest
	err          error
	roomAppErr   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
}

func (f *fakeRoomGuardClient) ResolveRoomAppCode(_ context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) {
	f.lastRoomApp = req
	if f.roomAppErr != nil {
		return nil, f.roomAppErr
	}
	if f.err != nil {
		return nil, f.err
	}
	if f.roomAppResp != nil {
		return f.roomAppResp, nil
	}
	return &roomv1.ResolveRoomAppCodeResponse{AppCode: "lalu"}, 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.GetOwnerCountryCode() != "US" {
		t.Fatalf("CreateRoom must derive owner_country_code from user-service: %+v", 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 TestV5PayNotifyReturnsPlainSuccess(t *testing.T) {
	walletClient := &fakeWalletClient{}
	handler := NewHandlerWithClients(nil, nil, nil)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"orderNo":"order-1","productType":"INSTAPAY_QR","amount":"20.00","businessType":"0","fee":"0.00","orderStatus":"2","sign":"stub-sign"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/payment/v5pay/notify", bytes.NewReader(body))
	request.Header.Set("Content-Type", "application/json")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("v5pay notify status mismatch: got %d body=%q", recorder.Code, recorder.Body.String())
	}
	if strings.TrimSpace(recorder.Body.String()) != "success" {
		t.Fatalf("v5pay notify must return plain success, got %q", recorder.Body.String())
	}
	if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/plain") {
		t.Fatalf("v5pay notify must return text/plain, got %q", contentType)
	}
	if walletClient.lastV5PayNotify == nil || walletClient.lastV5PayNotify.GetFields()["orderNo"] != "order-1" || walletClient.lastV5PayNotify.GetFields()["amount"] != "20.00" {
		t.Fatalf("v5pay notify should forward string fields to wallet-service: %+v", walletClient.lastV5PayNotify)
	}
}

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_heartbeat",
			path:      "/api/v1/rooms/mic/heartbeat",
			body:      `{"room_id":"room-1","command_id":"cmd-mic-heartbeat-1","mic_session_id":"mic-1"}`,
			commandID: "cmd-mic-heartbeat-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastMicHeartbeat.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:      "room_password",
			path:      "/api/v1/rooms/password",
			body:      `{"room_id":"room-1","command_id":"cmd-room-password-1","locked":true,"password":"1234"}`,
			commandID: "cmd-room-password-1",
			extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
				return client.lastRoomPassword.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,"duration_ms":600000}`,
			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 := giftTestHostAuthority(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)
			}
			if test.name == "kick" && client.lastKick.GetDurationMs() != 600000 {
				t.Fatalf("kick duration_ms was not forwarded: %+v", client.lastKick)
			}
		})
	}
}

func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
	roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
		Result:           &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
		BillingReceiptId: "receipt-lucky",
		RoomHeat:         100,
		CoinBalanceAfter: 8200,
		LuckyGift: &roomv1.LuckyGiftDrawResult{
			Enabled:              true,
			DrawId:               "lucky_draw_test",
			CommandId:            "cmd-gift-lucky",
			PoolId:               "super_lucky",
			GiftId:               "rose",
			RuleVersion:          12,
			ExperiencePool:       "novice",
			SelectedTierId:       "batch",
			MultiplierPpm:        5_000_000,
			EffectiveRewardCoins: 500,
			RewardStatus:         "granted",
			CoinBalanceAfter:     0,
			CreatedAtMs:          1_779_258_000_000,
		},
		LuckyGifts: []*roomv1.LuckyGiftDrawResult{
			{Enabled: true, DrawId: "lucky_draw_1", CommandId: "cmd-gift-lucky:target:43", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_1", TargetUserId: 43},
			{Enabled: true, DrawId: "lucky_draw_2", CommandId: "cmd-gift-lucky:target:44", PoolId: "super_lucky", GiftId: "rose", MultiplierPpm: 3_000_000, EffectiveRewardCoins: 300, RewardStatus: "granted", WalletTransactionId: "wallet_tx_lucky_2", TargetUserId: 44},
		},
	}}
	router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`)))
	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())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			LuckyGift struct {
				DrawID               string `json:"draw_id"`
				PoolID               string `json:"pool_id"`
				MultiplierPPM        int64  `json:"multiplier_ppm"`
				EffectiveRewardCoins int64  `json:"effective_reward_coins"`
				RewardStatus         string `json:"reward_status"`
				WalletTransactionID  string `json:"wallet_transaction_id"`
				CoinBalanceAfter     int64  `json:"coin_balance_after"`
			} `json:"lucky_gift"`
			LuckyGifts []struct {
				TargetUserID         int64 `json:"target_user_id"`
				MultiplierPPM        int64 `json:"multiplier_ppm"`
				EffectiveRewardCoins int64 `json:"effective_reward_coins"`
			} `json:"lucky_gifts"`
			CoinBalanceAfter int64 `json:"coin_balance_after"`
		} `json:"data"`
	}
	if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
		t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
	}
	if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 5_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 500 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "" || envelope.Data.LuckyGift.CoinBalanceAfter != 0 {
		t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
	}
	if len(envelope.Data.LuckyGifts) != 2 || envelope.Data.LuckyGifts[0].TargetUserID != 43 || envelope.Data.LuckyGifts[0].MultiplierPPM != 2_000_000 || envelope.Data.LuckyGifts[1].TargetUserID != 44 || envelope.Data.LuckyGifts[1].MultiplierPPM != 3_000_000 {
		t.Fatalf("lucky_gifts response mismatch: %+v", envelope.Data.LuckyGifts)
	}
	if envelope.Data.CoinBalanceAfter != 8200 {
		t.Fatalf("send gift response must expose sender coin_balance_after: %+v", envelope.Data)
	}
}

func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
	roomClient := &fakeRoomClient{}
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001}}
	profileClient := &fakeUserProfileClient{regionID: 1001}
	handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host","target_user_id":43,"gift_id":"rose","gift_count":2}`)))
	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 hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 1 || hostClient.lastBatchHost.GetUserIds()[0] != 43 {
		t.Fatalf("gateway must batch query target host profile before sending gift: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls)
	}
	if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 {
		t.Fatalf("sender country/profile must come from one BatchGetUsers call: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests))
	}
	if roomClient.lastGift == nil || !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 || roomClient.lastGift.GetTargetAgencyOwnerUserId() != 30001 {
		t.Fatalf("gateway must inject active host period scope: %+v", roomClient.lastGift)
	}
}

func TestSendGiftDoesNotInjectHostPeriodScopeForNonHost(t *testing.T) {
	roomClient := &fakeRoomClient{}
	hostClient := &fakeUserHostClient{}
	handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-non-host","target_user_id":43,"gift_id":"rose","gift_count":2}`)))
	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 hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 {
		t.Fatalf("non-host lookup must still use batch host query: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
	}
	if roomClient.lastGift == nil || roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 0 {
		t.Fatalf("non-host target must not receive host period scope: %+v", roomClient.lastGift)
	}
}

func TestSendGiftForwardsMultipleTargetUserIDs(t *testing.T) {
	roomClient := &fakeRoomClient{}
	router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-multi","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
	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 roomClient.lastGift == nil || roomClient.lastGift.GetTargetUserId() != 43 || len(roomClient.lastGift.GetTargetUserIds()) != 2 || roomClient.lastGift.GetTargetUserIds()[0] != 43 || roomClient.lastGift.GetTargetUserIds()[1] != 44 {
		t.Fatalf("gateway must forward all target_user_ids: %+v", roomClient.lastGift)
	}
}

func TestBatchSendGiftForwardsDisplayModeAndResponse(t *testing.T) {
	roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
		Result:           &roomv1.CommandResult{Applied: true, RoomVersion: 15, ServerTimeMs: 1_779_258_000_000},
		BillingReceiptId: "receipt-batch",
		RoomHeat:         500,
		CoinBalanceAfter: 7600,
		BatchDisplay: &roomv1.SendGiftBatchDisplay{
			EventId:          "evt-batch",
			SenderUserId:     42,
			GiftId:           "rose",
			GiftCount:        2,
			TotalGiftValue:   400,
			TotalCoinSpent:   400,
			BillingReceiptId: "receipt-batch",
			TargetUserIds:    []int64{43, 44},
			CommandId:        "cmd-gift-batch",
			GiftAnimationUrl: "https://cdn.example/rose.svga",
			GiftEffectTypes:  []string{"room"},
			RoomHeat:         500,
			Targets: []*roomv1.SendGiftBatchTarget{
				{
					TargetUserId: 43, TargetGiftValue: 220, CommandId: "cmd-gift-batch:target:43",
				},
				{
					TargetUserId: 44, TargetGiftValue: 180, CommandId: "cmd-gift-batch:target:44",
				},
			},
		},
	}}
	router := giftTestHostAuthority(NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/batch-send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-batch","target_user_ids":[43,44],"gift_id":"rose","gift_count":2,"pool_id":"super_lucky"}`)))
	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 roomClient.lastGift == nil || roomClient.lastGift.GetDisplayMode() != "batch" || len(roomClient.lastGift.GetTargetUserIds()) != 2 || roomClient.lastGift.GetPoolId() != "super_lucky" {
		t.Fatalf("gateway must forward batch display mode and targets: %+v", roomClient.lastGift)
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			CoinBalanceAfter int64 `json:"coin_balance_after"`
			BatchDisplay     struct {
				EventID       string  `json:"event_id"`
				TargetUserIDs []int64 `json:"target_user_ids"`
				Targets       []struct {
					TargetUserID    int64  `json:"target_user_id"`
					TargetGiftValue int64  `json:"target_gift_value"`
					CommandID       string `json:"command_id"`
				} `json:"targets"`
			} `json:"batch_display"`
		} `json:"data"`
	}
	if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
		t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
	}
	if envelope.Code != "OK" || envelope.Data.CoinBalanceAfter != 7600 || envelope.Data.BatchDisplay.EventID != "evt-batch" || len(envelope.Data.BatchDisplay.TargetUserIDs) != 2 || len(envelope.Data.BatchDisplay.Targets) != 2 {
		t.Fatalf("batch send response mismatch: %+v", envelope.Data)
	}
	if target := envelope.Data.BatchDisplay.Targets[0]; target.TargetUserID != 43 || target.TargetGiftValue != 220 || target.CommandID != "cmd-gift-batch:target:43" {
		t.Fatalf("gateway batch first target display mismatch: %+v", target)
	}
	if target := envelope.Data.BatchDisplay.Targets[1]; target.TargetUserID != 44 || target.TargetGiftValue != 180 || target.CommandID != "cmd-gift-batch:target:44" {
		t.Fatalf("gateway batch second target display mismatch: %+v", target)
	}
}

func TestSendGiftInjectsDisplayProfilesForIMSnapshot(t *testing.T) {
	roomClient := &fakeRoomClient{}
	walletClient := &fakeWalletClient{myVipResp: &walletv1.GetMyVipResponse{State: &walletv1.VipState{
		EffectiveVip: &walletv1.UserVip{UserId: 42, Level: 7, Active: true},
		EffectiveBenefits: []*walletv1.VipBenefit{{
			BenefitCode: "gift_tray_skin", BenefitType: "decoration", Status: "active",
			ResourceId: 7007, ResourceType: "gift_tray_skin",
			Resource: &walletv1.Resource{ResourceId: 7007, ResourceType: "gift_tray_skin", Status: "active", AnimationUrl: "https://cdn.example/vip-gift-tray.svga"},
		}},
	}}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		42: {
			UserId:              42,
			DisplayUserId:       "100042",
			PrettyDisplayUserId: "888042",
			Username:            "Robot Sender",
			Avatar:              "https://cdn.example/sender.png",
			CountryId:           840,
			RegionId:            1001,
		},
		43: {
			UserId:              43,
			DisplayUserId:       "100043",
			PrettyDisplayUserId: "888043",
			Username:            "Robot Receiver",
			Avatar:              "https://cdn.example/receiver.png",
			CountryId:           840,
			RegionId:            1001,
		},
	}}
	handler := NewHandlerWithClients(roomClient, nil, nil, profileClient)
	handler.SetUserHostClient(&fakeUserHostClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-profile","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
	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())
	}
	sender := roomClient.lastGift.GetSenderDisplayProfile()
	if sender.GetUserId() != 42 || sender.GetUsername() != "Robot Sender" || sender.GetAvatar() == "" || sender.GetDisplayUserId() != "100042" || sender.GetPrettyDisplayUserId() != "888042" || sender.GetEffectiveVipLevel() != 7 || sender.GetGiftBroadcastAnimationUrl() != "https://cdn.example/vip-gift-tray.svga" {
		t.Fatalf("gateway must inject sender display profile snapshot: %+v", sender)
	}
	targets := roomClient.lastGift.GetTargetDisplayProfiles()
	if len(targets) != 1 || targets[0].GetUserId() != 43 || targets[0].GetUsername() != "Robot Receiver" || targets[0].GetAvatar() == "" || targets[0].GetDisplayUserId() != "100043" {
		t.Fatalf("gateway must inject target display profile snapshots: %+v", targets)
	}
	if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 || len(profileClient.lastBatch.GetUserIds()) != 2 || profileClient.lastBatch.GetUserIds()[0] != 42 || profileClient.lastBatch.GetUserIds()[1] != 43 {
		t.Fatalf("gift user snapshots must use one batch profile request: get=%+v batch=%+v calls=%d", profileClient.lastGet, profileClient.lastBatch, len(profileClient.batchRequests))
	}
	if walletClient.lastMyVip.GetUserId() != 42 {
		t.Fatalf("gift sender VIP snapshot must query the authenticated sender: %+v", walletClient.lastMyVip)
	}
}

func TestSendGiftInjectsHostPeriodScopeForEachTarget(t *testing.T) {
	roomClient := &fakeRoomClient{}
	hostClient := &fakeUserHostClient{hostProfiles: map[int64]*userv1.HostProfile{
		43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001},
		44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002},
	}}
	handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-multi-host","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
	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 hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 || hostClient.lastBatchHost == nil || len(hostClient.lastBatchHost.GetUserIds()) != 2 || hostClient.lastBatchHost.GetUserIds()[0] != 43 || hostClient.lastBatchHost.GetUserIds()[1] != 44 {
		t.Fatalf("gateway must resolve multi-target host profiles in one batch: batch=%+v batch_calls=%d single_calls=%d", hostClient.lastBatchHost, hostClient.batchHostCalls, hostClient.hostCalls)
	}
	scopes := roomClient.lastGift.GetTargetHostScopes()
	if len(scopes) != 2 || !scopes[0].GetTargetIsHost() || scopes[0].GetTargetHostRegionId() != 8801 || !scopes[1].GetTargetIsHost() || scopes[1].GetTargetHostRegionId() != 8802 {
		t.Fatalf("gateway must inject each target host scope: %+v", scopes)
	}
	if !roomClient.lastGift.GetTargetIsHost() || roomClient.lastGift.GetTargetHostRegionId() != 8801 {
		t.Fatalf("legacy first-target host scope must remain populated: %+v", roomClient.lastGift)
	}
}

func TestSendGiftFallsBackToSingleHostLookupWhenBatchRPCIsUnimplemented(t *testing.T) {
	roomClient := &fakeRoomClient{}
	hostClient := &fakeUserHostClient{
		batchHostErr: status.Error(codes.Unimplemented, "unknown method BatchGetHostProfiles"),
		hostProfiles: map[int64]*userv1.HostProfile{
			43: {UserId: 43, Status: "active", RegionId: 8801, CurrentAgencyOwnerUserId: 30001},
			44: {UserId: 44, Status: "active", RegionId: 8802, CurrentAgencyOwnerUserId: 30002},
		},
	}
	handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host-fallback","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
	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 hostClient.batchHostCalls != 1 || hostClient.hostCalls != 2 {
		t.Fatalf("unimplemented batch RPC should fall back to one single lookup per target: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
	}
	scopes := roomClient.lastGift.GetTargetHostScopes()
	if len(scopes) != 2 || scopes[0].GetTargetHostRegionId() != 8801 || scopes[1].GetTargetHostRegionId() != 8802 {
		t.Fatalf("fallback must preserve each target host scope: %+v", scopes)
	}
}

func TestSendGiftPropagatesBatchHostProfileErrors(t *testing.T) {
	roomClient := &fakeRoomClient{}
	hostClient := &fakeUserHostClient{batchHostErr: xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host profile lookup failed"))}
	handler := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-host-error","target_user_ids":[43,44],"gift_id":"rose","gift_count":2}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code == http.StatusOK || roomClient.lastGift != nil {
		t.Fatalf("batch host lookup errors must block SendGift: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
	}
	if hostClient.batchHostCalls != 1 || hostClient.hostCalls != 0 {
		t.Fatalf("non-unimplemented batch errors must not fall back to single lookup: batch_calls=%d single_calls=%d", hostClient.batchHostCalls, hostClient.hostCalls)
	}
}

func TestSendGiftRequiresSenderCountryRegionFromBatchUserProfile(t *testing.T) {
	roomClient := &fakeRoomClient{}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		42: {UserId: 42, Status: userv1.UserStatus_USER_STATUS_ACTIVE, RegionId: 1001},
		43: {UserId: 43, Status: userv1.UserStatus_USER_STATUS_ACTIVE, CountryId: 840, RegionId: 1001},
	}}
	router := NewHandlerWithClients(roomClient, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-sender-country","target_user_id":43,"gift_id":"rose","gift_count":1}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusBadRequest || roomClient.lastGift != nil {
		t.Fatalf("sender missing country must block SendGift before room RPC: status=%d gift=%+v body=%s", recorder.Code, roomClient.lastGift, recorder.Body.String())
	}
	if profileClient.lastGet != nil || len(profileClient.batchRequests) != 1 {
		t.Fatalf("sender country validation must use batch profile path: get=%+v batches=%d", profileClient.lastGet, len(profileClient.batchRequests))
	}
}

func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
	previousNow := roomapi.TimeNow
	roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
	defer func() { roomapi.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},
		EffectiveVip: &roomv1.RoomEffectiveVipSnapshot{ProgramType: "tiered_privilege_v1", Level: 8, Name: "VIP8", RoomEntryNoticeEnabled: true},
		Room: &roomv1.RoomSnapshot{
			RoomId:      "room_1001",
			OwnerUserId: 101,
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			Locked:      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",
		},
	}}
	profileCardResource := &walletv1.Resource{
		ResourceId:    8104,
		ResourceCode:  "profile_card_gold",
		ResourceType:  "profile_card",
		Name:          "Gold Profile Card",
		AssetUrl:      "https://cdn.example/profile-card.png",
		AnimationUrl:  "https://cdn.example/profile-card.mp4",
		MetadataJson:  `{"profile_card_layout":{"source_width":1136,"source_height":1680,"content_top":143,"content_bottom":1667,"content_height":1525}}`,
		GrantStrategy: "extend_expiry",
		Status:        "active",
	}
	micSeatResource := &walletv1.Resource{
		ResourceId:    8106,
		ResourceCode:  "mic_seat_wave_gold",
		ResourceType:  "mic_seat_animation",
		Name:          "Gold Mic Wave",
		AssetUrl:      "https://cdn.example/mic-seat.png",
		AnimationUrl:  "https://cdn.example/mic-seat.svga",
		MetadataJson:  `{"usage":"mic_seat"}`,
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	micSeatIconResource := &walletv1.Resource{
		ResourceId:    8107,
		ResourceCode:  "mic_seat_icon_gold",
		ResourceType:  "mic_seat_icon",
		Name:          "Gold Mic Icon",
		AssetUrl:      "https://cdn.example/mic-seat-icon.png",
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	shortBadgeResource := &walletv1.Resource{
		ResourceId:    8105,
		ResourceCode:  "badge_tile_gold",
		ResourceType:  "badge",
		Name:          "Gold Tile Badge",
		PreviewUrl:    "https://cdn.example/badge-tile.png",
		MetadataJson:  `{"badge_form":"tile","badge_kind":"normal"}`,
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	walletClient := &fakeWalletClient{
		batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
			Users: []*walletv1.UserEquippedResources{{
				UserId:                 42,
				EffectiveVipLevel:      9,
				AllowedVipBenefitCodes: []string{"colored_nickname", "colored_id", "colored_room_name"},
				Resources: []*walletv1.UserResourceEntitlement{
					{
						UserId:        42,
						EntitlementId: "ent-profile-card",
						ResourceId:    8104,
						Resource:      profileCardResource,
						Equipped:      true,
						ExpiresAtMs:   1999999999999,
					},
					{
						UserId:          42,
						EntitlementId:   "ent-badge-tile",
						ResourceId:      8105,
						Resource:        shortBadgeResource,
						VipBenefitCodes: []string{"vip_badge_identity"},
						Equipped:        true,
						ExpiresAtMs:     1999999999999,
					},
					{
						UserId:        42,
						EntitlementId: "ent-mic-seat-icon",
						ResourceId:    8107,
						Resource:      micSeatIconResource,
						Equipped:      true,
						ExpiresAtMs:   1999999999999,
					},
					{
						UserId:        42,
						EntitlementId: "ent-mic-seat-wave",
						ResourceId:    8106,
						Resource:      micSeatResource,
						Equipped:      true,
						ExpiresAtMs:   1999999999999,
					},
				},
			}},
		},
	}
	profileClient := &fakeUserProfileClient{}
	handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
	handler.SetWalletClient(walletClient)
	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","password":"1234"}`)))
	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 || room["locked"] != true {
		t.Fatalf("unexpected initial room data: %+v", room)
	}
	if roomClient.lastJoin == nil || roomClient.lastJoin.GetPassword() != "1234" {
		t.Fatalf("join must forward room password to room-service: %+v", roomClient.lastJoin)
	}
	effectiveVIP := data["effective_vip"].(map[string]any)
	if effectiveVIP["program_type"] != "tiered_privilege_v1" || effectiveVIP["level"] != float64(8) || effectiveVIP["name"] != "VIP8" || effectiveVIP["room_entry_notice_enabled"] != true {
		t.Fatalf("join response must expose room effective VIP snapshot: %+v", effectiveVIP)
	}
	joinProfile := roomClient.lastJoin.GetActorDisplayProfile()
	if joinProfile.GetUserId() != 42 || joinProfile.GetNickname() != "hy" || joinProfile.GetAvatar() != "https://cdn.example/avatar.png" || joinProfile.GetDisplayUserId() != "100001" {
		t.Fatalf("join must inject actor display profile snapshot: %+v", joinProfile)
	}
	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)
	}
	hasRoomDisplayResourceQuery := false
	for _, req := range walletClient.batchEquippedRequests {
		if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,chat_bubble,badge,mic_seat_icon,mic_seat_animation" {
			if strings.Join(req.GetVipBenefitCodes(), ",") != "colored_nickname,colored_id,colored_room_name" {
				t.Fatalf("room display query must request server-authorized colorful identity benefits: %+v", req)
			}
			hasRoomDisplayResourceQuery = true
			break
		}
	}
	if !hasRoomDisplayResourceQuery {
		t.Fatalf("join response must batch independent mic_seat_icon and mic_seat_animation resources: %+v", walletClient.batchEquippedRequests)
	}
	profiles, profilesOK := data["profiles"].([]any)
	if !profilesOK || len(profiles) == 0 {
		t.Fatalf("join response must include room display profiles: %+v", data["profiles"])
	}
	var currentProfile map[string]any
	for _, item := range profiles {
		profile, ok := item.(map[string]any)
		if !ok || profile["user_id"] != "42" {
			continue
		}
		currentProfile = profile
		break
	}
	profileCard, profileCardOK := currentProfile["profile_card"].(map[string]any)
	metadataJSON, _ := profileCard["metadata_json"].(string)
	if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) {
		t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile)
	}
	vip, vipOK := currentProfile["vip"].(map[string]any)
	vipBenefitCodes, vipBenefitCodesOK := vip["benefit_codes"].([]any)
	if !vipOK || vip["level"] != float64(9) || vip["active"] != true ||
		!vipBenefitCodesOK || len(vipBenefitCodes) != 3 ||
		vipBenefitCodes[0] != "colored_nickname" || vipBenefitCodes[1] != "colored_id" || vipBenefitCodes[2] != "colored_room_name" {
		t.Fatalf("join profile must include wallet-confirmed paid/trial VIP identity: %+v", currentProfile)
	}
	micSeatIcon, micSeatIconOK := currentProfile["mic_seat_icon"].(map[string]any)
	if !micSeatIconOK || micSeatIcon["resource_id"] != float64(8107) || micSeatIcon["asset_url"] != "https://cdn.example/mic-seat-icon.png" {
		t.Fatalf("join profile must include mic_seat_icon independently from Voice Wave: %+v", currentProfile)
	}
	micSeatAnimation, micSeatAnimationOK := currentProfile["mic_seat_animation"].(map[string]any)
	if !micSeatAnimationOK || micSeatAnimation["resource_id"] != float64(8106) || micSeatAnimation["animation_url"] != "https://cdn.example/mic-seat.svga" {
		t.Fatalf("join profile must include mic_seat_animation resource: %+v", currentProfile)
	}
	badges, badgesOK := currentProfile["badges"].([]any)
	if !badgesOK || len(badges) != 1 {
		t.Fatalf("join profile must include equipped badge list: %+v", currentProfile["badges"])
	}
	badge := badges[0].(map[string]any)
	badgeResource := badge["resource"].(map[string]any)
	vipBadgeBenefitCodes, vipBadgeBenefitCodesOK := badgeResource["vip_benefit_codes"].([]any)
	if badge["badge_form"] != "tile" || badgeResource["preview_url"] != "https://cdn.example/badge-tile.png" ||
		!vipBadgeBenefitCodesOK || len(vipBadgeBenefitCodes) != 1 || vipBadgeBenefitCodes[0] != "vip_badge_identity" {
		t.Fatalf("join profile must include equipped tile badge resource: %+v", badge)
	}
}

func TestGetRoomDetailPermissionsExcludeMutedUserFromMicUp(t *testing.T) {
	queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
		Room: &roomv1.RoomSnapshot{
			RoomId:      "room-muted-permissions",
			OwnerUserId: 101,
			Status:      "active",
			ChatEnabled: true,
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1}, {SeatNo: 2}},
			OnlineUsers: []*roomv1.RoomUser{{UserId: 42, Role: "audience"}},
			MuteUserIds: []int64{42},
			Version:     3,
		},
		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/room-muted-permissions/detail", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-muted-permissions")
	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 muted room detail response failed: %v", err)
	}
	permissions := response.Data.(map[string]any)["permissions"].(map[string]any)
	if permissions["is_muted"] != true || permissions["can_speak"] != false || permissions["can_mic_up"] != false {
		t.Fatalf("muted viewer permissions must reject public chat and mic up: %+v", permissions)
	}
}

func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing.T) {
	roomClient := &fakeRoomClient{joinResp: &roomv1.JoinRoomResponse{
		Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8, ServerTimeMs: 1_700_000_010_123},
		User:   &roomv1.RoomUser{UserId: 42, Role: "audience", JoinedAtMs: 1_700_000_010_000, LastSeenAtMs: 1_700_000_010_100},
		Room: &roomv1.RoomSnapshot{
			RoomId:      "room_lite",
			OwnerUserId: 101,
			Mode:        "voice",
			Status:      "active",
			ChatEnabled: true,
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1, UserId: 102, PublishState: "publishing"}, {SeatNo: 2, Locked: true}},
			GiftRank:    []*roomv1.RankItem{{UserId: 301, Score: 900, GiftValue: 900}},
			RoomExt:     map[string]string{"title": "Lite Room", "cover_url": "https://cdn.example/lite.png"},
			Heat:        88,
			Version:     8,
			RoomShortId: "100102",
			OnlineCount: 200,
			Locked:      true,
		},
	}}
	walletClient := &fakeWalletClient{batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
		Users: []*walletv1.UserEquippedResources{{
			UserId: 42,
			Resources: []*walletv1.UserResourceEntitlement{{
				UserId:        42,
				EntitlementId: "ent-lite-vehicle",
				ExpiresAtMs:   1_999_999_999_999,
				Equipped:      true,
				Resource: &walletv1.Resource{
					ResourceId:   8201,
					ResourceCode: "vehicle-lite",
					ResourceType: "vehicle",
					Name:         "Lite Vehicle",
					AssetUrl:     "https://cdn.example/vehicle.png",
					PreviewUrl:   "https://cdn.example/vehicle-preview.png",
					AnimationUrl: "https://cdn.example/vehicle.svga",
					MetadataJson: `{"format":"svga"}`,
				},
			}},
		}},
	}}
	profileClient := &fakeUserProfileClient{}
	handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, profileClient)
	handler.SetWalletClient(walletClient)
	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_lite","command_id":"cmd-join-lite","role":"audience","response_mode":"lite","presence_recovery":true}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-join-lite")
	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 roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || !roomClient.lastJoin.GetPresenceRecovery() {
		t.Fatalf("lite join must preserve response_mode and recovery semantics: %+v", roomClient.lastJoin)
	}
	vehicle := roomClient.lastJoin.GetEntryVehicle()
	if vehicle.GetResourceId() != 8201 || vehicle.GetAnimationUrl() != "https://cdn.example/vehicle.svga" || vehicle.GetEntitlementId() != "ent-lite-vehicle" {
		t.Fatalf("lite join must pass the current user's entry vehicle snapshot: %+v", vehicle)
	}
	if len(walletClient.batchEquippedRequests) != 1 {
		t.Fatalf("lite join must issue exactly one minimal vehicle lookup: %+v", walletClient.batchEquippedRequests)
	}
	equippedRequest := walletClient.batchEquippedRequests[0]
	if strings.Join(int64SliceToStrings(equippedRequest.GetUserIds()), ",") != "42" || strings.Join(equippedRequest.GetResourceTypes(), ",") != "vehicle" {
		t.Fatalf("lite vehicle lookup must stay scoped to the actor and vehicle type: %+v", equippedRequest)
	}
	if walletClient.batchEquippedBudget <= 0 || walletClient.batchEquippedBudget > 100*time.Millisecond {
		t.Fatalf("lite vehicle lookup must use the short best-effort budget: %s", walletClient.batchEquippedBudget)
	}
	if len(profileClient.batchRequests) != 0 {
		t.Fatalf("lite join must not call full BatchGetUsers: %+v", profileClient.batchRequests)
	}
	if profileClient.lastRoomBasicBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastRoomBasicBatch.GetUserIds()), ",") != "101,42,102" {
		t.Fatalf("lite join must batch only owner/viewer/mic basic users: %+v", profileClient.lastRoomBasicBatch)
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode lite join response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	if _, exists := data["contribution_rank"]; exists {
		t.Fatalf("lite join must not include contribution rank: %+v", data["contribution_rank"])
	}
	room := data["room"].(map[string]any)
	if room["room_id"] != "room_lite" || room["online_count"].(float64) != 200 {
		t.Fatalf("lite join must keep core room fields and online_count: %+v", room)
	}
	profiles := data["profiles"].([]any)
	if len(profiles) != 3 {
		t.Fatalf("lite join must include basic profiles for owner/viewer/mic only: %+v", profiles)
	}
	viewerProfile := map[string]any{}
	for _, item := range profiles {
		profile := item.(map[string]any)
		if profile["user_id"] == "42" {
			viewerProfile = profile
		}
		if profile["user_id"] == "301" {
			t.Fatalf("lite join must not hydrate contribution-rank users: %+v", profiles)
		}
	}
	if viewerProfile["username"] != "user-42" || viewerProfile["avatar"] == "" || viewerProfile["display_user_id"] == "" {
		t.Fatalf("lite profile must carry only basic display fields: %+v", viewerProfile)
	}
	if _, exists := viewerProfile["avatar_frame"]; exists {
		t.Fatalf("lite profile must not carry appearance fields: %+v", viewerProfile)
	}
}

func TestJoinRoomLiteContinuesWhenEntryVehicleLookupFails(t *testing.T) {
	roomClient := &fakeRoomClient{}
	walletClient := &fakeWalletClient{err: errors.New("wallet unavailable")}
	handler := NewHandlerWithConfig(roomClient, nil, nil, nil, TencentIMConfig{}, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_lite_degraded","command_id":"cmd-join-lite-degraded","role":"audience","response_mode":"lite"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-join-lite-degraded")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("vehicle lookup failure must not block lite join: status=%d body=%s", recorder.Code, recorder.Body.String())
	}
	if roomClient.lastJoin == nil || roomClient.lastJoin.GetEntryVehicle() != nil {
		t.Fatalf("degraded lite join must submit presence without a fabricated vehicle: %+v", roomClient.lastJoin)
	}
	if len(walletClient.batchEquippedRequests) != 1 || walletClient.batchEquippedBudget <= 0 || walletClient.batchEquippedBudget > 100*time.Millisecond {
		t.Fatalf("degraded lite join must keep one bounded vehicle lookup: requests=%+v budget=%s", walletClient.batchEquippedRequests, walletClient.batchEquippedBudget)
	}
}

func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
		RegionId:  1001,
		Countries: []string{"AE", "US", "TR"},
	}}}
	countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{
		Countries: []*userv1.Country{
			{CountryId: 1, CountryCode: "AE", CountryName: "United Arab Emirates", CountryDisplayName: "UAE", Flag: "🇦🇪"},
			{CountryId: 2, CountryCode: "US", CountryName: "United States", CountryDisplayName: "US", Flag: "🇺🇸"},
			{CountryId: 3, CountryCode: "TR", CountryName: "Türkiye", CountryDisplayName: "TR", Flag: "🇹🇷"},
		},
	}}
	queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
		Rooms: []*roomv1.RoomListItem{
			{
				RoomId: "room-1", VisibleRegionId: 1001, Heat: 90, Locked: true, CountryCode: "US", OwnerVipLevel: 5,
				RoomBorder: &roomv1.RoomDecorationResource{ResourceId: 5201, ResourceType: "room_border", AssetUrl: "https://cdn.example.com/room-border/gold.webp"},
			},
		},
		NextCursor: "cursor-2",
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetRoomQueryClient(queryClient)
	handler.SetUserRegionClient(regionClient)
	handler.SetUserCountryQueryClient(countryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&cursor=cursor-1&q=room&country=US&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 || queryClient.lastList.GetViewerCountryCode() != "US" {
		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" || queryClient.lastList.GetCountryCode() != "US" {
		t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
	}
	if regionClient.last == nil || regionClient.last.GetRegionId() != 1001 || countryClient.last == nil {
		t.Fatalf("ListRooms must fetch region countries and country metadata: region=%+v countries=%+v", regionClient.last, countryClient.last)
	}
	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)
	countries := data["countries"].([]any)
	if len(countries) != 3 {
		t.Fatalf("room list must expose current region countries: %+v", countries)
	}
	for index, want := range []string{"US", "AE", "TR"} {
		country := countries[index].(map[string]any)
		if country["country_code"] != want {
			t.Fatalf("country order mismatch at %d: want %s got %+v", index, want, countries)
		}
	}
	rooms := data["rooms"].([]any)
	first := rooms[0].(map[string]any)
	if first["im_group_id"] != "room-1" || first["locked"] != true || first["country_code"] != "US" || first["owner_vip_level"] != float64(5) || first["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" {
		t.Fatalf("room list must expose room IM group id: %+v", first)
	}
}

func TestSaveRoomBorderAppliesOwnedResourceAndReturnsFlatURL(t *testing.T) {
	client := &fakeRoomVIPClient{fakeRoomClient: &fakeRoomClient{}}
	router := NewHandler(client).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/room-1/border/save", bytes.NewReader([]byte(`{"command_id":"cmd-save-border","resource_id":"5201","entitlement_id":"ent-border-1"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-save-border")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusOK {
		t.Fatalf("save room border status mismatch: got=%d body=%s", recorder.Code, recorder.Body.String())
	}
	forwarded := client.lastApplyDecoration
	if forwarded == nil || forwarded.GetMeta().GetRoomId() != "room-1" || forwarded.GetMeta().GetActorUserId() != 42 || forwarded.GetMeta().GetCommandId() != "cmd-save-border" || forwarded.GetDecorationType() != "room_border" || forwarded.GetResourceId() != 5201 || forwarded.GetEntitlementId() != "ent-border-1" {
		t.Fatalf("save room border must reuse authoritative decoration command: %+v", forwarded)
	}
	var envelope httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode save room border response failed: %v", err)
	}
	data := envelope.Data.(map[string]any)
	room := data["room"].(map[string]any)
	if data["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" || room["room_border_url"] != "https://cdn.example.com/room-border/gold.webp" {
		t.Fatalf("save room border response must expose flat URL: %+v", data)
	}
}

func TestListRoomsHuwaaDefaultsToAllRegionsAndSupportsValidatedFilters(t *testing.T) {
	newHandler := func(regionClient *fakeUserRegionClient, countryClient *fakeUserCountryQueryClient, queryClient *fakeRoomQueryClient) http.Handler {
		handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
		handler.SetAppConfigReader(appconfig.StaticReader{RoomDiscoveryAll: map[string]bool{"huwaa": true}})
		handler.SetRoomQueryClient(queryClient)
		handler.SetUserRegionClient(regionClient)
		handler.SetUserCountryQueryClient(countryClient)
		return handler.Routes(auth.NewVerifier("secret"))
	}
	currentRegion := &userv1.RegionResponse{Region: &userv1.Region{
		RegionId: 1001, RegionCode: "ME", Status: "active", Countries: []string{"US", "AE"},
	}}
	activeRegions := &userv1.ListRegionsResponse{Regions: []*userv1.Region{
		{RegionId: 1001, RegionCode: "ME", Status: "active", Countries: []string{"US", "AE"}},
		{RegionId: 2002, RegionCode: "SEA", Status: "active", Countries: []string{"BD", "PK"}},
	}}
	registrationCountries := &userv1.ListRegistrationCountriesResponse{Countries: []*userv1.Country{
		{CountryId: 1, CountryCode: "US"},
		{CountryId: 2, CountryCode: "AE"},
		{CountryId: 3, CountryCode: "BD"},
		{CountryId: 4, CountryCode: "PK"},
	}}

	t.Run("all", func(t *testing.T) {
		regionClient := &fakeUserRegionClient{resp: currentRegion}
		queryClient := &fakeRoomQueryClient{}
		router := newHandler(regionClient, &fakeUserCountryQueryClient{resp: registrationCountries}, queryClient)
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
		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.lastList == nil || !queryClient.lastList.GetAllVisibleRegions() || queryClient.lastList.GetFilterRegionId() != 0 || queryClient.lastList.GetVisibleRegionId() != 1001 {
			t.Fatalf("Huwaa All must preserve viewer region only as sort context and query every region: %+v", queryClient.lastList)
		}
		if regionClient.lastList != nil {
			t.Fatalf("All needs no catalog validation RPC: %+v", regionClient.lastList)
		}
	})

	t.Run("region", func(t *testing.T) {
		regionClient := &fakeUserRegionClient{resp: currentRegion, listResp: activeRegions}
		queryClient := &fakeRoomQueryClient{}
		router := newHandler(regionClient, &fakeUserCountryQueryClient{resp: registrationCountries}, queryClient)
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?region_id=2002", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
		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.lastList == nil || queryClient.lastList.GetAllVisibleRegions() || queryClient.lastList.GetFilterRegionId() != 2002 || queryClient.lastList.GetVisibleRegionId() != 2002 {
			t.Fatalf("validated Huwaa region filter mismatch: %+v", queryClient.lastList)
		}
		if regionClient.lastList == nil || regionClient.lastList.GetStatus() != "active" {
			t.Fatalf("region filter must resolve against active catalog: %+v", regionClient.lastList)
		}
	})

	t.Run("country", func(t *testing.T) {
		regionClient := &fakeUserRegionClient{resp: currentRegion, listResp: activeRegions}
		queryClient := &fakeRoomQueryClient{}
		router := newHandler(regionClient, &fakeUserCountryQueryClient{resp: registrationCountries}, queryClient)
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?country_code=BD", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
		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.lastList == nil || !queryClient.lastList.GetAllVisibleRegions() || queryClient.lastList.GetCountryCode() != "BD" || queryClient.lastList.GetFilterRegionId() != 0 {
			t.Fatalf("validated Huwaa country filter mismatch: %+v", queryClient.lastList)
		}
	})

	t.Run("country_without_active_region_mapping", func(t *testing.T) {
		regionClient := &fakeUserRegionClient{resp: currentRegion, listResp: activeRegions}
		queryClient := &fakeRoomQueryClient{}
		countries := &userv1.ListRegistrationCountriesResponse{Countries: append(append([]*userv1.Country{}, registrationCountries.GetCountries()...), &userv1.Country{CountryId: 5, CountryCode: "CN"})}
		router := newHandler(regionClient, &fakeUserCountryQueryClient{resp: countries}, queryClient)
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?country_code=CN", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if recorder.Code != http.StatusBadRequest || queryClient.lastList != nil {
			t.Fatalf("unmapped country must be rejected before room-service: status=%d req=%+v body=%s", recorder.Code, queryClient.lastList, recorder.Body.String())
		}
	})

	t.Run("region_outside_active_catalog", func(t *testing.T) {
		regionClient := &fakeUserRegionClient{resp: currentRegion, listResp: activeRegions}
		queryClient := &fakeRoomQueryClient{}
		router := newHandler(regionClient, &fakeUserCountryQueryClient{resp: registrationCountries}, queryClient)
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?region_id=9999", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if recorder.Code != http.StatusBadRequest || queryClient.lastList != nil {
			t.Fatalf("unknown region must be rejected before room-service: status=%d req=%+v body=%s", recorder.Code, queryClient.lastList, recorder.Body.String())
		}
	})
}

func TestListRoomsHuwaaAllowsAllAndCatalogFiltersWithoutViewerRegion(t *testing.T) {
	activeRegions := &userv1.ListRegionsResponse{Regions: []*userv1.Region{
		{RegionId: 2002, RegionCode: "SEA", Status: "active", Countries: []string{"BD"}},
	}}
	registrationCountries := &userv1.ListRegistrationCountriesResponse{Countries: []*userv1.Country{
		{CountryId: 3, CountryCode: "BD"},
	}}
	tests := []struct {
		name              string
		path              string
		wantAll           bool
		wantRegion        int64
		wantVisibleRegion int64
		wantCountry       string
	}{
		{name: "all", path: "/api/v1/rooms", wantAll: true},
		{name: "region", path: "/api/v1/rooms?region_id=2002", wantRegion: 2002, wantVisibleRegion: 2002},
		{name: "country", path: "/api/v1/rooms?country_code=BD", wantAll: true, wantCountry: "BD"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
				42: {UserId: 42, Country: "US", RegionId: 0},
			}}
			regionClient := &fakeUserRegionClient{listResp: activeRegions}
			countryClient := &fakeUserCountryQueryClient{resp: registrationCountries}
			queryClient := &fakeRoomQueryClient{}
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
			handler.SetAppConfigReader(appconfig.StaticReader{RoomDiscoveryAll: map[string]bool{"huwaa": true}})
			handler.SetRoomQueryClient(queryClient)
			handler.SetUserRegionClient(regionClient)
			handler.SetUserCountryQueryClient(countryClient)
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodGet, tt.path, nil)
			request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
			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.lastList == nil || queryClient.lastList.GetVisibleRegionId() != tt.wantVisibleRegion || queryClient.lastList.GetAllVisibleRegions() != tt.wantAll || queryClient.lastList.GetFilterRegionId() != tt.wantRegion || queryClient.lastList.GetCountryCode() != tt.wantCountry {
				t.Fatalf("regionless Huwaa list scope mismatch: %+v", queryClient.lastList)
			}
			if regionClient.last != nil {
				t.Fatalf("regionless Huwaa user must not trigger GetRegion or guess a legacy region: %+v", regionClient.last)
			}
			var envelope httpkit.ResponseEnvelope
			if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
				t.Fatalf("decode room list response failed: %v", err)
			}
			if rawCountries, exists := envelope.Data.(map[string]any)["countries"]; exists {
				if countries, ok := rawCountries.([]any); !ok || len(countries) != 0 {
					t.Fatalf("regionless Huwaa legacy countries must stay empty: %+v", rawCountries)
				}
			}
		})
	}
}

func TestListRoomsRejectsConflictingFiltersAndKeepsNonHuwaaRegionIsolation(t *testing.T) {
	t.Run("conflicting_huwaa_filters", 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?region_id=2002&country_code=BD", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "huwaa"))
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if recorder.Code != http.StatusBadRequest || queryClient.lastList != nil {
			t.Fatalf("conflicting filters must fail before upstream calls: status=%d req=%+v body=%s", recorder.Code, queryClient.lastList, recorder.Body.String())
		}
	})

	t.Run("non_huwaa_region_filter", func(t *testing.T) {
		profileClient := &fakeUserProfileClient{regionID: 1001}
		regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{RegionId: 1001, Countries: []string{"US"}}}}
		countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{Countries: []*userv1.Country{{CountryId: 1, CountryCode: "US"}}}}
		queryClient := &fakeRoomQueryClient{}
		handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
		handler.SetRoomQueryClient(queryClient)
		handler.SetUserRegionClient(regionClient)
		handler.SetUserCountryQueryClient(countryClient)
		router := handler.Routes(auth.NewVerifier("secret"))
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?region_id=2002", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "lalu"))
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if recorder.Code != http.StatusBadRequest || queryClient.lastList != nil {
			t.Fatalf("non-Huwaa region filter must not bypass current-region isolation: status=%d req=%+v body=%s", recorder.Code, queryClient.lastList, recorder.Body.String())
		}
	})

	t.Run("non_huwaa_missing_viewer_region", func(t *testing.T) {
		profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
			42: {UserId: 42, Country: "US", RegionId: 0},
		}}
		regionClient := &fakeUserRegionClient{}
		countryClient := &fakeUserCountryQueryClient{}
		queryClient := &fakeRoomQueryClient{}
		handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
		handler.SetRoomQueryClient(queryClient)
		handler.SetUserRegionClient(regionClient)
		handler.SetUserCountryQueryClient(countryClient)
		router := handler.Routes(auth.NewVerifier("secret"))
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "lalu"))
		recorder := httptest.NewRecorder()

		router.ServeHTTP(recorder, request)

		if recorder.Code != http.StatusBadRequest || queryClient.lastList != nil || regionClient.last != nil || countryClient.last != nil {
			t.Fatalf("non-Huwaa missing region must keep the previous fail-closed behavior: status=%d req=%+v body=%s", recorder.Code, queryClient.lastList, recorder.Body.String())
		}
	})
}

func TestGetRoomRocketEmbedsRewardResourceGroups(t *testing.T) {
	queryClient := &fakeRoomQueryClient{rocketResp: &roomv1.GetRoomRocketResponse{
		Rocket: &roomv1.RoomRocketInfo{
			Enabled:       true,
			LaunchDelayMs: 1200,
			Levels: []*roomv1.RoomRocketLevel{{
				Level:         1,
				FuelThreshold: 100,
				InRoomRewards: []*roomv1.RoomRocketRewardItem{{
					RewardItemId:    "in-room-coin",
					ResourceGroupId: 23,
					Weight:          100,
					DisplayName:     "1888 Coins",
				}},
			}},
			State: &roomv1.RoomRocketState{
				CurrentLevel:  1,
				CurrentFuel:   0,
				FuelThreshold: 100,
				Status:        "charging",
				LastRewards: []*roomv1.RoomRocketRewardGrant{{
					RewardRole:      "igniter",
					UserId:          42,
					RewardItemId:    "chat-bubble",
					ResourceGroupId: 24,
					DisplayName:     "Blue Bubble",
					Status:          "succeeded",
				}},
			},
		},
		ServerTimeMs: 1_780_000_000_000,
	}}
	walletClient := &fakeWalletClient{resourceGroupsByID: map[int64]*walletv1.ResourceGroup{
		23: {
			GroupId:   23,
			GroupCode: "coin_1888",
			Name:      "Coin Reward",
			Status:    "active",
			Items: []*walletv1.ResourceGroupItem{{
				GroupItemId:       2301,
				ItemType:          "wallet_asset",
				WalletAssetType:   "COIN",
				WalletAssetAmount: 1888,
				Quantity:          1,
			}},
		},
		24: {
			GroupId:   24,
			GroupCode: "bubble_7d",
			Name:      "Chat Bubble",
			Status:    "active",
			Items: []*walletv1.ResourceGroupItem{{
				GroupItemId: 2401,
				ItemType:    "resource",
				ResourceId:  8101,
				Resource: &walletv1.Resource{
					ResourceId:   8101,
					ResourceCode: "bubble_blue",
					ResourceType: "chat_bubble",
					Name:         "Blue Bubble",
					Status:       "active",
					AssetUrl:     "https://cdn.example/bubble.png",
				},
				Quantity:   1,
				DurationMs: 604800000,
			}},
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room_1001/rocket", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-rocket")
	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.lastRocket == nil || queryClient.lastRocket.GetViewerUserId() != 42 || queryClient.lastRocket.GetRoomId() != "room_1001" {
		t.Fatalf("GetRoomRocket must forward authenticated viewer and room: %+v", queryClient.lastRocket)
	}
	var envelope httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode rocket response failed: %v", err)
	}
	data := envelope.Data.(map[string]any)
	level := data["levels"].([]any)[0].(map[string]any)
	inRoomReward := level["in_room_rewards"].([]any)[0].(map[string]any)
	inRoomGroup := inRoomReward["resource_group"].(map[string]any)
	if inRoomReward["resource_group_id"].(float64) != 23 || inRoomGroup["group_id"].(float64) != 23 {
		t.Fatalf("rocket reward must embed resource group id 23: %+v", inRoomReward)
	}
	coinItem := inRoomGroup["items"].([]any)[0].(map[string]any)
	if coinItem["item_type"] != "wallet_asset" || coinItem["wallet_asset_type"] != "COIN" || coinItem["wallet_asset_amount"].(float64) != 1888 {
		t.Fatalf("rocket reward group must expose wallet asset amount: %+v", coinItem)
	}
	state := data["state"].(map[string]any)
	lastReward := state["last_rewards"].([]any)[0].(map[string]any)
	lastGroup := lastReward["resource_group"].(map[string]any)
	resourceItem := lastGroup["items"].([]any)[0].(map[string]any)
	resource := resourceItem["resource"].(map[string]any)
	if lastReward["resource_group_id"].(float64) != 24 || resource["resource_type"] != "chat_bubble" || resource["asset_url"] != "https://cdn.example/bubble.png" || resourceItem["duration_ms"].(float64) != 604800000 {
		t.Fatalf("rocket last reward must expose resource material and duration: reward=%+v item=%+v", lastReward, resourceItem)
	}
}

func TestListRoomsRejectsCountryOutsideUserRegion(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	regionClient := &fakeUserRegionClient{resp: &userv1.RegionResponse{Region: &userv1.Region{
		RegionId:  1001,
		Countries: []string{"US", "AE"},
	}}}
	countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{
		Countries: []*userv1.Country{
			{CountryId: 1, CountryCode: "US", Flag: "🇺🇸"},
			{CountryId: 2, CountryCode: "AE", Flag: "🇦🇪"},
			{CountryId: 3, CountryCode: "CN", Flag: "🇨🇳"},
		},
	}}
	queryClient := &fakeRoomQueryClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetRoomQueryClient(queryClient)
	handler.SetUserRegionClient(regionClient)
	handler.SetUserCountryQueryClient(countryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=hot&limit=2&country=CN", 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 {
		t.Fatalf("invalid country must not reach room-service: %+v", queryClient.lastList)
	}
}

func TestSetRoomPasswordForwardsPasswordAndReturnsLockedState(t *testing.T) {
	client := &fakeRoomClient{}
	router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/password", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-password","locked":true,"password":" 1234 "}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-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 client.lastRoomPassword == nil || !client.lastRoomPassword.GetLocked() || client.lastRoomPassword.GetPassword() != "1234" {
		t.Fatalf("room password request was not normalized and forwarded: %+v", client.lastRoomPassword)
	}
	if strings.Contains(recorder.Body.String(), "password_hash") || strings.Contains(recorder.Body.String(), "room_password_hash") {
		t.Fatalf("set password response must not expose password hash: %s", recorder.Body.String())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode room password response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	room := data["room"].(map[string]any)
	if room["locked"] != true {
		t.Fatalf("set password response must expose locked state: %+v", room)
	}
}

func TestListRoomsRejectsMineFeedTabs(t *testing.T) {
	for _, tab := range []string{"visited", "friend", "following", "followed", "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", "followed"} {
		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 == "friend" || tab == "following" {
				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", "followed":
				if len(queryClient.lastFeeds.GetRelatedUsers()) != 0 || socialClient.lastFollowing != nil || socialClient.lastFriends != nil {
					t.Fatalf("%s feed must not load social relations: feeds=%+v social=%+v/%+v", tab, 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(map[string]any{
		"tab":             "following",
		"query":           "music",
		"updated_at_ms":   int64(8000),
		"subject_user_id": int64(10002),
		"room_id":         "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 TestRoomFollowActionsUseAuthenticatedUser(t *testing.T) {
	for _, test := range []struct {
		name   string
		method string
	}{
		{name: "follow", method: http.MethodPost},
		{name: "unfollow", method: http.MethodDelete},
	} {
		t.Run(test.name, func(t *testing.T) {
			roomClient := &fakeRoomClient{}
			router := NewHandler(roomClient).Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(test.method, "/api/v1/rooms/room-follow/follow", nil)
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-room-"+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())
			}
			var response httpkit.ResponseEnvelope
			if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
				t.Fatalf("decode room follow response failed: %v", err)
			}
			data := response.Data.(map[string]any)
			switch test.method {
			case http.MethodPost:
				if roomClient.lastFollowRoom == nil || roomClient.lastFollowRoom.GetUserId() != 42 || roomClient.lastFollowRoom.GetRoomId() != "room-follow" {
					t.Fatalf("follow room request must use authenticated user and path room: %+v", roomClient.lastFollowRoom)
				}
				if data["is_followed"] != true || data["room_id"] != "room-follow" {
					t.Fatalf("follow response mismatch: %+v", data)
				}
			case http.MethodDelete:
				if roomClient.lastUnfollowRoom == nil || roomClient.lastUnfollowRoom.GetUserId() != 42 || roomClient.lastUnfollowRoom.GetRoomId() != "room-follow" {
					t.Fatalf("unfollow room request must use authenticated user and path room: %+v", roomClient.lastUnfollowRoom)
				}
				if data["is_followed"] != false || data["room_id"] != "room-follow" {
					t.Fatalf("unfollow response mismatch: %+v", data)
				}
			}
		})
	}
}

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 TestAppHeartbeatUsesAuthenticatedSession(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, userClient, nil, &fakeUserProfileClient{})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/app/heartbeat", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Device-ID", "dev-heartbeat")
	request.Header.Set("X-Request-ID", "req-app-heartbeat")
	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.lastAppHeartbeat == nil || userClient.lastAppHeartbeat.GetUserId() != 42 || userClient.lastAppHeartbeat.GetSessionId() != "sess-test" {
		t.Fatalf("app heartbeat must use authenticated user and session: %+v", userClient.lastAppHeartbeat)
	}
	if userClient.lastAppHeartbeat.GetMeta().GetDeviceId() != "dev-heartbeat" {
		t.Fatalf("app heartbeat must pass device meta: %+v", userClient.lastAppHeartbeat.GetMeta())
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode app heartbeat response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["accepted"] != true || data["heartbeat_at_ms"].(float64) != 1_778_000_005_000 {
		t.Fatalf("app heartbeat response mismatch: %+v", response.Data)
	}
	if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || data["access_token"] != "access-heartbeat" {
		t.Fatalf("app heartbeat must expose renewed access token: header=%q data=%+v", recorder.Header().Get("X-Hyapp-Access-Token"), data)
	}
	if recorder.Header().Get("X-Hyapp-Expires-In") != strconv.FormatInt(testAppAccessTokenTTLSec, 10) || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
		t.Fatalf("app heartbeat must expose renewed access token TTL: header=%q data=%+v", recorder.Header().Get("X-Hyapp-Expires-In"), data)
	}
}

func TestReportAppEventsUsesAuthenticatedUserDimensions(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 210}
	statisticsClient := &fakeStatisticsClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetStatisticsClient(statisticsClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"events":[{"event_id":"evt-login-1","event_name":"banner_click","event_type":"click","screen":"home","target_type":"banner","target_id":"17","device_id":"dev-body","user_id":999,"country_id":1,"region_id":2,"platform":"ios","app_version":"1.2.3","language":"en-US","timezone":"Asia/Shanghai","duration_ms":12,"success":true,"properties":{"slot":"home"}}]}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
	request.Header.Set("X-Device-ID", "dev-header")
	request.Header.Set("X-Request-ID", "req-app-events")
	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 len(statisticsClient.lastAppEvents) != 1 {
		t.Fatalf("statistics app events not called: %+v", statisticsClient.lastAppEvents)
	}
	event := statisticsClient.lastAppEvents[0]
	if event.AppCode != "fami" || event.UserID != 42 || event.CountryID != 840 || event.RegionID != 210 {
		t.Fatalf("authenticated event must use server dimensions: %+v", event)
	}
	if event.DeviceID != "dev-body" || event.EventName != "banner_click" || event.Platform != "ios" || string(event.Properties) != `{"slot":"home"}` {
		t.Fatalf("authenticated event fields mismatch: %+v properties=%s", event, string(event.Properties))
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode app events response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	if data["accepted"] != true || data["received"].(float64) != 1 || data["stored"].(float64) != 1 || data["duplicated"].(float64) != 0 {
		t.Fatalf("app events response mismatch: %+v", data)
	}
}

func TestReportAppEventsAllowsAnonymousWithDeviceID(t *testing.T) {
	statisticsClient := &fakeStatisticsClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetStatisticsClient(statisticsClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"events":[{"event_id":"evt-anon-1","event_name":"splash_view"}]}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
	request.Header.Set("X-Device-ID", "dev-anon")
	request.Header.Set("X-App-Platform", "android")
	request.Header.Set("X-App-Version", "2.0.1")
	request.Header.Set("Accept-Language", "ar-EG,ar;q=0.9")
	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 len(statisticsClient.lastAppEvents) != 1 {
		t.Fatalf("statistics app events not called: %+v", statisticsClient.lastAppEvents)
	}
	event := statisticsClient.lastAppEvents[0]
	if event.UserID != 0 || event.DeviceID != "dev-anon" || event.Platform != "android" || event.AppVersion != "2.0.1" || event.Language != "ar-EG" {
		t.Fatalf("anonymous event must use device/header fields: %+v", event)
	}
}

func TestReportAppEventsRejectsInvalidRequests(t *testing.T) {
	for _, test := range []struct {
		name string
		body []byte
	}{
		{name: "missing device anonymous", body: []byte(`{"events":[{"event_id":"evt-1","event_name":"open"}]}`)},
		{name: "missing event id", body: []byte(`{"events":[{"event_name":"open","device_id":"dev-1"}]}`)},
		{name: "missing event name", body: []byte(`{"events":[{"event_id":"evt-1","device_id":"dev-1"}]}`)},
		{name: "too many events", body: mustAppEventsBody(t, 51)},
	} {
		t.Run(test.name, func(t *testing.T) {
			statisticsClient := &fakeStatisticsClient{}
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
			handler.SetStatisticsClient(statisticsClient)
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(test.body))
			request.Header.Set("X-Request-ID", "req-invalid-app-events")
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-invalid-app-events")
			if len(statisticsClient.lastAppEvents) != 0 {
				t.Fatalf("invalid app events must not reach statistics-service: %+v", statisticsClient.lastAppEvents)
			}
		})
	}
}

func TestReportAppEventsRejects185ByteErrorCodeBeforeDownstream(t *testing.T) {
	statisticsClient := &fakeStatisticsClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetStatisticsClient(statisticsClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body, err := json.Marshal(map[string]any{
		"events": []map[string]any{{
			"event_id":   "evt-error-code-too-long",
			"event_name": "pay_failed",
			"device_id":  "dev-1",
			"error_code": strings.Repeat("e", 185),
		}},
	})
	if err != nil {
		t.Fatalf("marshal app events request: %v", err)
	}
	request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-app-events-error-code-limit")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	// Regression: statistics-service used to reject this after forwarding and
	// gateway translated that downstream 400 into a misleading 502.
	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-app-events-error-code-limit")
	if len(statisticsClient.lastAppEvents) != 0 {
		t.Fatalf("oversized error_code must not reach statistics-service: %+v", statisticsClient.lastAppEvents)
	}
}

func TestReportAppEventsUpstreamFailure(t *testing.T) {
	statisticsClient := &fakeStatisticsClient{appErr: errors.New("statistics unavailable")}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetStatisticsClient(statisticsClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"events":[{"event_id":"evt-upstream","event_name":"open","device_id":"dev-1"}]}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-app-events-upstream")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

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

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 TestBatchUserVoiceRoomPresencesUsesAuthenticatedViewerAndStableJSON(t *testing.T) {
	queryClient := &fakeRoomQueryClient{voicePresenceResp: &roomv1.BatchGetUserVoiceRoomPresencesResponse{
		Items: []*roomv1.UserVoiceRoomPresence{
			{
				UserId:       1002,
				State:        "on_mic",
				SeatNo:       3,
				Joinable:     true,
				ObservedAtMs: 1_784_265_600_000,
				Room: &roomv1.UserVoiceRoomPresenceRoom{
					RoomId:        "room-profile-live",
					RoomShortId:   "886688",
					OwnerUserId:   1001,
					HostUserId:    1001,
					Title:         "Live Room",
					CoverUrl:      "https://cdn.example.com/room.png",
					BackgroundUrl: "https://cdn.example.com/background.png",
					Mode:          "voice",
					OnlineCount:   56,
					SeatCount:     10,
					Locked:        true,
				},
			},
			{UserId: 1003, State: "not_on_mic", ObservedAtMs: 1_784_265_600_000},
		},
		ServerTimeMs: 1_784_265_600_123,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,1003,1002", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile-live")
	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.lastVoicePresence == nil || queryClient.lastVoicePresence.GetViewerUserId() != 42 {
		t.Fatalf("voice presence query must use authenticated viewer: %+v", queryClient.lastVoicePresence)
	}
	if got := queryClient.lastVoicePresence.GetUserIds(); len(got) != 2 || got[0] != 1002 || got[1] != 1003 {
		t.Fatalf("voice presence query must preserve first-seen deduplicated ids: %v", got)
	}
	if queryClient.lastVoicePresence.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-live") {
		t.Fatalf("voice presence query must propagate generated request id: %+v", queryClient.lastVoicePresence.GetMeta())
	}

	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode voice presence response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	items := data["items"].([]any)
	onMic := items[0].(map[string]any)
	room := onMic["room"].(map[string]any)
	if onMic["user_id"] != "1002" || onMic["state"] != "on_mic" || onMic["joinable"] != true || room["room_id"] != "room-profile-live" || room["rtc_room_id"] != "room-profile-live" || room["locked"] != true {
		t.Fatalf("on-mic response contract mismatch: %+v", onMic)
	}
	notOnMic := items[1].(map[string]any)
	if notOnMic["state"] != "not_on_mic" || notOnMic["joinable"] != false || notOnMic["room"] != nil {
		t.Fatalf("not-on-mic response must keep false and null fields: %+v", notOnMic)
	}
}

func TestBatchUserVoiceRoomPresencesRejectsInvalidIDs(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(&fakeRoomQueryClient{})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/voice-room-presence:batch?user_ids=1002,,1003", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile-live-invalid")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, "INVALID_USER_IDS", "req-profile-live-invalid")
}

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"])
	}
	if data["is_followed"] != false {
		t.Fatalf("snapshot DTO must expose is_followed=false explicitly: %+v", data)
	}
}

func TestGetRoomDetailIncludesFollowState(t *testing.T) {
	queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{
		Room: &roomv1.RoomSnapshot{
			RoomId:      "room-detail",
			OwnerUserId: 1001,
			Status:      "active",
			ChatEnabled: true,
			Version:     9,
			MicSeats:    []*roomv1.SeatState{{SeatNo: 1, UserId: 42}},
			OnlineUsers: []*roomv1.RoomUser{
				{UserId: 42, Role: "audience", JoinedAtMs: 1000, LastSeenAtMs: 2000},
			},
			RoomExt: map[string]string{"title": "Detail Room"},
		},
		ServerTimeMs: 123456,
		IsFollowed:   true,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-detail/detail", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-detail")
	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 || queryClient.lastSnapshot.GetViewerUserId() != 42 || queryClient.lastSnapshot.GetRoomId() != "room-detail" {
		t.Fatalf("detail must read snapshot with authenticated viewer: %+v", queryClient.lastSnapshot)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode detail response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	if data["is_followed"] != true {
		t.Fatalf("detail response must expose room follow state: %+v", data)
	}
}

func TestListRoomOnlineUsersIncludesProfileGenderAndAge(t *testing.T) {
	queryClient := &fakeRoomQueryClient{onlineResp: &roomv1.ListRoomOnlineUsersResponse{
		Items: []*roomv1.RoomOnlineUser{
			{
				UserId:       42,
				Role:         "audience",
				RoomRole:     "admin",
				GiftValue:    188800,
				JoinedAtMs:   1000,
				LastSeenAtMs: 2000,
			},
		},
		Total:        1,
		Page:         1,
		PageSize:     20,
		ServerTimeMs: 3000,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-online/online-users?page=1&page_size=20&sort=gift_value", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-online-users")
	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.lastOnline == nil || queryClient.lastOnline.GetRoomId() != "room-online" || queryClient.lastOnline.GetViewerUserId() != 42 || queryClient.lastOnline.GetSort() != "gift_value" {
		t.Fatalf("online list request mismatch: %+v", queryClient.lastOnline)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode online users 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 {
		t.Fatalf("online users response mismatch: %+v", response)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["user_id"] != "42" || first["room_role"] != "admin" || first["gift_value"] != float64(188800) {
		t.Fatalf("online user item mismatch: %+v", first)
	}
	profile, ok := first["profile"].(map[string]any)
	age, ageOK := profile["age"].(float64)
	if !ok || profile["gender"] != "female" || !ageOK || age <= 0 {
		t.Fatalf("online user profile must expose gender and positive age: %+v", first["profile"])
	}
	if profile["country"] != "US" || profile["country_name"] != "United States" || profile["country_display_name"] != "United States" || profile["country_flag"] != "🇺🇸" {
		t.Fatalf("online user profile must expose country code, name, display name, and flag: %+v", profile)
	}
	if _, exists := profile["birth"]; exists {
		t.Fatalf("online user profile must not expose birth: %+v", profile)
	}
}

func TestListRoomBannedUsersIncludesProfileCountryAndUnbanTime(t *testing.T) {
	queryClient := &fakeRoomQueryClient{bannedUsersResp: &roomv1.ListRoomBannedUsersResponse{
		Items: []*roomv1.RoomBannedUser{
			{
				UserId:      43,
				ActorUserId: 42,
				CreatedAtMs: 1000,
				UnbanAtMs:   7000,
				RemainingMs: 6000,
				Permanent:   false,
			},
		},
		Total:        1,
		Page:         1,
		PageSize:     20,
		ServerTimeMs: 2000,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-banned/banned-users?page=1&page_size=20", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-banned-users")
	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.lastBannedUsers == nil || queryClient.lastBannedUsers.GetRoomId() != "room-banned" || queryClient.lastBannedUsers.GetViewerUserId() != 42 || queryClient.lastBannedUsers.GetPageSize() != 20 {
		t.Fatalf("banned users request mismatch: %+v", queryClient.lastBannedUsers)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode banned users 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 {
		t.Fatalf("banned users response mismatch: %+v", response)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["user_id"] != "43" || first["actor_user_id"] != "42" || first["unban_at_ms"] != float64(7000) || first["remaining_ms"] != float64(6000) {
		t.Fatalf("banned user item mismatch: %+v", first)
	}
	if first["username"] != "user-43" || first["avatar"] != "https://cdn.example/avatar.png" || first["country"] != "US" || first["country_flag"] != "🇺🇸" {
		t.Fatalf("banned user profile fields mismatch: %+v", first)
	}
}

func TestGetRoomContributionRankIncludesProfiles(t *testing.T) {
	queryClient := &fakeRoomQueryClient{contributionRankResp: &roomv1.ListRoomContributionRankResponse{
		RoomId:       "room-rank",
		Period:       "week",
		StartAtMs:    1_780_000_000_000,
		EndAtMs:      1_780_604_800_000,
		Total:        1,
		ServerTimeMs: 1_780_000_010_000,
		Items: []*roomv1.RoomContributionRankItem{
			{Rank: 1, UserId: 43, Score: 900, GiftValue: 900, UpdatedAtMs: 1_780_000_001_000},
		},
	}}
	profileClient := &fakeUserProfileClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-rank/contribution-rank?period=week&limit=10", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-contribution-rank")
	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.lastContributionRank == nil || queryClient.lastContributionRank.GetRoomId() != "room-rank" || queryClient.lastContributionRank.GetPeriod() != "week" || queryClient.lastContributionRank.GetLimit() != 10 || queryClient.lastContributionRank.GetViewerUserId() != 42 {
		t.Fatalf("contribution rank request mismatch: %+v", queryClient.lastContributionRank)
	}
	if profileClient.lastBatch == nil || strings.Join(int64SliceToStrings(profileClient.lastBatch.GetUserIds()), ",") != "43" {
		t.Fatalf("contribution rank must hydrate ranked users: %+v", profileClient.lastBatch)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode contribution rank response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	items, itemsOK := data["contribution_rank"].([]any)
	profiles, profilesOK := data["profiles"].([]any)
	if response.Code != httpkit.CodeOK || response.RequestID == "" || response.RequestID != recorder.Header().Get("X-Request-ID") || !ok || !itemsOK || len(items) != 1 || !profilesOK || len(profiles) != 1 {
		t.Fatalf("contribution rank response mismatch: %+v", response)
	}
	if data["room_id"] != "room-rank" || data["period"] != "week" || data["start_at_ms"] != float64(1_780_000_000_000) || data["end_at_ms"] != float64(1_780_604_800_000) || data["total"] != float64(1) || data["server_time_ms"] != float64(1_780_000_010_000) {
		t.Fatalf("contribution rank metadata mismatch: %+v", data)
	}
	first := items[0].(map[string]any)
	if first["rank"] != float64(1) || first["user_id"] != "43" || first["score"] != float64(900) || first["gift_value"] != float64(900) || first["updated_at_ms"] != float64(1_780_000_001_000) {
		t.Fatalf("rank item mismatch: %+v", first)
	}
	profile, ok := first["profile"].(map[string]any)
	if !ok || profile["username"] != "user-43" || profile["avatar"] != "https://cdn.example/avatar.png" {
		t.Fatalf("rank item profile mismatch: %+v", first["profile"])
	}
}

func TestGetRoomContributionRankDefaultsPeriod(t *testing.T) {
	queryClient := &fakeRoomQueryClient{contributionRankResp: &roomv1.ListRoomContributionRankResponse{
		RoomId:       "room-rank",
		Period:       "day",
		StartAtMs:    1_780_000_000_000,
		EndAtMs:      1_780_086_400_000,
		ServerTimeMs: 1_780_000_010_000,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-rank/contribution-rank", 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 queryClient.lastContributionRank == nil || queryClient.lastContributionRank.GetPeriod() != "" || queryClient.lastContributionRank.GetLimit() != 20 {
		t.Fatalf("default contribution rank request mismatch: %+v", queryClient.lastContributionRank)
	}
}

func TestGetRoomContributionRankRejectsInvalidPeriod(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/room-rank/contribution-rank?period=year", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-room-contribution-rank-invalid")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-room-contribution-rank-invalid")
	if queryClient.lastContributionRank != nil {
		t.Fatalf("invalid period must not call room-service: %+v", queryClient.lastContributionRank)
	}
}

func TestGetRoomContributionRankRejectsInvalidLimit(t *testing.T) {
	tests := []string{"0", "-1", "abc"}
	for _, limit := range tests {
		t.Run(limit, func(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/room-rank/contribution-rank?period=day&limit="+limit, nil)
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-room-contribution-rank-limit")
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

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

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 TestListLoginRiskBlockedCountriesIsPublic(t *testing.T) {
	countryClient := &fakeUserCountryQueryClient{blockedResp: &userv1.ListLoginRiskBlockedCountriesResponse{
		Countries: []*userv1.LoginRiskBlockedCountry{
			{CountryCode: "CN", Keyword: "中国", UpdatedAtMs: 1700000000000},
		},
		UpdatedAtMs: 1700000000000,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserCountryQueryClient(countryClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/login-risk/blocked-countries", nil)
	request.Header.Set("X-Request-ID", "req-risk-blocks")
	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.lastBlocked == nil || countryClient.lastBlocked.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-risk-blocks") {
		t.Fatalf("login risk block request metadata mismatch: %+v", countryClient.lastBlocked)
	}
	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 || data["updated_at_ms"].(float64) != 1700000000000 {
		t.Fatalf("blocked countries response shape mismatch: %+v", data)
	}
	country, ok := countries[0].(map[string]any)
	if !ok || country["country_code"] != "CN" || country["keyword"] != "中国" {
		t.Fatalf("blocked 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 TestListH5LinksUsesBDLeaderPositionAliasForAdminItem(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAppConfigReader(appconfig.StaticReader{
		Links: []appconfig.H5Link{
			{Key: "admin", Label: "Admin Center", URL: "https://h5.example.com/admin", UpdatedAtMs: 1700000000000},
			{Key: "bd", Label: "BD Center", URL: "https://h5.example.com/bd", UpdatedAtMs: 1700000001000},
		},
		PositionAliases: map[string]string{"lalu:42": "Senior Admin"},
	})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/h5-links", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-h5-links-alias")
	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)
	}
	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"] != "admin" || first["label"] != "Senior Admin" || first["url"] != "https://h5.example.com/admin" {
		t.Fatalf("admin h5 alias 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 TestListExploreTabsReturnsEnabledAdminConfig(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAppConfigReader(appconfig.StaticReader{ExploreTabs: []appconfig.ExploreTab{
		{ID: 7, AppCode: "lalu", Tab: "推荐", H5URL: "https://h5.example.com/explore/recommend", Enabled: true, SortOrder: 1, UpdatedAtMs: 1700000000000},
		{ID: 8, AppCode: "lalu", Tab: "游戏", H5URL: "https://h5.example.com/explore/games", Enabled: true, SortOrder: 2, UpdatedAtMs: 1700000001000},
	}})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/explore-tabs", nil)
	request.Header.Set("X-Request-ID", "req-explore-tabs")
	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 || data["total"].(float64) != 2 {
		t.Fatalf("unexpected explore response: %+v", response)
	}
	items, ok := data["items"].([]any)
	if !ok || len(items) != 2 {
		t.Fatalf("explore items shape mismatch: %+v", data)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["tab"] != "推荐" || first["h5_url"] != "https://h5.example.com/explore/recommend" || first["enabled"] != true {
		t.Fatalf("explore item mismatch: %+v", first)
	}
}

func TestListExploreTabsRequiresConfigReader(t *testing.T) {
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/explore-tabs", nil)
	request.Header.Set("X-Request-ID", "req-explore-tabs-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-explore-tabs-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 TestAppBootstrapResolvesFamiPackageFallback(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-fami")
	request.Header.Set("X-App-Package", "com.chat.fami")
	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 || data["app_code"] != "fami" {
		t.Fatalf("fami bootstrap app_code mismatch: %+v", response)
	}
}

type captureAppConfigReader struct {
	appconfig.StaticReader
	bannerQuery appconfig.BannerQuery
	splashQuery appconfig.SplashScreenQuery
	popupQuery  appconfig.PopupQuery
}

func (r *captureAppConfigReader) ListBanners(ctx context.Context, query appconfig.BannerQuery) ([]appconfig.Banner, error) {
	r.bannerQuery = query
	return r.StaticReader.ListBanners(ctx, query)
}

func (r *captureAppConfigReader) ListSplashScreens(ctx context.Context, query appconfig.SplashScreenQuery) ([]appconfig.SplashScreen, error) {
	r.splashQuery = query
	return r.StaticReader.ListSplashScreens(ctx, query)
}

func (r *captureAppConfigReader) ListPopups(ctx context.Context, query appconfig.PopupQuery) ([]appconfig.Popup, error) {
	r.popupQuery = query
	return r.StaticReader.ListPopups(ctx, query)
}

func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{
		{
			ID:                8,
			CoverURL:          "https://cdn.example.com/banner.png",
			RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
			BannerType:        "h5",
			DisplayScope:      "home,room,me",
			DisplayScopes:     []string{"home", "room", "me"},
			Param:             "https://h5.example.com/activity",
			Platform:          "android",
			SortOrder:         10,
			Description:       "room banner",
			StartsAtMs:        1700000000000,
			EndsAtMs:          1800000000000,
			UpdatedAtMs:       1700000002000,
		},
	}}}
	handler.SetAppConfigReader(reader)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?display_scope=me&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 reader.bannerQuery.DisplayScope != "me" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 0 || reader.bannerQuery.Country != "" {
		t.Fatalf("banner query mismatch: %+v", reader.bannerQuery)
	}
	if profileClient.lastGet != nil {
		t.Fatalf("anonymous banner request must not query user profile: %+v", profileClient.lastGet)
	}
	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["display_scope"] != "home,room,me" || first["room_small_image_url"] != "https://cdn.example.com/banner-small.png" {
		t.Fatalf("banner display fields mismatch: %+v", first)
	}
	displayScopes, ok := first["display_scopes"].([]any)
	if !ok || len(displayScopes) != 3 || displayScopes[0] != "home" || displayScopes[1] != "room" || displayScopes[2] != "me" {
		t.Fatalf("banner display scopes mismatch: %+v", first)
	}
	if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000002000 {
		t.Fatalf("banner metadata mismatch: %+v", first)
	}
}

func TestListAppBannersUsesAuthenticatedUserRegion(t *testing.T) {
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		42: {
			UserId:   42,
			AppCode:  "lalu",
			RegionId: 1,
			Country:  "AE",
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Banners: []appconfig.Banner{
		{
			ID:           16,
			CoverURL:     "https://cdn.example.com/middle-east-banner.png",
			BannerType:   "h5",
			DisplayScope: "home",
			Param:        "https://h5.example.com/middle-east",
			Platform:     "android",
			SortOrder:    1,
			RegionID:     1,
			CountryCode:  "AE",
		},
	}}}
	handler.SetAppConfigReader(reader)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/banners?display_scope=home&platform=android&country=US&region_id=999", nil)
	request.Header.Set("X-Request-ID", "req-app-banners-auth")
	request.Header.Set("X-App-Code", "lalu")
	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 profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
		t.Fatalf("user profile request mismatch: %+v", profileClient.lastGet)
	}
	if profileClient.lastGet.GetMeta().GetAppCode() != "lalu" {
		t.Fatalf("user profile app_code mismatch: %+v", profileClient.lastGet.GetMeta())
	}
	if reader.bannerQuery.DisplayScope != "home" || reader.bannerQuery.Platform != "android" || reader.bannerQuery.RegionID != 1 || reader.bannerQuery.Country != "AE" {
		t.Fatalf("banner query mismatch: %+v", reader.bannerQuery)
	}
}

func TestListSplashScreensReturnsAdminAppConfig(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{SplashScreens: []appconfig.SplashScreen{
		{
			ID:                12,
			CoverURL:          "https://cdn.example.com/splash.png",
			SplashType:        "h5",
			Param:             "https://h5.example.com/splash",
			Platform:          "ios",
			SortOrder:         2,
			RegionID:          7,
			CountryCode:       "BR",
			Description:       "launch splash",
			DisplayDurationMs: 4500,
			StartsAtMs:        1700000000000,
			EndsAtMs:          1800000000000,
			UpdatedAtMs:       1700000003000,
		},
	}}}
	handler.SetAppConfigReader(reader)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/splash-screens?platform=ios&country=BR&region_id=7", nil)
	request.Header.Set("X-Request-ID", "req-app-splash")
	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 reader.splashQuery.AppCode != "lalu" || reader.splashQuery.Platform != "ios" || reader.splashQuery.RegionID != 7 || reader.splashQuery.Country != "BR" {
		t.Fatalf("splash query mismatch: %+v", reader.splashQuery)
	}
	if data["total"].(float64) != 1 {
		t.Fatalf("splash total mismatch: %+v", data)
	}
	items, ok := data["items"].([]any)
	if !ok || len(items) != 1 {
		t.Fatalf("splash items shape mismatch: %+v", data)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["cover_url"] != "https://cdn.example.com/splash.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/splash" {
		t.Fatalf("splash item mismatch: %+v", first)
	}
	if first["platform"] != "ios" || first["sort_order"].(float64) != 2 || first["region_id"].(float64) != 7 || first["country_code"] != "BR" {
		t.Fatalf("splash scope mismatch: %+v", first)
	}
	if first["display_duration_ms"].(float64) != 4500 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000003000 {
		t.Fatalf("splash metadata mismatch: %+v", first)
	}
}

func TestSplashScreenJSONIncludesEmptyScopeFields(t *testing.T) {
	payload, err := json.Marshal(appconfig.SplashScreen{
		ID:          99,
		CoverURL:    "https://cdn.example.com/splash.png",
		SplashType:  "app",
		Platform:    "android",
		UpdatedAtMs: 1700000003000,
	})
	if err != nil {
		t.Fatalf("marshal splash screen failed: %v", err)
	}
	var item map[string]any
	if err := json.Unmarshal(payload, &item); err != nil {
		t.Fatalf("unmarshal splash screen failed: %v", err)
	}
	for _, key := range []string{"region_id", "country_code", "description", "display_duration_ms", "starts_at_ms", "ends_at_ms"} {
		if _, ok := item[key]; !ok {
			t.Fatalf("splash json missing %s: %+v", key, item)
		}
	}
}

func TestListPopupsReturnsAdminAppConfig(t *testing.T) {
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	reader := &captureAppConfigReader{StaticReader: appconfig.StaticReader{Popups: []appconfig.Popup{
		{
			ID:                21,
			Code:              "new-user-popup",
			Name:              "新人活动",
			ImageURL:          "https://cdn.example.com/popup.png",
			JumpType:          "h5",
			JumpURL:           "https://h5.example.com/new-user",
			DisplayPeriodDays: 2,
			SortOrder:         3,
			StartsAtMs:        1700000000000,
			EndsAtMs:          1800000000000,
			UpdatedAtMs:       1700000004000,
		},
	}}}
	handler.SetAppConfigReader(reader)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/popups", nil)
	request.Header.Set("X-Request-ID", "req-app-popups")
	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 reader.popupQuery.AppCode != "lalu" {
		t.Fatalf("popup query mismatch: %+v", reader.popupQuery)
	}
	if data["total"].(float64) != 1 {
		t.Fatalf("popup total mismatch: %+v", data)
	}
	items, ok := data["items"].([]any)
	if !ok || len(items) != 1 {
		t.Fatalf("popup items shape mismatch: %+v", data)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["code"] != "new-user-popup" || first["name"] != "新人活动" || first["jump_type"] != "h5" || first["jump_url"] != "https://h5.example.com/new-user" {
		t.Fatalf("popup item mismatch: %+v", first)
	}
	if first["image_url"] != "https://cdn.example.com/popup.png" || first["display_period_days"].(float64) != 2 || first["sort_order"].(float64) != 3 {
		t.Fatalf("popup display fields mismatch: %+v", first)
	}
	if first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000004000 {
		t.Fatalf("popup 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{
		roleSummary: &userv1.UserRoleSummary{
			UserId:       42,
			IsHost:       true,
			IsAgency:     true,
			IsManager:    true,
			IsBd:         true,
			IsBdLeader:   true,
			IsCoinSeller: true,
		},
	}
	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.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastRoleSummary.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-host-identity") {
		t.Fatalf("host identity role summary request mismatch: %+v", hostClient.lastRoleSummary)
	}
	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_manager"] != 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{}
	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_manager"] != 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 TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
	hostClient := &fakeUserHostClient{searchAgenciesResp: &userv1.SearchAgenciesResponse{Agencies: []*userv1.Agency{
		{
			AgencyId:    7001,
			OwnerUserId: 901,
			RegionId:    30,
			Name:        "Admin Seed Agency",
			Avatar:      "https://cdn.example/agency.png",
			HostCount:   3,
			Status:      "active",
			JoinEnabled: true,
			MaxHosts:    100,
			CreatedAtMs: 1700000000000,
			UpdatedAtMs: 1700000001000,
		},
	}}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=150", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-agency-search")
	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.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 100 {
		t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	items := data["items"].([]any)
	first := items[0].(map[string]any)
	if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || first["avatar"] != "https://cdn.example/agency.png" || first["host_count"] != float64(3) || data["page_size"] != float64(100) {
		t.Fatalf("host agency search response mismatch: %+v", response)
	}
	if _, exists := first["max_hosts"]; exists {
		t.Fatalf("host agency search response must not expose max_hosts: %+v", first)
	}
}

func TestSearchHostAgenciesAllowsEmptyKeywordForCountryList(t *testing.T) {
	hostClient := &fakeUserHostClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-agency-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 hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "" || hostClient.lastSearchAgencies.GetPageSize() != 100 {
		t.Fatalf("host agency list request mismatch: %+v", hostClient.lastSearchAgencies)
	}
}

func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
	hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
		{
			UserId:             801,
			DisplayUserId:      "900801",
			Username:           "Seller One",
			Avatar:             "https://cdn.example/avatar.png",
			CountryId:          86,
			CountryCode:        "CN",
			CountryName:        "China",
			CountryDisplayName: "中国",
			RegionId:           30,
			RegionCode:         "east-asia",
			RegionName:         "East Asia",
			Status:             "active",
			MerchantAssetType:  "COIN_SELLER_COIN",
			ContactInfo:        "+65 1234 5678",
			UpdatedAtMs:        1700000001000,
		},
		{
			UserId:            802,
			DisplayUserId:     "900802",
			Username:          "Seller Without Contact",
			RegionId:          30,
			Status:            "active",
			MerchantAssetType: "COIN_SELLER_COIN",
			UpdatedAtMs:       1700000002000,
		},
	}}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-sellers", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-coin-sellers")
	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.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
		t.Fatalf("coin seller list request mismatch: %+v", hostClient.lastListCoinSellers)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	items := data["items"].([]any)
	first := items[0].(map[string]any)
	if data["total"] != float64(2) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" || first["whatsapp_phone"] != "+65 1234 5678" || first["whatsapp_url"] != "https://wa.me/6512345678" {
		t.Fatalf("coin seller list response mismatch: %+v", response)
	}
	second := items[1].(map[string]any)
	if second["whatsapp_phone"] != "" || second["whatsapp_url"] != "" {
		t.Fatalf("empty contact whatsapp fields must be stable empty strings: %+v", second)
	}
}

func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) {
	hostClient := &fakeUserHostClient{
		hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30},
		listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
			{
				UserId:            42,
				DisplayUserId:     "163006",
				Username:          "self seller",
				RegionId:          30,
				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/salary-wallet/coin-sellers/search?identity=HOST&display_user_id=163006", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-salary-search-self")
	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.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
		t.Fatalf("salary wallet seller search request mismatch: %+v", hostClient.lastListCoinSellers)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	seller := data["seller"].(map[string]any)
	if seller["user_id"] != "42" || seller["display_user_id"] != "163006" || seller["merchant_asset_type"] != "COIN_SELLER_COIN" {
		t.Fatalf("self salary seller search response mismatch: %+v", response)
	}
}

func TestSalaryWalletWithdrawAddressGetAndSave(t *testing.T) {
	profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/withdraw-address", nil)
	getRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	getRequest.Header.Set("X-Request-ID", "req-salary-withdraw-address-get")
	getRecorder := httptest.NewRecorder()

	router.ServeHTTP(getRecorder, getRequest)

	if getRecorder.Code != http.StatusOK {
		t.Fatalf("get status mismatch: got %d body=%s", getRecorder.Code, getRecorder.Body.String())
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
		t.Fatalf("withdraw address get request mismatch: %+v", profileClient.lastGet)
	}
	var getResponse httpkit.ResponseEnvelope
	if err := json.NewDecoder(getRecorder.Body).Decode(&getResponse); err != nil {
		t.Fatalf("decode get response failed: %v", err)
	}
	getData := getResponse.Data.(map[string]any)
	if getData["withdraw_usdt_trc20_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || getData["usdt_trc20_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || getData["has_withdraw_usdt_trc20_address"] != true {
		t.Fatalf("withdraw address get response mismatch: %+v", getResponse)
	}

	saveBody := []byte(`{"payment_address":"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"}`)
	saveRequest := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw-address", bytes.NewReader(saveBody))
	saveRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	saveRequest.Header.Set("X-Request-ID", "req-salary-withdraw-address-save")
	saveRecorder := httptest.NewRecorder()

	router.ServeHTTP(saveRecorder, saveRequest)

	if saveRecorder.Code != http.StatusOK {
		t.Fatalf("save status mismatch: got %d body=%s", saveRecorder.Code, saveRecorder.Body.String())
	}
	if profileClient.lastWithdrawAddress == nil ||
		profileClient.lastWithdrawAddress.GetUserId() != 42 ||
		profileClient.lastWithdrawAddress.GetUsdtTrc20Address() != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" {
		t.Fatalf("withdraw address save request mismatch: %+v", profileClient.lastWithdrawAddress)
	}
	var saveResponse httpkit.ResponseEnvelope
	if err := json.NewDecoder(saveRecorder.Body).Decode(&saveResponse); err != nil {
		t.Fatalf("decode save response failed: %v", err)
	}
	saveData := saveResponse.Data.(map[string]any)
	if saveData["withdraw_usdt_trc20_address"] != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" || saveData["has_withdraw_usdt_trc20_address"] != true {
		t.Fatalf("withdraw address save response mismatch: %+v", saveResponse)
	}
}

func TestSalaryWalletWithdrawCreatesOperationsPendingApplicationWithSavedAddress(t *testing.T) {
	walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
		{AssetType: "HOST_SALARY_USD", AvailableAmount: 6000},
	}}}
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
	profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
	withdrawalWriter := &fakeWithdrawalApplicationWriter{}
	withdrawalNotifier := &fakeWithdrawalApplicationNotifier{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	handler.SetWithdrawalApplicationWriter(withdrawalWriter)
	handler.SetWithdrawalApplicationNotifier(withdrawalNotifier)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-salary-withdraw")
	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 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "HOST_SALARY_USD" {
		t.Fatalf("salary withdraw balance request mismatch: %+v", walletClient.last)
	}
	if walletClient.lastFreezeSalary == nil ||
		walletClient.lastFreezeSalary.GetUserId() != 42 ||
		walletClient.lastFreezeSalary.GetSalaryAssetType() != "HOST_SALARY_USD" ||
		walletClient.lastFreezeSalary.GetSalaryUsdMinor() != 5000 ||
		walletClient.lastFreezeSalary.GetWithdrawalRef() == "" ||
		!strings.Contains(walletClient.lastFreezeSalary.GetCommandId(), walletClient.lastFreezeSalary.GetWithdrawalRef()) {
		t.Fatalf("salary withdraw must freeze wallet balance before application: %+v", walletClient.lastFreezeSalary)
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || profileClient.lastWithdrawAddress != nil {
		t.Fatalf("salary withdraw address lookup mismatch: get=%+v update=%+v", profileClient.lastGet, profileClient.lastWithdrawAddress)
	}
	if withdrawalWriter.calls != 1 ||
		withdrawalWriter.last.AppCode != "lalu" ||
		withdrawalWriter.last.UserID != 42 ||
		withdrawalWriter.last.SalaryAssetType != "HOST_SALARY_USD" ||
		withdrawalWriter.last.WithdrawAmount != "50.00" ||
		withdrawalWriter.last.WithdrawAmountMinor != 5000 ||
		withdrawalWriter.last.WithdrawMethod != financewithdrawal.MethodUSDTTRC20 ||
		withdrawalWriter.last.WithdrawAddress != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" ||
		withdrawalWriter.last.FreezeCommandID != walletClient.lastFreezeSalary.GetCommandId() ||
		withdrawalWriter.last.FreezeTransactionID != "freeze-tx-1" ||
		withdrawalWriter.last.CreatedAtMS <= 0 {
		t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls)
	}
	if withdrawalNotifier.calls != 1 ||
		withdrawalNotifier.last.ID != 77 ||
		withdrawalNotifier.last.UserID != 42 ||
		withdrawalNotifier.last.WithdrawAmount != "50.00" ||
		withdrawalNotifier.last.FreezeTransactionID != "freeze-tx-1" {
		t.Fatalf("withdraw application must notify DingTalk after persistence: %+v calls=%d", withdrawalNotifier.last, withdrawalNotifier.calls)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	balanceAfter, _ := data["salary_balance_after"].(map[string]any)
	if data["application_id"] != "77" ||
		data["status"] != "pending" ||
		data["operations_status"] != "pending" ||
		data["withdraw_amount"] != "50.00" ||
		data["withdraw_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" ||
		data["freeze_transaction_id"] != "freeze-tx-1" ||
		data["salary_usd_minor"] != float64(5000) ||
		balanceAfter["available_amount"] != float64(1000) ||
		balanceAfter["frozen_amount"] != float64(5000) {
		t.Fatalf("withdraw application response mismatch: %+v", response)
	}
}

func TestSalaryWalletWithdrawSavesSubmittedAddressBeforeApplication(t *testing.T) {
	walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
		{AssetType: "HOST_SALARY_USD", AvailableAmount: 7500},
	}}}
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
	profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
	withdrawalWriter := &fakeWithdrawalApplicationWriter{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	handler.SetWithdrawalApplicationWriter(withdrawalWriter)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd_minor":7500,"payment_address":"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-salary-withdraw-new-address")
	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.lastWithdrawAddress == nil ||
		profileClient.lastWithdrawAddress.GetUserId() != 42 ||
		profileClient.lastWithdrawAddress.GetUsdtTrc20Address() != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" ||
		profileClient.lastGet != nil {
		t.Fatalf("submitted withdraw address save mismatch: get=%+v update=%+v", profileClient.lastGet, profileClient.lastWithdrawAddress)
	}
	if withdrawalWriter.calls != 1 ||
		withdrawalWriter.last.WithdrawAmount != "75.00" ||
		withdrawalWriter.last.WithdrawAmountMinor != 7500 ||
		withdrawalWriter.last.FreezeTransactionID != "freeze-tx-1" ||
		withdrawalWriter.last.WithdrawAddress != "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF" {
		t.Fatalf("withdraw application command mismatch: %+v calls=%d", withdrawalWriter.last, withdrawalWriter.calls)
	}
}

func TestSalaryWalletWithdrawRejectsInsufficientBalance(t *testing.T) {
	walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{
		{AssetType: "HOST_SALARY_USD", AvailableAmount: 4999},
	}}}
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}}
	profileClient := &fakeUserProfileClient{withdrawAddress: "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb"}
	withdrawalWriter := &fakeWithdrawalApplicationWriter{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	handler.SetWithdrawalApplicationWriter(withdrawalWriter)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/withdraw", bytes.NewReader([]byte(`{"identity":"HOST","amount_usd":"50.00"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-salary-withdraw-insufficient")
	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 withdrawalWriter.calls != 0 || walletClient.lastFreezeSalary != nil || profileClient.lastGet != nil || profileClient.lastWithdrawAddress != nil {
		t.Fatalf("insufficient balance must stop before freeze/address/application: calls=%d freeze=%+v get=%+v update=%+v", withdrawalWriter.calls, walletClient.lastFreezeSalary, profileClient.lastGet, profileClient.lastWithdrawAddress)
	}
}

func TestSalaryWalletTransferMapsEachIdentityToSalaryAssetAndAllowsSelfSeller(t *testing.T) {
	tests := []struct {
		name        string
		identity    string
		assetType   string
		configure   func(*fakeUserHostClient)
		assertCalls func(*testing.T, *fakeUserHostClient)
	}{
		{
			name:      "host",
			identity:  "HOST",
			assetType: "HOST_SALARY_USD",
			configure: func(hostClient *fakeUserHostClient) {
				hostClient.hostProfile = &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}
			},
			assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
				t.Helper()
				if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 42 {
					t.Fatalf("host identity request mismatch: %+v", hostClient.lastHost)
				}
			},
		},
		{
			name:      "agency",
			identity:  "AGENCY",
			assetType: "AGENCY_SALARY_USD",
			configure: func(hostClient *fakeUserHostClient) {
				hostClient.roleSummary = &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001}
				hostClient.getAgencyResp = &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active", RegionId: 30}}
			},
			assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
				t.Helper()
				if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
					t.Fatalf("agency identity requests mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency)
				}
			},
		},
		{
			name:      "bd",
			identity:  "BD",
			assetType: "BD_SALARY_USD",
			configure: func(hostClient *fakeUserHostClient) {
				hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 30}
			},
			assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
				t.Helper()
				if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" {
					t.Fatalf("bd identity request mismatch: %+v", hostClient.lastBD)
				}
			},
		},
		{
			name:      "bd-leader-admin",
			identity:  "BD_LEADER",
			assetType: "ADMIN_SALARY_USD",
			configure: func(hostClient *fakeUserHostClient) {
				hostClient.bdProfile = &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30}
			},
			assertCalls: func(t *testing.T, hostClient *fakeUserHostClient) {
				t.Helper()
				if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd_leader" {
					t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD)
				}
			},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			walletClient := &fakeWalletClient{transferSalaryResp: &walletv1.TransferSalaryToCoinSellerResponse{
				TransactionId:            "wtx-salary-" + test.name,
				SourceSalaryBalanceAfter: 950,
				SellerBalanceAfter:       40500,
				SalaryUsdMinor:           50,
				CoinAmount:               40500,
				CoinPerUsd:               81000,
				RateMinUsdMinor:          10,
				RateMaxUsdMinor:          10000,
			}}
			hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
				{
					UserId:            42,
					DisplayUserId:     "163006",
					Username:          "self seller",
					RegionId:          30,
					Status:            "active",
					MerchantAssetType: "COIN_SELLER_COIN",
				},
			}}}
			test.configure(hostClient)
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
			handler.SetWalletClient(walletClient)
			handler.SetUserHostClient(hostClient)
			router := handler.Routes(auth.NewVerifier("secret"))
			body := []byte(`{"command_id":"cmd-salary-self-` + test.name + `","identity":"` + test.identity + `","target_display_user_id":"163006","amount_usd":"0.50","reason":"salary transfer"}`)
			request := httptest.NewRequest(http.MethodPost, "/api/v1/salary-wallet/transfer-to-coin-seller", bytes.NewReader(body))
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("X-Request-ID", "req-salary-transfer-self-"+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())
			}
			test.assertCalls(t, hostClient)
			if hostClient.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
				t.Fatalf("coin seller search request mismatch: %+v", hostClient.lastListCoinSellers)
			}
			if walletClient.lastTransferSalary == nil ||
				walletClient.lastTransferSalary.GetSourceUserId() != 42 ||
				walletClient.lastTransferSalary.GetSellerUserId() != 42 ||
				walletClient.lastTransferSalary.GetSalaryAssetType() != test.assetType ||
				walletClient.lastTransferSalary.GetSalaryUsdMinor() != 50 ||
				walletClient.lastTransferSalary.GetRegionId() != 30 {
				t.Fatalf("salary transfer wallet command mismatch: %+v", walletClient.lastTransferSalary)
			}
		})
	}
}

func TestSalaryWalletHistoryMapsIdentityToSalaryAsset(t *testing.T) {
	walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
		Total: 2,
		Transactions: []*walletv1.WalletTransaction{
			{
				EntryId:        31,
				TransactionId:  "wtx-admin-salary-in",
				BizType:        "host_salary_settlement",
				AssetType:      "ADMIN_SALARY_USD",
				AvailableDelta: 1100,
				AvailableAfter: 1100,
				CreatedAtMs:    1_781_000_000_000,
			},
			{
				EntryId:            32,
				TransactionId:      "wtx-admin-salary-out",
				BizType:            "salary_transfer_to_coin_seller",
				AssetType:          "ADMIN_SALARY_USD",
				AvailableDelta:     -500,
				AvailableAfter:     600,
				CounterpartyUserId: 9001,
				CreatedAtMs:        1_781_000_060_000,
			},
		},
	}}
	hostClient := &fakeUserHostClient{
		bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 30},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		9001: {
			UserId:        9001,
			DisplayUserId: "163001",
			Username:      "Seller One",
			Avatar:        "https://cdn.example/seller.png",
			AppCode:       "lalu",
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/salary-wallet/history?identity=BD_LEADER&page=2&page_size=10", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-salary-history")
	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.lastBD == nil || hostClient.lastBD.GetRole() != "bd_leader" || hostClient.lastBD.GetUserId() != 42 {
		t.Fatalf("bd leader identity request mismatch: %+v", hostClient.lastBD)
	}
	if walletClient.lastTransactions == nil ||
		walletClient.lastTransactions.GetUserId() != 42 ||
		walletClient.lastTransactions.GetAssetType() != "ADMIN_SALARY_USD" ||
		walletClient.lastTransactions.GetPage() != 2 ||
		walletClient.lastTransactions.GetPageSize() != 10 {
		t.Fatalf("salary wallet history request mismatch: %+v", walletClient.lastTransactions)
	}
	if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 {
		t.Fatalf("salary history counterparty profile request mismatch: %+v", profileClient.lastBatch)
	}
	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 || data["identity"] != "BD_LEADER" || data["salary_asset_type"] != "ADMIN_SALARY_USD" || len(items) != 2 {
		t.Fatalf("salary wallet history response mismatch: %+v", response)
	}
	second := items[1].(map[string]any)
	if second["available_delta"] != float64(-500) || second["available_after"] != float64(600) || second["counterparty_display_user_id"] != "163001" {
		t.Fatalf("salary wallet history item mismatch: %+v", second)
	}
}

func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
	hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
		ApplicationId:   8001,
		CommandId:       "cmd-apply-agency",
		ApplicantUserId: 42,
		AgencyId:        7001,
		RegionId:        30,
		Status:          "pending",
		CreatedAtMs:     1700000000000,
		UpdatedAtMs:     1700000001000,
	}}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/host/agency-applications", bytes.NewReader([]byte(`{"command_id":"cmd-apply-agency","agency_id":"7001"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-agency-apply")
	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.lastApplyAgency == nil || hostClient.lastApplyAgency.GetUserId() != 42 || hostClient.lastApplyAgency.GetCommandId() != "cmd-apply-agency" || hostClient.lastApplyAgency.GetAgencyId() != 7001 {
		t.Fatalf("host agency apply request mismatch: %+v", hostClient.lastApplyAgency)
	}
}

func TestHostCenterAgencyUsesCurrentHostAgencyAndOwnerProfile(t *testing.T) {
	hostClient := &fakeUserHostClient{
		hostProfile: &userv1.HostProfile{
			UserId:          42,
			Status:          "active",
			RegionId:        30,
			CurrentAgencyId: 7001,
		},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
			AgencyId:    7001,
			OwnerUserId: 99,
			RegionId:    30,
			Name:        "Yumi Star Agency",
			Avatar:      "https://cdn.example/agency.png",
			Status:      "active",
		}},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		99: {UserId: 99, DisplayUserId: "163003", Username: "agency-owner", Avatar: "https://cdn.example/agency-owner.png"},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/agency?agency_id=9999", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-center-agency")
	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 {
		t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost)
	}
	if hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
		t.Fatalf("agency request must use current host agency: %+v", hostClient.lastGetAgency)
	}
	if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 99 {
		t.Fatalf("owner profile request mismatch: %+v", profileClient.lastBatch)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	agency := data["agency"].(map[string]any)
	if agency["agency_id"] != "7001" || agency["short_id"] != "163003" || agency["avatar"] != "https://cdn.example/agency.png" || agency["name"] != "Yumi Star Agency" {
		t.Fatalf("host center agency response mismatch: %+v", agency)
	}
}

func TestGetAgencyProfileReturnsIndependentAgencyAvatar(t *testing.T) {
	hostClient := &fakeUserHostClient{
		hostProfile: &userv1.HostProfile{UserId: 88, Status: "active", RegionId: 30, CurrentAgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
			AgencyId:    7001,
			OwnerUserId: 99,
			RegionId:    30,
			Name:        "Independent Agency",
			Avatar:      "https://cdn.example/agency-avatar.png",
			HostCount:   3,
			Status:      "active",
			JoinEnabled: true,
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agencies/profile?user_id=88", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-agency-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 hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 88 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
		t.Fatalf("agency profile upstream requests mismatch: host=%+v agency=%+v", hostClient.lastHost, hostClient.lastGetAgency)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	agency := data["agency"].(map[string]any)
	if agency["agency_id"] != "7001" || agency["owner_user_id"] != "99" || agency["name"] != "Independent Agency" || agency["avatar"] != "https://cdn.example/agency-avatar.png" || agency["host_count"] != float64(3) {
		t.Fatalf("agency profile response mismatch: %+v", agency)
	}
}

func TestGetAgencyProfileReturnsNullWhenTargetNotActiveHost(t *testing.T) {
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{UserId: 88, Status: "inactive", RegionId: 30, CurrentAgencyId: 7001}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agencies/profile?user_id=88", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-agency-profile-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())
	}
	if hostClient.lastGetAgency != nil {
		t.Fatalf("inactive host must not fetch agency: %+v", hostClient.lastGetAgency)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	if data["agency"] != nil {
		t.Fatalf("inactive host must return null agency: %+v", data)
	}
}

func TestListAgencyRoomsUsesActiveAgencyHosts(t *testing.T) {
	hostClient := &fakeUserHostClient{
		hostProfile: &userv1.HostProfile{UserId: 88, Status: "active", RegionId: 30, CurrentAgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
			AgencyId:    7001,
			OwnerUserId: 99,
			RegionId:    30,
			Name:        "Independent Agency",
			Avatar:      "https://cdn.example/agency-avatar.png",
			Status:      "active",
		}},
		membersResp: &userv1.GetAgencyMembersResponse{Memberships: []*userv1.AgencyMembership{
			{MembershipId: 1, AgencyId: 7001, HostUserId: 101, Status: "active"},
			{MembershipId: 2, AgencyId: 7001, HostUserId: 102, Status: "inactive"},
			{MembershipId: 3, AgencyId: 7001, HostUserId: 99, Status: "active"},
		}},
	}
	roomClient := &fakeRoomQueryClient{ownersResp: &roomv1.ListRoomsResponse{
		Rooms: []*roomv1.RoomListItem{{
			RoomId:            "room-101",
			OwnerUserId:       101,
			Title:             "Agency Room",
			CoverUrl:          "https://cdn.example/room.png",
			Mode:              "voice",
			Status:            "active",
			Heat:              88,
			OnlineCount:       12,
			SeatCount:         8,
			OccupiedSeatCount: 3,
			RoomShortId:       "M20",
			CountryCode:       "AE",
		}},
		NextCursor: "next-owner-room",
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetRoomQueryClient(roomClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agencies/rooms?user_id=88&tab=new&limit=120&cursor=cursor-1&query=karaoke", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-agency-rooms")
	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.lastMembers == nil || hostClient.lastMembers.GetAgencyId() != 7001 || hostClient.lastMembers.GetStatus() != "active" {
		t.Fatalf("agency members request mismatch: %+v", hostClient.lastMembers)
	}
	if roomClient.lastListOwners == nil {
		t.Fatal("agency rooms must call ListRoomsByOwners")
	}
	ownerIDs := roomClient.lastListOwners.GetOwnerUserIds()
	if len(ownerIDs) != 2 || ownerIDs[0] != 99 || ownerIDs[1] != 101 {
		t.Fatalf("owner ids mismatch: got %v request=%+v", roomClient.lastListOwners.GetOwnerUserIds(), roomClient.lastListOwners)
	}
	if roomClient.lastListOwners.GetViewerUserId() != 42 || roomClient.lastListOwners.GetTab() != "new" || roomClient.lastListOwners.GetLimit() != 50 || roomClient.lastListOwners.GetCursor() != "cursor-1" || roomClient.lastListOwners.GetQuery() != "karaoke" {
		t.Fatalf("agency rooms request mismatch: %+v", roomClient.lastListOwners)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	rooms := data["rooms"].([]any)
	first := rooms[0].(map[string]any)
	if data["agency_id"] != "7001" || data["next_cursor"] != "next-owner-room" || first["room_id"] != "room-101" || first["owner_user_id"] != "101" || first["country_flag"] != "🇦🇪" {
		t.Fatalf("agency rooms response mismatch: %+v", response)
	}
}

func TestUpdateAgencyProfileForwardsOwnerCommand(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary:   &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, RegionId: 30, Name: "Old Agency", Avatar: "https://cdn.example/old.png", Status: "active"}},
		updateAgencyResp: &userv1.UpdateAgencyProfileResponse{Agency: &userv1.Agency{
			AgencyId:    7001,
			OwnerUserId: 42,
			RegionId:    30,
			Name:        "New Agency",
			Avatar:      "https://cdn.example/new.png",
			Status:      "active",
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/agencies/profile/update", bytes.NewReader([]byte(`{"command_id":"cmd-agency-profile","name":" New Agency ","avatar":" https://cdn.example/new.png "}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-agency-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())
	}
	if hostClient.lastUpdateAgency == nil ||
		hostClient.lastUpdateAgency.GetCommandId() != "cmd-agency-profile" ||
		hostClient.lastUpdateAgency.GetOperatorUserId() != 42 ||
		hostClient.lastUpdateAgency.GetAgencyId() != 7001 ||
		hostClient.lastUpdateAgency.GetName() != "New Agency" ||
		hostClient.lastUpdateAgency.GetAvatar() != "https://cdn.example/new.png" {
		t.Fatalf("update agency profile request mismatch: %+v", hostClient.lastUpdateAgency)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	agency := data["agency"].(map[string]any)
	if agency["agency_id"] != "7001" || agency["name"] != "New Agency" || agency["avatar"] != "https://cdn.example/new.png" {
		t.Fatalf("update agency profile response mismatch: %+v", agency)
	}
}

func TestHostCenterPlatformPolicyUsesCurrentHostRegion(t *testing.T) {
	expectedCycle := time.Now().UTC().Format("2006-01")
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
		UserId:          42,
		Status:          "active",
		RegionId:        30,
		CurrentAgencyId: 7001,
	}}
	walletClient := &fakeWalletClient{
		hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{
			Found: true,
			Policy: &walletv1.HostSalaryPolicy{
				PolicyId:                 9001,
				CycleKey:                 expectedCycle,
				PolicyVersion:            9001,
				Name:                     "Host Growth Policy",
				PolicyType:               "SALARY_DIAMOND",
				RegionId:                 30,
				Status:                   "active",
				SettlementMode:           "half_month",
				SettlementTriggerMode:    "manual",
				GiftCoinToDiamondRatio:   "1.0000",
				ResidualDiamondToUsdRate: "0.0100",
				EffectiveFromMs:          1700000000000,
				Levels: []*walletv1.HostSalaryPolicyLevel{
					{LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUsdMinor: 1500, HostCoinReward: 200, AgencySalaryUsdMinor: 300, Status: "active", SortOrder: 1},
					{LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUsdMinor: 4500, HostCoinReward: 500, AgencySalaryUsdMinor: 900, Status: "active", SortOrder: 2},
				},
			},
		},
		hostSalaryProgressResp: &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{
			HostUserId:        42,
			CycleKey:          expectedCycle,
			RegionId:          30,
			AgencyOwnerUserId: 99,
			TotalDiamonds:     1200,
			GiftDiamondTotal:  1200,
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-host-policy")
	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 {
		t.Fatalf("host profile request mismatch: %+v", hostClient.lastHost)
	}
	if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 30 || walletClient.lastHostSalaryPolicy.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" {
		t.Fatalf("host salary policy request mismatch: %+v", walletClient.lastHostSalaryPolicy)
	}
	if walletClient.lastHostSalaryProgress == nil || walletClient.lastHostSalaryProgress.GetHostUserId() != 42 || walletClient.lastHostSalaryProgress.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryProgress.GetAppCode() == "" || walletClient.lastHostSalaryProgress.GetRequestId() == "" {
		t.Fatalf("host salary progress request mismatch: %+v", walletClient.lastHostSalaryProgress)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	policy := data["policy"].(map[string]any)
	levels := policy["levels"].([]any)
	firstLevel := levels[0].(map[string]any)
	progress := data["progress"].(map[string]any)
	levelProgress := data["level_progress"].(map[string]any)
	if data["found"] != true || data["host_region_id"] != float64(30) || policy["policy_id"] != "9001" || policy["cycle_key"] != expectedCycle || policy["policy_version"] != float64(9001) || policy["policy_type"] != "SALARY_DIAMOND" || policy["settlement_mode"] != "half_month" || policy["settlement_trigger_mode"] != "manual" || policy["point_diamonds_per_usd"] != float64(0) || policy["coins_per_usd"] != float64(0) || policy["agency_point_share_bps"] != float64(0) || policy["minimum_withdraw_usd_minor"] != float64(0) || policy["withdraw_fee_bps"] != float64(0) || firstLevel["host_salary_usd"] != 15.0 || firstLevel["agency_salary_usd"] != 3.0 {
		t.Fatalf("host policy response mismatch: %+v", data)
	}
	if progress["cycle_key"] != expectedCycle || progress["total_diamonds"] != float64(1200) {
		t.Fatalf("host salary progress response mismatch: %+v", progress)
	}
	if levelProgress["current_level"] != float64(1) || levelProgress["current_value"] != float64(1200) || levelProgress["next_level"] != float64(2) || levelProgress["needed_for_next_level"] != float64(1800) {
		t.Fatalf("host level progress response mismatch: %+v", levelProgress)
	}
}

func TestHostCenterPlatformPolicyRejectsNonActiveHost(t *testing.T) {
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
		UserId:   42,
		Status:   "pending",
		RegionId: 30,
	}}
	walletClient := &fakeWalletClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusForbidden {
		t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if walletClient.lastHostSalaryPolicy != nil {
		t.Fatalf("wallet should not be called for non-active host: %+v", walletClient.lastHostSalaryPolicy)
	}
	if walletClient.lastHostSalaryProgress != nil {
		t.Fatalf("progress should not be called for non-active host: %+v", walletClient.lastHostSalaryProgress)
	}
}

func TestHostCenterPlatformPolicySkipsSalaryProgressForNonSalaryPolicies(t *testing.T) {
	hostProfile := &userv1.HostProfile{UserId: 42, Status: "active", RegionId: 30}
	tests := []struct {
		name       string
		policyResp *walletv1.GetActiveHostSalaryPolicyResponse
	}{
		{
			name: "point diamond",
			policyResp: &walletv1.GetActiveHostSalaryPolicyResponse{Found: true, Policy: &walletv1.HostSalaryPolicy{
				PolicyId: 9002, PolicyVersion: 3, PolicyType: "POINT_DIAMOND", RegionId: 30, Status: "active",
			}},
		},
		{name: "no policy", policyResp: &walletv1.GetActiveHostSalaryPolicyResponse{}},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			hostClient := &fakeUserHostClient{hostProfile: hostProfile}
			walletClient := &fakeWalletClient{hostSalaryPolicyResp: test.policyResp}
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
			handler.SetUserHostClient(hostClient)
			handler.SetWalletClient(walletClient)
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodGet, "/api/v1/host-center/platform-policy", 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 walletClient.lastHostSalaryProgress != nil {
				t.Fatalf("non-salary policy queried salary progress: %+v", walletClient.lastHostSalaryProgress)
			}
			var response httpkit.ResponseEnvelope
			if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
				t.Fatalf("decode response failed: %v", err)
			}
			data := response.Data.(map[string]any)
			if data["progress"] != nil || data["level_progress"] != nil {
				t.Fatalf("non-salary policy exposed salary progress: %+v", data)
			}
		})
	}
}

func TestAgencyCenterOverviewAllowsOwnerWithoutManagerProfile(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
			AgencyId:       7001,
			OwnerUserId:    42,
			RegionId:       30,
			ParentBdUserId: 77,
			Name:           "Yumi Star Agency",
			Status:         "active",
			HostCount:      3,
		}},
		applicationsResp: &userv1.GetAgencyApplicationsResponse{Applications: []*userv1.AgencyApplication{
			{ApplicationId: 9001, ApplicantUserId: 100, AgencyId: 7001, Status: "pending"},
			{ApplicationId: 9002, ApplicantUserId: 101, AgencyId: 7001, Status: "pending"},
		}},
	}
	walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{
		42: {Balances: []*walletv1.AssetBalance{{AssetType: "AGENCY_SALARY_USD", AvailableAmount: 1288075}}},
	}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		77: {UserId: 77, DisplayUserId: "880001", Username: "Mila BD", Avatar: "https://cdn.example/bd.png"},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/overview", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-agency-center-overview")
	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.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
		t.Fatalf("agency owner resolution mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency)
	}
	if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "AGENCY_SALARY_USD" {
		t.Fatalf("agency salary request mismatch: %+v", walletClient.last)
	}
	if hostClient.lastApplications == nil || hostClient.lastApplications.GetAgencyId() != 7001 || hostClient.lastApplications.GetStatus() != "pending" {
		t.Fatalf("pending applications request mismatch: %+v", hostClient.lastApplications)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	salary := data["salary"].(map[string]any)
	bd := data["bd"].(map[string]any)
	if salary["asset_type"] != "AGENCY_SALARY_USD" || salary["available_amount"] != float64(1288075) || salary["display_amount"] != 12880.75 || data["pending_application_count"] != float64(2) || bd["display_user_id"] != "880001" {
		t.Fatalf("overview response mismatch: %+v", data)
	}
}

func TestAgencyCenterPlatformPolicyUsesAgencyRegion(t *testing.T) {
	expectedCycle := time.Now().UTC().Format("2006-01")
	hostClient := &fakeUserHostClient{
		roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
			AgencyId:    7001,
			OwnerUserId: 42,
			RegionId:    31,
			Name:        "Yumi Star Agency",
			Status:      "active",
		}},
	}
	walletClient := &fakeWalletClient{
		hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{
			Found: true,
			Policy: &walletv1.HostSalaryPolicy{
				PolicyId:                 9101,
				CycleKey:                 expectedCycle,
				PolicyVersion:            9101,
				Name:                     "Agency Region Policy",
				PolicyType:               "SALARY_DIAMOND",
				RegionId:                 31,
				Status:                   "active",
				SettlementMode:           "half_month",
				SettlementTriggerMode:    "manual",
				GiftCoinToDiamondRatio:   "1.0000",
				ResidualDiamondToUsdRate: "0.0100",
				Levels: []*walletv1.HostSalaryPolicyLevel{
					{LevelNo: 1, RequiredDiamonds: 1000, HostSalaryUsdMinor: 1200, HostCoinReward: 100, AgencySalaryUsdMinor: 240, Status: "active", SortOrder: 1},
					{LevelNo: 2, RequiredDiamonds: 3000, HostSalaryUsdMinor: 3600, HostCoinReward: 300, AgencySalaryUsdMinor: 720, Status: "active", SortOrder: 2},
				},
			},
		},
		hostSalaryProgressResp: &walletv1.GetHostSalaryProgressResponse{Progress: &walletv1.HostSalaryProgress{
			HostUserId:        42,
			CycleKey:          expectedCycle,
			RegionId:          31,
			AgencyOwnerUserId: 42,
			TotalDiamonds:     30000,
			GiftDiamondTotal:  30000,
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/platform-policy", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-agency-policy")
	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.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
		t.Fatalf("agency owner resolution mismatch: role=%+v agency=%+v", hostClient.lastRoleSummary, hostClient.lastGetAgency)
	}
	if walletClient.lastHostSalaryPolicy == nil || walletClient.lastHostSalaryPolicy.GetRegionId() != 31 || walletClient.lastHostSalaryPolicy.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryPolicy.GetSettlementTriggerMode() != "" || walletClient.lastHostSalaryPolicy.GetAppCode() == "" || walletClient.lastHostSalaryPolicy.GetRequestId() == "" {
		t.Fatalf("agency policy request mismatch: %+v", walletClient.lastHostSalaryPolicy)
	}
	if walletClient.lastHostSalaryProgress == nil || walletClient.lastHostSalaryProgress.GetHostUserId() != 42 || walletClient.lastHostSalaryProgress.GetCycleKey() != expectedCycle || walletClient.lastHostSalaryProgress.GetAppCode() == "" || walletClient.lastHostSalaryProgress.GetRequestId() == "" {
		t.Fatalf("agency owner salary progress request mismatch: %+v", walletClient.lastHostSalaryProgress)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	policy := data["policy"].(map[string]any)
	levels := policy["levels"].([]any)
	secondLevel := levels[1].(map[string]any)
	progress := data["progress"].(map[string]any)
	if data["found"] != true || data["agency_region_id"] != float64(31) || policy["policy_id"] != "9101" || policy["cycle_key"] != expectedCycle || policy["policy_version"] != float64(9101) || len(levels) != 2 || secondLevel["agency_salary_usd"] != 7.2 {
		t.Fatalf("agency policy response mismatch: %+v", data)
	}
	if progress["host_user_id"] != float64(42) || progress["cycle_key"] != expectedCycle || progress["total_diamonds"] != float64(30000) {
		t.Fatalf("agency owner salary progress response mismatch: %+v", progress)
	}
}

func TestAgencyCenterPointDiamondPolicySkipsOwnerSalaryProgress(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{
			AgencyId: 7001, OwnerUserId: 42, RegionId: 31, Status: "active",
		}},
	}
	walletClient := &fakeWalletClient{hostSalaryPolicyResp: &walletv1.GetActiveHostSalaryPolicyResponse{
		Found: true,
		Policy: &walletv1.HostSalaryPolicy{
			PolicyId: 9102, PolicyVersion: 4, PolicyType: "POINT_DIAMOND", RegionId: 31, Status: "active",
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/platform-policy", 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 walletClient.lastHostSalaryProgress != nil {
		t.Fatalf("POINT_DIAMOND agency policy queried salary progress: %+v", walletClient.lastHostSalaryProgress)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	if data["progress"] != nil {
		t.Fatalf("POINT_DIAMOND agency policy exposed salary progress: %+v", data)
	}
}

func TestBDCenterOverviewUsesBDSalaryAndDirectAgencies(t *testing.T) {
	hostClient := &fakeUserHostClient{
		bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
		bdAgenciesResp: &userv1.ListBDAgenciesResponse{Agencies: []*userv1.Agency{
			{AgencyId: 7001, OwnerUserId: 99, ParentBdUserId: 42, Name: "Yumi Star Agency", Status: "active"},
		}},
	}
	walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{
		42: {Balances: []*walletv1.AssetBalance{{AssetType: "BD_SALARY_USD", AvailableAmount: 4862080}}},
	}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		42: {UserId: 42, DisplayUserId: "163008", Username: "BD_Jasmine", Avatar: "https://cdn.example/bd.png"},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/bd-center/overview", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-bd-center-overview")
	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.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" || hostClient.lastBDAgencies == nil || hostClient.lastBDAgencies.GetBdUserId() != 42 {
		t.Fatalf("bd center requests mismatch: profile=%+v agencies=%+v", hostClient.lastBD, hostClient.lastBDAgencies)
	}
	if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "BD_SALARY_USD" {
		t.Fatalf("bd salary request 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 := response.Data.(map[string]any)
	profile := data["profile"].(map[string]any)
	salary := data["salary"].(map[string]any)
	overview := data["overview"].(map[string]any)
	if profile["display_user_id"] != "163008" || salary["asset_type"] != "BD_SALARY_USD" || salary["display_amount"] != 48620.8 || overview["total_agency"] != float64(1) {
		t.Fatalf("bd center overview response mismatch: %+v", data)
	}
}

func TestBDCenterInviteAgencyCreatesPendingInvitation(t *testing.T) {
	hostClient := &fakeUserHostClient{
		bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
		inviteAgencyResp: &userv1.InviteAgencyResponse{Invitation: &userv1.RoleInvitation{
			InvitationId:   9001,
			CommandId:      "cmd-bd-invite-agency",
			InvitationType: "agency",
			Status:         "pending",
			InviterUserId:  42,
			TargetUserId:   99,
			AgencyName:     "Target Agency",
		}},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		99: {UserId: 99, DisplayUserId: "163099", Username: "Target Agency"},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/bd/invitations/agency", bytes.NewReader([]byte(`{"command_id":"cmd-bd-invite-agency","target_user_id":"99"}`)))
	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 hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd" || hostClient.lastInviteAgency == nil || hostClient.lastInviteAgency.GetInviterUserId() != 42 || hostClient.lastInviteAgency.GetTargetUserId() != 99 || hostClient.lastInviteAgency.GetAgencyName() != "Target Agency" {
		t.Fatalf("bd invite agency request mismatch: %+v", hostClient.lastInviteAgency)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	invitation := data["invitation"].(map[string]any)
	if invitation["status"] != "pending" || invitation["target_user_id"] != "99" {
		t.Fatalf("bd invite response mismatch: %+v", data)
	}
}

func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) {
	hostClient := &fakeUserHostClient{
		bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001},
		inviteBDResp: &userv1.InviteBDResponse{Invitation: &userv1.RoleInvitation{
			InvitationId:       9002,
			CommandId:          "cmd-bd-self",
			InvitationType:     "bd",
			Status:             "pending",
			InviterUserId:      42,
			InviterBdUserId:    42,
			TargetUserId:       42,
			ParentLeaderUserId: 42,
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/bd-leader/invitations/bd", bytes.NewReader([]byte(`{"command_id":"cmd-bd-self","target_user_id":"42"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-bd-self")
	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.lastBD == nil || hostClient.lastBD.GetRole() != "bd_leader" || hostClient.lastInviteBD == nil || hostClient.lastInviteBD.GetInviterUserId() != 42 || hostClient.lastInviteBD.GetTargetUserId() != 42 {
		t.Fatalf("self invite must be forwarded to user-service: %+v", hostClient.lastInviteBD)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	invitation := data["invitation"].(map[string]any)
	if invitation["status"] != "pending" || invitation["target_user_id"] != "42" || invitation["parent_leader_user_id"] != "42" {
		t.Fatalf("bd self invite response mismatch: %+v", data)
	}
}

func TestAgencyCenterInviteHostCreatesPendingInvitation(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary:   &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
		inviteHostResp: &userv1.InviteHostResponse{Invitation: &userv1.RoleInvitation{
			InvitationId:   9010,
			CommandId:      "cmd-host-invite",
			InvitationType: "host",
			Status:         "pending",
			InviterUserId:  42,
			TargetUserId:   100,
			AgencyName:     "Voice Agency",
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/invitations/host", bytes.NewReader([]byte(`{"command_id":"cmd-host-invite","target_user_id":"100"}`)))
	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 hostClient.lastInviteHost == nil || hostClient.lastInviteHost.GetInviterUserId() != 42 || hostClient.lastInviteHost.GetTargetUserId() != 100 {
		t.Fatalf("host invite request mismatch: %+v", hostClient.lastInviteHost)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	invitation := response.Data.(map[string]any)["invitation"].(map[string]any)
	if invitation["status"] != "pending" || invitation["invitation_type"] != "host" {
		t.Fatalf("host invite response mismatch: %+v", response.Data)
	}
}

func TestRoleInvitationRegionMismatchEnvelopeUsesConcreteMessage(t *testing.T) {
	regionErr := xerr.ToGRPCError(xerr.New(xerr.RegionMismatch, "target region does not match inviter region"))
	tests := []struct {
		name      string
		path      string
		body      string
		hostSetup func() *fakeUserHostClient
		reached   func(*fakeUserHostClient) bool
	}{
		{
			name: "agency_owner_invite_host",
			path: "/api/v1/agency-center/invitations/host",
			body: `{"command_id":"cmd-host-region","target_user_id":"100"}`,
			hostSetup: func() *fakeUserHostClient {
				return &fakeUserHostClient{
					roleSummary:   &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
					getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
					inviteHostErr: regionErr,
				}
			},
			reached: func(client *fakeUserHostClient) bool { return client.lastInviteHost != nil },
		},
		{
			name: "bd_invite_agency",
			path: "/api/v1/bd/invitations/agency",
			body: `{"command_id":"cmd-agency-region","target_user_id":"100","agency_name":"Target Agency"}`,
			hostSetup: func() *fakeUserHostClient {
				return &fakeUserHostClient{
					bdProfile:       &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
					inviteAgencyErr: regionErr,
				}
			},
			reached: func(client *fakeUserHostClient) bool { return client.lastInviteAgency != nil },
		},
		{
			name: "bd_leader_invite_bd",
			path: "/api/v1/bd-leader/invitations/bd",
			body: `{"command_id":"cmd-bd-region","target_user_id":"100"}`,
			hostSetup: func() *fakeUserHostClient {
				return &fakeUserHostClient{
					bdProfile:   &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001},
					inviteBDErr: regionErr,
				}
			},
			reached: func(client *fakeUserHostClient) bool { return client.lastInviteBD != nil },
		},
		{
			name: "target_accept_invitation_after_region_changed",
			path: "/api/v1/role-invitations/9011/process",
			body: `{"command_id":"cmd-accept-region","action":"accept"}`,
			hostSetup: func() *fakeUserHostClient {
				return &fakeUserHostClient{processInviteErr: regionErr}
			},
			reached: func(client *fakeUserHostClient) bool { return client.lastProcessInvite != nil },
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			hostClient := test.hostSetup()
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
			handler.SetUserHostClient(hostClient)
			router := handler.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)

			assertEnvelopeMessage(t, recorder, http.StatusForbidden, string(xerr.RegionMismatch), "不是同一个地区", "req-"+test.name)
			if !test.reached(hostClient) {
				t.Fatalf("route did not reach role-invitation RPC for %s", test.name)
			}
		})
	}
}

func TestRoleInvitationsListAndProcessForwardCurrentUser(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{
			InvitationId:   9011,
			InvitationType: "agency",
			Status:         "pending",
			InviterUserId:  77,
			TargetUserId:   42,
			AgencyName:     "Target Agency",
			CreatedAtMs:    1700000000000,
		}}},
		processInviteResp: &userv1.ProcessRoleInvitationResponse{
			Invitation: &userv1.RoleInvitation{
				InvitationId:   9011,
				InvitationType: "agency",
				Status:         "accepted",
				InviterUserId:  77,
				TargetUserId:   42,
			},
			Agency:     &userv1.Agency{AgencyId: 7008, OwnerUserId: 42, Status: "active", Name: "Target Agency"},
			Membership: &userv1.AgencyMembership{MembershipId: 3008, AgencyId: 7008, HostUserId: 42, MembershipType: "owner", Status: "active"},
		},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		77: {UserId: 77, DisplayUserId: "163077", Username: "Leader Rose"},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/role-invitations?status=pending", nil)
	listRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	listRecorder := httptest.NewRecorder()
	router.ServeHTTP(listRecorder, listRequest)
	if listRecorder.Code != http.StatusOK {
		t.Fatalf("list status mismatch: got %d body=%s", listRecorder.Code, listRecorder.Body.String())
	}
	if hostClient.lastRoleInvitations == nil || hostClient.lastRoleInvitations.GetTargetUserId() != 42 || hostClient.lastRoleInvitations.GetStatus() != "pending" {
		t.Fatalf("list invitations request mismatch: %+v", hostClient.lastRoleInvitations)
	}

	processRequest := httptest.NewRequest(http.MethodPost, "/api/v1/role-invitations/9011/process", bytes.NewReader([]byte(`{"command_id":"cmd-accept-9011","action":"accept"}`)))
	processRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	processRecorder := httptest.NewRecorder()
	router.ServeHTTP(processRecorder, processRequest)
	if processRecorder.Code != http.StatusOK {
		t.Fatalf("process status mismatch: got %d body=%s", processRecorder.Code, processRecorder.Body.String())
	}
	if hostClient.lastProcessInvite == nil || hostClient.lastProcessInvite.GetActorUserId() != 42 || hostClient.lastProcessInvite.GetInvitationId() != 9011 || hostClient.lastProcessInvite.GetAction() != "accept" {
		t.Fatalf("process invitation request mismatch: %+v", hostClient.lastProcessInvite)
	}
}

func TestAgencyCenterOverviewRejectsNonOwner(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary:   &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 99, Status: "active"}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(&fakeWalletClient{})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/overview", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusForbidden {
		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)
	}
	if response.Code != httpkit.CodePermissionDenied || hostClient.lastGetAgency == nil || hostClient.lastGetAgency.GetAgencyId() != 7001 {
		t.Fatalf("non-owner response mismatch: response=%+v getAgency=%+v", response, hostClient.lastGetAgency)
	}
}

func TestAgencyCenterHostsReturnsActiveHostSalary(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary:   &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
		membersResp: &userv1.GetAgencyMembersResponse{Memberships: []*userv1.AgencyMembership{
			{MembershipId: 1, AgencyId: 7001, HostUserId: 42, MembershipType: "owner", Status: "active", JoinedAtMs: 10},
			{MembershipId: 2, AgencyId: 7001, HostUserId: 100, MembershipType: "host", Status: "active", JoinedAtMs: 20},
			{MembershipId: 3, AgencyId: 7001, HostUserId: 101, MembershipType: "host", Status: "active", JoinedAtMs: 30},
		}},
	}
	walletClient := &fakeWalletClient{balancesByUserID: map[int64]*walletv1.GetBalancesResponse{
		100: {Balances: []*walletv1.AssetBalance{{AssetType: "HOST_SALARY_USD", AvailableAmount: 305025}}},
		101: {Balances: []*walletv1.AssetBalance{{AssetType: "HOST_SALARY_USD", AvailableAmount: 428050}}},
	}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		100: {UserId: 100, DisplayUserId: "163100", Username: "Lina_voice", Avatar: "https://cdn.example/lina.png"},
		101: {UserId: 101, DisplayUserId: "163101", Username: "Nora_live", Avatar: "https://cdn.example/nora.png"},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/agency-center/hosts", 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 hostClient.lastMembers == nil || hostClient.lastMembers.GetAgencyId() != 7001 || hostClient.lastMembers.GetStatus() != "active" {
		t.Fatalf("members request mismatch: %+v", hostClient.lastMembers)
	}
	if len(walletClient.balanceRequests) != 2 || walletClient.balanceRequests[0].GetUserId() != 100 || walletClient.balanceRequests[1].GetUserId() != 101 {
		t.Fatalf("host salary requests mismatch: %+v", walletClient.balanceRequests)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data := response.Data.(map[string]any)
	items := data["items"].([]any)
	first := items[0].(map[string]any)
	salary := first["salary"].(map[string]any)
	if data["total"] != float64(2) || first["host_user_id"] != "100" || first["display_user_id"] != "163100" || first["username"] != "Lina_voice" || salary["display_amount"] != 3050.25 {
		t.Fatalf("hosts response mismatch: %+v", data)
	}
}

func TestAgencyCenterReviewAndRemoveForwardOwnerCommands(t *testing.T) {
	hostClient := &fakeUserHostClient{
		roleSummary:   &userv1.UserRoleSummary{UserId: 42, IsAgency: true, IsManager: false, AgencyId: 7001},
		getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
		reviewResp: &userv1.ReviewAgencyApplicationResponse{Application: &userv1.AgencyApplication{
			ApplicationId: 9001,
			AgencyId:      7001,
			Status:        "approved",
		}},
		kickResp: &userv1.KickAgencyHostResponse{Membership: &userv1.AgencyMembership{
			MembershipId: 2,
			AgencyId:     7001,
			HostUserId:   100,
			Status:       "ended",
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	handler.SetWalletClient(&fakeWalletClient{})
	router := handler.Routes(auth.NewVerifier("secret"))

	reviewRequest := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/applications/9001/review", bytes.NewReader([]byte(`{"command_id":"cmd-review","decision":"approve","reason":"ok"}`)))
	reviewRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	reviewRecorder := httptest.NewRecorder()
	router.ServeHTTP(reviewRecorder, reviewRequest)
	if reviewRecorder.Code != http.StatusOK {
		t.Fatalf("review status mismatch: got %d body=%s", reviewRecorder.Code, reviewRecorder.Body.String())
	}
	if hostClient.lastReview == nil || hostClient.lastReview.GetReviewerUserId() != 42 || hostClient.lastReview.GetApplicationId() != 9001 || hostClient.lastReview.GetCommandId() != "cmd-review" || hostClient.lastReview.GetDecision() != "approve" {
		t.Fatalf("review request mismatch: %+v", hostClient.lastReview)
	}

	removeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/hosts/100/remove", bytes.NewReader([]byte(`{"command_id":"cmd-remove","reason":"agency_center"}`)))
	removeRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	removeRecorder := httptest.NewRecorder()
	router.ServeHTTP(removeRecorder, removeRequest)
	if removeRecorder.Code != http.StatusOK {
		t.Fatalf("remove status mismatch: got %d body=%s", removeRecorder.Code, removeRecorder.Body.String())
	}
	if hostClient.lastKick == nil || hostClient.lastKick.GetOperatorUserId() != 42 || hostClient.lastKick.GetAgencyId() != 7001 || hostClient.lastKick.GetHostUserId() != 100 || hostClient.lastKick.GetCommandId() != "cmd-remove" {
		t.Fatalf("remove request mismatch: %+v", hostClient.lastKick)
	}
}

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 TestMicHeartbeatForwardsCurrentSession(t *testing.T) {
	client := &fakeRoomClient{}
	router := NewHandler(client).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"room_id":"room-1","command_id":"cmd-mic-heartbeat","target_user_id":43,"mic_session_id":"mic-session-1"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/heartbeat", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-mic-heartbeat")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-mic-heartbeat")
	if client.lastMicHeartbeat == nil {
		t.Fatal("MicHeartbeat request was not sent")
	}
	if client.lastMicHeartbeat.GetTargetUserId() != 43 ||
		client.lastMicHeartbeat.GetMicSessionId() != "mic-session-1" {
		t.Fatalf("mic heartbeat fields mismatch: %+v", client.lastMicHeartbeat)
	}
}

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("X-Real-IP", "129.226.91.24")
	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)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["access_token"] != "access-1" || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
		t.Fatalf("third-party login must expose access token TTL: %+v", response.Data)
	}
	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.GetMeta().GetAppVersion() != "1.2.3" || req.GetMeta().GetBuildNumber() != "123" {
		t.Fatalf("third-party body version fallback was not propagated into auth meta: %+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 TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","username":"hy","avatar":"https://cdn.example/avatar.png","gender":"male","country":"CN","invite_code":"INV-1","device":"iPhone 15","os_version":"iOS 18.1","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/register", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-auth-register")
	request.Header.Set("X-Forwarded-For", "203.0.113.10")
	request.Header.Set("User-Agent", "hyapp-test/1.0")
	request.Header.Set("CF-IPCountry", "sg")
	request.Header.Set("X-App-Version", "2.0.0")
	request.Header.Set("X-App-Build-Number", "200")
	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-register")
	if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" || data["access_token"] != "access-register" || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
		t.Fatalf("register must return completed token data: %+v", response.Data)
	}
	req := userClient.lastRegisterThird
	if req == nil {
		t.Fatal("RegisterThirdParty request was not sent")
	}
	if userClient.lastLoginThirdParty != nil {
		t.Fatalf("register endpoint must not call LoginThirdParty: %+v", userClient.lastLoginThirdParty)
	}
	if req.GetMeta().GetRequestId() != generatedRequestID || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetCountryByIp() != "SG" {
		t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
	}
	if req.GetMeta().GetAppVersion() != "2.0.0" || req.GetMeta().GetBuildNumber() != "200" || req.GetAppVersion() != "2.0.0" || req.GetBuildNumber() != "200" {
		t.Fatalf("register version headers must override body values in both auth meta and registration fields: %+v meta=%+v", req, req.GetMeta())
	}
	if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" || req.GetDeviceId() != "dev-1" {
		t.Fatalf("provider credential device fields mismatch: %+v", req)
	}
	if req.GetUsername() != "hy" || req.GetAvatar() == "" || req.GetGender() != "male" || req.GetCountry() != "CN" || req.GetInviteCode() != "INV-1" {
		t.Fatalf("profile fields mismatch: %+v", req)
	}
	if req.GetPlatform() != "ios" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" || req.GetInstallChannel() != "app_store" || req.GetCampaign() != "spring_launch" {
		t.Fatalf("source fields mismatch: %+v", req)
	}
}

func TestCheckThirdPartyRegisteredOnlyReturnsRegisteredFlag(t *testing.T) {
	userClient := &fakeUserAuthClient{checkRegistered: true}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/registered/check", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-auth-check")
	request.Header.Set("X-Forwarded-For", "203.0.113.10")
	request.Header.Set("User-Agent", "hyapp-test/1.0")
	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["registered"] != true {
		t.Fatalf("unexpected registered check response: %+v", response)
	}
	req := userClient.lastCheckThird
	if req == nil {
		t.Fatal("CheckThirdPartyRegistered request was not sent")
	}
	if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" {
		t.Fatalf("provider credential mismatch: %+v", req)
	}
	if userClient.lastLoginThirdParty != nil || userClient.lastRegisterThird != nil {
		t.Fatalf("check endpoint must not login or register: login=%+v register=%+v", userClient.lastLoginThirdParty, userClient.lastRegisterThird)
	}
}

func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) {
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
	request.RemoteAddr = "10.0.0.9:54123"
	request.Header.Set("X-Forwarded-For", "114.8.228.33, 10.0.0.1")
	request.Header.Set("X-Real-IP", "129.226.91.24")

	if got := clientIP(request); got != "114.8.228.33" {
		t.Fatalf("clientIP must use the first public X-Forwarded-For address before proxy X-Real-IP, got %q", got)
	}
}

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","app_version":"1.2.3","build_number":"123","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")
	request.Header.Set("X-App-Version", "2.0.0")
	request.Header.Set("X-App-Build-Number", "200")
	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 account login response failed: %v", err)
	}
	if response.Code != httpkit.CodeOK || response.RequestID != assertGeneratedRequestID(t, recorder, "req-account-login") {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	data, ok := response.Data.(map[string]any)
	if !ok || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
		t.Fatalf("account login must expose access token TTL: %+v", response.Data)
	}
	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())
	}
	if userClient.lastLoginPassword.GetMeta().GetAppVersion() != "2.0.0" || userClient.lastLoginPassword.GetMeta().GetBuildNumber() != "200" {
		t.Fatalf("password login version headers must override body fallback: %+v", userClient.lastLoginPassword.GetMeta())
	}
}

func TestQuickCreateAccountCreatesCompletedPasswordAccount(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/account/quick-create", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-quick-create")
	request.Header.Set("X-App-Code", "lalu")
	request.Header.Set("X-App-Version", "3.0.0")
	request.Header.Set("X-App-Build-Number", "300")
	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)
	}
	if response.Code != httpkit.CodeOK || response.RequestID != assertGeneratedRequestID(t, recorder, "req-quick-create") {
		t.Fatalf("unexpected envelope: %+v", response)
	}
	req := userClient.lastQuickCreate
	if req == nil {
		t.Fatal("QuickCreateAccount request was not sent")
	}
	if req.GetPassword() != "secret-pass" || req.GetUsername() == "" || req.GetAvatar() == "" {
		t.Fatalf("quick create profile defaults were not populated: %+v", req)
	}
	if req.GetCountry() != "SG" || req.GetGender() != "unknown" || req.GetDeviceId() == "" {
		t.Fatalf("quick create required defaults mismatch: %+v", req)
	}
	if req.GetPlatform() != "android" || req.GetLanguage() != "zh-CN" || req.GetTimezone() != "Asia/Shanghai" {
		t.Fatalf("quick create login context mismatch: %+v", req)
	}
	if req.GetMeta().GetAppCode() != "lalu" || req.GetMeta().GetDeviceId() != req.GetDeviceId() {
		t.Fatalf("quick create meta mismatch: %+v", req.GetMeta())
	}
	if req.GetMeta().GetAppVersion() != "3.0.0" || req.GetMeta().GetBuildNumber() != "300" || req.GetAppVersion() != "3.0.0" || req.GetBuildNumber() != "300" {
		t.Fatalf("quick create version headers were not propagated consistently: %+v meta=%+v", req, req.GetMeta())
	}

	data, ok := response.Data.(map[string]any)
	if !ok || data["uid"] != "100001" || data["account"] != "100001" || data["access_token"] != "access-quick" || data["password_set"] != true {
		t.Fatalf("quick create response mismatch: %+v", response.Data)
	}
	token, ok := data["token"].(map[string]any)
	if !ok || token["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
		t.Fatalf("quick create token TTL mismatch: %+v", data["token"])
	}
}

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 TestLoginRiskIPWhitelistBypassesFastGuard(t *testing.T) {
	userClient := &fakeUserAuthClient{whitelisted: true}
	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-whitelist")
	request.Header.Set("X-Forwarded-For", "203.0.113.52")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-login-risk-whitelist")
	if userClient.loginPasswordCount != 1 || userClient.blockedCount != 0 {
		t.Fatalf("whitelisted IP must bypass fast guard: login=%d blocked=%d", userClient.loginPasswordCount, userClient.blockedCount)
	}
	if userClient.lastWhitelist == nil || userClient.lastWhitelist.GetClientIp() != "203.0.113.52" {
		t.Fatalf("whitelist check request mismatch: %+v", userClient.lastWhitelist)
	}
}

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 TestSessionDenylistReadErrorFailsAuthenticatedRequest(t *testing.T) {
	cache := newMemoryLoginRiskCache()
	cache.revokedErr = errors.New("redis unavailable")
	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-denylist-error")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-session-denylist-error")
}

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 == 0 {
			var response httpkit.ResponseEnvelope
			if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
				t.Fatalf("decode refresh response failed: %v", err)
			}
			data, ok := response.Data.(map[string]any)
			if response.Code != httpkit.CodeOK || !ok || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
				t.Fatalf("refresh must expose access token TTL: %+v", response)
			}
		}
		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 TestRefreshPropagatesCurrentClientVersionWithHeaderPriority(t *testing.T) {
	userClient := &fakeUserAuthClient{}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"refresh_token":"refresh-1","refresh_request_id":"refresh-attempt-1","device_id":"dev-1","app_version":"1.2.3","build_number":"123"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-refresh-version")
	request.Header.Set("X-App-Version", "2.0.0")
	request.Header.Set("X-App-Build-Number", "200")
	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 := userClient.lastRefresh.GetMeta()
	if meta.GetAppVersion() != "2.0.0" || meta.GetBuildNumber() != "200" {
		t.Fatalf("refresh client version mismatch: %+v", meta)
	}
	if userClient.lastRefresh.GetRefreshRequestId() != "refresh-attempt-1" {
		t.Fatalf("refresh_request_id must reach user-service, got %q", userClient.lastRefresh.GetRefreshRequestId())
	}
}

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","profile_bg_img":"https://cdn.example/bg.png","user_id":99}`)
	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)
	}
	if profileClient.lastBackground != nil {
		t.Fatalf("profile update must not modify profile_bg_img: %+v", profileClient.lastBackground)
	}
}

func TestUpdateProfileBackgroundUsesAuthenticatedUserIDAndURL(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"url":"https://cdn.example/profile-bg.png","user_id":99}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/profile-bg-img", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-profile-bg")
	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.lastBackground == nil || profileClient.lastBackground.GetUserId() != 42 {
		t.Fatalf("authenticated user_id was not propagated to profile background update: %+v", profileClient.lastBackground)
	}
	if profileClient.lastBackground.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-profile-bg") || profileClient.lastBackground.GetProfileBgImg() != "https://cdn.example/profile-bg.png" {
		t.Fatalf("profile background request mismatch: %+v", profileClient.lastBackground)
	}
	var response httpkit.ResponseEnvelope
	if err := json.Unmarshal(recorder.Body.Bytes(), &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_bg_img"] != "https://cdn.example/profile-bg.png" {
		t.Fatalf("profile background response mismatch: %+v", response)
	}
}

func TestUpdateContactInfoUsesAuthenticatedUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"whatsapp_phone":"+65 1234 5678","user_id":99}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/contact-info", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-contact")
	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.lastContact == nil || profileClient.lastContact.GetUserId() != 42 {
		t.Fatalf("authenticated user_id was not propagated to contact update: %+v", profileClient.lastContact)
	}
	if profileClient.lastContact.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-contact") || profileClient.lastContact.GetContactInfo() != "+65 1234 5678" {
		t.Fatalf("contact update request mismatch: %+v", profileClient.lastContact)
	}
	var response httpkit.ResponseEnvelope
	if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
		t.Fatalf("decode response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || data["contact_info"] != "+65 1234 5678" || data["whatsapp_phone"] != "+65 1234 5678" {
		t.Fatalf("contact update response mismatch: %+v", response)
	}
}

func TestGetMyProfileUsesAuthenticatedUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
		UserId:           42,
		DisplayUserId:    "100001",
		Username:         "hy",
		Gender:           "female",
		ProfileCompleted: true,
		OnboardingStatus: "completed",
		CreatedAtMs:      1_770_000_000_000,
		ProfileBgImg:     "https://cdn.example/profile-bg.png",
	}}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	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-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" || data["created_at_ms"] != float64(1_770_000_000_000) || data["profile_bg_img"] != "https://cdn.example/profile-bg.png" {
		t.Fatalf("profile response mismatch: %+v", response)
	}
}

func TestGetMyInviteOverviewUsesAuthenticatedUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{}
	router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me/invite-overview", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-invite-overview")
	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["my_invite_code"] != "A1B2C3" || data["invite_enabled"] != true || data["invite_count"] != float64(12) || data["valid_invite_count"] != float64(3) {
		t.Fatalf("invite overview 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:   "你的提现申请已通过。",
			Actions: []*activityv1.InboxAction{{
				Action: "claim",
				Label:  "Claim",
				Style:  "primary",
				Api: &activityv1.InboxActionAPI{
					Method:   "POST",
					Path:     "/api/v1/rewards/reward_01/claim",
					BodyJson: `{"command_id":"claim_reward_01"}`,
				},
			}},
			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)
	}
	items, ok := data["items"].([]any)
	if !ok || len(items) != 1 {
		t.Fatalf("message list items mismatch: %+v", data["items"])
	}
	item, ok := items[0].(map[string]any)
	if !ok {
		t.Fatalf("message list item type mismatch: %+v", items[0])
	}
	actions, ok := item["actions"].([]any)
	if !ok || len(actions) != 1 {
		t.Fatalf("message actions mismatch: %+v", item["actions"])
	}
	action, ok := actions[0].(map[string]any)
	api, apiOK := action["api"].(map[string]any)
	body, bodyOK := api["body"].(map[string]any)
	if !ok || !apiOK || !bodyOK || action["label"] != "Claim" || api["method"] != "POST" || api["path"] != "/api/v1/rewards/reward_01/claim" || body["command_id"] != "claim_reward_01" {
		t.Fatalf("message action response mismatch: %+v", action)
	}
}

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 TestAchievementAndBadgeRoutesEmbedResourceMaterials(t *testing.T) {
	badgeResource := &walletv1.Resource{
		ResourceId:    8001,
		ResourceCode:  "badge_first_gift",
		ResourceType:  "badge",
		Name:          "First Gift",
		Status:        "active",
		Grantable:     true,
		GrantStrategy: "new_entitlement",
		UsageScopes:   []string{"badge"},
		AssetUrl:      "https://cdn.example/badge.png",
		MetadataJson:  `{"badge_form":"tile"}`,
		SortOrder:     10,
	}
	achievementClient := &fakeAchievementClient{
		listResp: &activityv1.ListAchievementsResponse{
			Achievements: []*activityv1.UserAchievement{{
				Definition: &activityv1.AchievementDefinition{
					AchievementId:          "ach_first_gift",
					CollectionId:           "ordinary",
					CollectionType:         "ordinary",
					AchievementType:        "ordinary",
					Title:                  "First Gift",
					Status:                 "active",
					Version:                1,
					PrimaryBadgeResourceId: 8001,
					RewardResourceGroupId:  9001,
				},
				CycleKey:      "lifetime",
				ProgressValue: 1,
				TargetValue:   1,
				UserStatus:    "unlocked",
				RewardStatus:  "granted",
			}},
			Total:        1,
			ServerTimeMs: 1778256000000,
		},
		badgesResp: &activityv1.ListMyBadgesResponse{
			ProfileTileBadges: []*activityv1.BadgeDisplayItem{{
				Slot:       "profile_tile",
				Position:   1,
				BadgeForm:  "tile",
				ResourceId: 8001,
				SourceType: "achievement",
				SourceId:   "ach_first_gift",
				PinMode:    "user",
			}},
			ServerTimeMs: 1778256000000,
		},
	}
	walletClient := &fakeWalletClient{
		resourcesByID: map[int64]*walletv1.Resource{8001: badgeResource},
		resourceGroupsByID: map[int64]*walletv1.ResourceGroup{9001: {
			GroupId:   9001,
			GroupCode: "ach_first_gift_reward",
			Name:      "First Gift Reward",
			Status:    "active",
			Items: []*walletv1.ResourceGroupItem{{
				GroupItemId: 1,
				ItemType:    "resource",
				ResourceId:  8001,
				Resource:    badgeResource,
				Quantity:    1,
				SortOrder:   10,
			}},
		}},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAchievementClient(achievementClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/achievements?page=2&page_size=5", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("achievement status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var achievementEnvelope struct {
		Code string `json:"code"`
		Data struct {
			Achievements []struct {
				Definition struct {
					PrimaryBadgeResource struct {
						ResourceID int64  `json:"resource_id"`
						AssetURL   string `json:"asset_url"`
					} `json:"primary_badge_resource"`
					RewardResourceGroup struct {
						GroupID int64 `json:"group_id"`
						Items   []struct {
							Resource struct {
								ResourceID int64  `json:"resource_id"`
								AssetURL   string `json:"asset_url"`
							} `json:"resource"`
						} `json:"items"`
					} `json:"reward_resource_group"`
				} `json:"definition"`
			} `json:"achievements"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&achievementEnvelope); err != nil {
		t.Fatalf("decode achievement response failed: %v", err)
	}
	if achievementClient.lastList == nil || achievementClient.lastList.GetUserId() != 42 || achievementClient.lastList.GetPage() != 2 || achievementClient.lastList.GetPageSize() != 5 {
		t.Fatalf("achievement request mismatch: %+v", achievementClient.lastList)
	}
	firstDefinition := achievementEnvelope.Data.Achievements[0].Definition
	if achievementEnvelope.Code != httpkit.CodeOK || firstDefinition.PrimaryBadgeResource.AssetURL != "https://cdn.example/badge.png" || firstDefinition.RewardResourceGroup.GroupID != 9001 || firstDefinition.RewardResourceGroup.Items[0].Resource.ResourceID != 8001 {
		t.Fatalf("achievement response must embed resource materials: %+v", achievementEnvelope)
	}

	request = httptest.NewRequest(http.MethodGet, "/api/v1/badges/me", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("badge status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var badgeEnvelope struct {
		Code string `json:"code"`
		Data struct {
			ProfileTileBadges []struct {
				ResourceID int64 `json:"resource_id"`
				Resource   struct {
					ResourceID int64  `json:"resource_id"`
					AssetURL   string `json:"asset_url"`
				} `json:"resource"`
			} `json:"profile_tile_badges"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&badgeEnvelope); err != nil {
		t.Fatalf("decode badge response failed: %v", err)
	}
	if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 {
		t.Fatalf("badge request mismatch: %+v", achievementClient.lastBadges)
	}
	if badgeEnvelope.Code != httpkit.CodeOK || len(badgeEnvelope.Data.ProfileTileBadges) != 1 || badgeEnvelope.Data.ProfileTileBadges[0].Resource.AssetURL != "https://cdn.example/badge.png" {
		t.Fatalf("badge response must embed resource materials: %+v", badgeEnvelope)
	}
}

func TestAchievementListAllowsExplicitUserIDWithoutToken(t *testing.T) {
	achievementClient := &fakeAchievementClient{
		listResp: &activityv1.ListAchievementsResponse{
			Total:        0,
			ServerTimeMs: 1778256000000,
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetAchievementClient(achievementClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/achievements?user_id=77&page=3&page_size=6&collection_type=event", nil)
	request.Header.Set("X-App-Code", "lalu")
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("achievement status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if achievementClient.lastList == nil || achievementClient.lastList.GetUserId() != 77 || achievementClient.lastList.GetPage() != 3 || achievementClient.lastList.GetPageSize() != 6 || achievementClient.lastList.GetCollectionType() != "event" {
		t.Fatalf("achievement user_id request mismatch: %+v", achievementClient.lastList)
	}

	achievementClient.lastList = nil
	request = httptest.NewRequest(http.MethodGet, "/api/v1/achievements?page=1&page_size=6", nil)
	request.Header.Set("X-App-Code", "lalu")
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusUnauthorized {
		t.Fatalf("anonymous achievement without user_id must stay unauthorized: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if achievementClient.lastList != nil {
		t.Fatalf("anonymous achievement without user_id must not reach upstream: %+v", achievementClient.lastList)
	}
}

func TestPublicUserProfileRouteUsesPathUserID(t *testing.T) {
	profileClient := &fakeUserProfileClient{
		usersByID: map[int64]*userv1.User{
			77: {
				UserId:        77,
				DisplayUserId: "100077",
				Username:      "target-user",
				Avatar:        "https://cdn.example/target.png",
			},
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-id/77/profile", nil)
	request.Header.Set("X-App-Code", "lalu")
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("public profile status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 77 {
		t.Fatalf("public profile must use path user_id: %+v", profileClient.lastGet)
	}
}

func TestCPWeeklyRankActivityUsesWeeklyScoreAndActivityClient(t *testing.T) {
	cpWeeklyClient := &fakeCPWeeklyRankClient{
		statusResp: &activityv1.GetCPWeeklyRankStatusResponse{
			Config: &activityv1.CPWeeklyRankConfig{
				ActivityCode: "cp_weekly_rank",
				Enabled:      true,
				RelationType: "cp",
				TopCount:     3,
			},
			Entries: []*activityv1.CPWeeklyRankEntry{
				{
					Rank:            1,
					RelationshipId:  "weekly_delta_high",
					RelationType:    "cp",
					Score:           100,
					UserA:           &activityv1.CPWeeklyRankUser{UserId: 7003, DisplayUserId: "7003", Username: "Weekly A", Avatar: "a.png"},
					UserB:           &activityv1.CPWeeklyRankUser{UserId: 7004, DisplayUserId: "7004", Username: "Weekly B", Avatar: "b.png"},
					FormedAtMs:      1780000000000,
					UpdatedAtMs:     1780876800000,
					FirstScoredAtMs: 1780876800000,
					LastScoredAtMs:  1781481599999,
				},
				{
					Rank:            2,
					RelationshipId:  "weekly_total_high",
					RelationType:    "cp",
					Score:           10,
					UserA:           &activityv1.CPWeeklyRankUser{UserId: 7001, DisplayUserId: "7001", Username: "Total A", Avatar: "c.png"},
					UserB:           &activityv1.CPWeeklyRankUser{UserId: 7002, DisplayUserId: "7002", Username: "Total B", Avatar: "d.png"},
					FormedAtMs:      1780000000000,
					UpdatedAtMs:     1780876800000,
					FirstScoredAtMs: 1780876800000,
					LastScoredAtMs:  1780876800000,
				},
			},
			PeriodStartMs: 1780876800000,
			PeriodEndMs:   1781481600000,
			ServerTimeMs:  1780880000000,
		},
	}
	userCPClient := &fakeUserCPClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetCPWeeklyRankClient(cpWeeklyClient)
	handler.SetUserCPClient(userCPClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/cp-weekly-rank?limit=2", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("cp weekly rank status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}

	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Entries []struct {
				Rank           int64  `json:"rank"`
				RelationshipID string `json:"relationship_id"`
				Score          int64  `json:"score"`
			} `json:"entries"`
			Period struct {
				StartMS int64 `json:"start_ms"`
				EndMS   int64 `json:"end_ms"`
			} `json:"period"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode cp weekly rank response failed: %v", err)
	}
	if cpWeeklyClient.lastStatus == nil || cpWeeklyClient.lastStatus.GetLimit() != 2 || cpWeeklyClient.lastStatus.GetMeta().GetAppCode() != "lalu" {
		t.Fatalf("cp weekly rank must call activity-service with request meta: %+v", cpWeeklyClient.lastStatus)
	}
	if userCPClient.listIntimacyLeaderboardCalls != 0 {
		t.Fatalf("cp weekly activity must not read ordinary cumulative intimacy leaderboard, calls=%d", userCPClient.listIntimacyLeaderboardCalls)
	}
	if envelope.Code != httpkit.CodeOK || len(envelope.Data.Entries) != 2 {
		t.Fatalf("cp weekly rank envelope mismatch: %+v", envelope)
	}
	if envelope.Data.Entries[0].RelationshipID != "weekly_delta_high" || envelope.Data.Entries[0].Score != 100 || envelope.Data.Entries[1].Score != 10 {
		t.Fatalf("cp weekly rank must expose activity weekly score unchanged: %+v", envelope.Data.Entries)
	}
	if envelope.Data.Period.StartMS != 1780876800000 || envelope.Data.Period.EndMS != 1781481600000 {
		t.Fatalf("cp weekly rank must expose activity period: %+v", envelope.Data.Period)
	}
}

func TestWeeklyStarCurrentEmbedsRewardResourceGroups(t *testing.T) {
	giftResource := &walletv1.Resource{
		ResourceId:   7308,
		ResourceCode: "weekly_star_gift_308",
		ResourceType: "gift",
		Name:         "Star Ball Gift",
		Status:       "active",
		AssetUrl:     "https://cdn.example/gift-308.svga",
		PreviewUrl:   "https://cdn.example/gift-308.png",
		SortOrder:    1,
	}
	rewardResource := &walletv1.Resource{
		ResourceId:      8101,
		ResourceCode:    "weekly_star_frame",
		ResourceType:    "avatar_frame",
		Name:            "Weekly Star Frame",
		Status:          "active",
		Grantable:       true,
		GrantStrategy:   "new_entitlement",
		WalletAssetType: "FRAME",
		UsageScopes:     []string{"profile"},
		AssetUrl:        "https://cdn.example/weekly-frame.png",
		PreviewUrl:      "https://cdn.example/weekly-frame-preview.png",
		SortOrder:       10,
	}
	weeklyStarClient := &fakeWeeklyStarClient{
		currentResp: &activityv1.GetWeeklyStarCurrentResponse{
			Cycle: &activityv1.WeeklyStarCycle{
				CycleId:      "wstar-test",
				ActivityCode: "weekly_star",
				RegionId:     686,
				Title:        "Weekly Star",
				Status:       "active",
				StartMs:      1780848000000,
				EndMs:        1781452740000,
				Gifts: []*activityv1.WeeklyStarGift{{
					GiftId:    "308",
					SortOrder: 1,
				}},
				Rewards: []*activityv1.WeeklyStarReward{{
					RankNo:          1,
					ResourceGroupId: 23,
				}},
			},
			TopEntries: []*activityv1.WeeklyStarLeaderboardEntry{{
				RankNo:          1,
				UserId:          88,
				Score:           120000,
				FirstScoredAtMs: 1780849000000,
				LastScoredAtMs:  1780849900000,
			}},
			ServerTimeMs: 1780916324006,
		},
	}
	walletClient := &fakeWalletClient{
		listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
			Gifts: []*walletv1.GiftConfig{{
				AppCode:    "lalu",
				GiftId:     "308",
				ResourceId: giftResource.GetResourceId(),
				Resource:   giftResource,
				Status:     "active",
				Name:       "Star Ball",
				SortOrder:  7,
				CoinPrice:  100000,
			}},
			Total: 1,
		},
		resourceGroupsByID: map[int64]*walletv1.ResourceGroup{23: {
			GroupId:     23,
			GroupCode:   "weekly_star_top1",
			Name:        "Top 1 Resource Group",
			Status:      "active",
			Description: "Top 1 reward materials",
			Items: []*walletv1.ResourceGroupItem{{
				GroupItemId: 501,
				ItemType:    "resource",
				ResourceId:  8101,
				Resource:    rewardResource,
				Quantity:    1,
				DurationMs:  7 * 24 * int64(time.Hour/time.Millisecond),
				SortOrder:   1,
			}},
		}},
	}
	userClient := &fakeUserProfileClient{
		regionID: 686,
		usersByID: map[int64]*userv1.User{
			42: {UserId: 42, DisplayUserId: "163000", Username: "viewer", RegionId: 686},
			88: {UserId: 88, DisplayUserId: "163088", Username: "weekly_ranker", Avatar: "https://cdn.example/ranker.png", RegionId: 686},
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, userClient)
	handler.SetWeeklyStarClient(weeklyStarClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/activities/weekly-star/current", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("weekly star current status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Cycle struct {
				Gifts []struct {
					GiftID     string `json:"gift_id"`
					Name       string `json:"name"`
					ImageURL   string `json:"image_url"`
					PreviewURL string `json:"preview_url"`
					Resource   struct {
						ResourceID int64  `json:"resource_id"`
						PreviewURL string `json:"preview_url"`
					} `json:"resource"`
				} `json:"gifts"`
				Rewards []struct {
					RankNo          int32 `json:"rank_no"`
					ResourceGroupID int64 `json:"resource_group_id"`
					ResourceGroup   struct {
						GroupID int64  `json:"group_id"`
						Name    string `json:"name"`
						Items   []struct {
							ResourceID int64 `json:"resource_id"`
							Quantity   int64 `json:"quantity"`
							DurationMS int64 `json:"duration_ms"`
							Resource   struct {
								ResourceID   int64  `json:"resource_id"`
								ResourceType string `json:"resource_type"`
								Name         string `json:"name"`
								AssetURL     string `json:"asset_url"`
							} `json:"resource"`
						} `json:"items"`
					} `json:"resource_group"`
				} `json:"rewards"`
			} `json:"cycle"`
			TopEntries []struct {
				User struct {
					Username string `json:"username"`
				} `json:"user"`
			} `json:"top_entries"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode weekly star current response failed: %v", err)
	}
	if weeklyStarClient.lastCurrent == nil || weeklyStarClient.lastCurrent.GetUserId() != 42 || weeklyStarClient.lastCurrent.GetRegionId() != 686 {
		t.Fatalf("weekly star current request mismatch: %+v", weeklyStarClient.lastCurrent)
	}
	if envelope.Code != httpkit.CodeOK || len(envelope.Data.Cycle.Rewards) != 1 {
		t.Fatalf("weekly star response missing rewards: %+v", envelope)
	}
	if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetAppCode() != "lalu" || !walletClient.lastListGiftConfigs.GetActiveOnly() || walletClient.lastListGiftConfigs.GetPageSize() != 500 {
		t.Fatalf("weekly star gift preload request mismatch: %+v", walletClient.lastListGiftConfigs)
	}
	if len(envelope.Data.Cycle.Gifts) != 1 || envelope.Data.Cycle.Gifts[0].GiftID != "308" || envelope.Data.Cycle.Gifts[0].Name != "Star Ball" || envelope.Data.Cycle.Gifts[0].ImageURL != "https://cdn.example/gift-308.png" || envelope.Data.Cycle.Gifts[0].PreviewURL != "https://cdn.example/gift-308.png" || envelope.Data.Cycle.Gifts[0].Resource.ResourceID != 7308 {
		t.Fatalf("weekly star gift must embed cover material details: %+v", envelope.Data.Cycle.Gifts)
	}
	firstReward := envelope.Data.Cycle.Rewards[0]
	if firstReward.ResourceGroupID != 23 || firstReward.ResourceGroup.GroupID != 23 || firstReward.ResourceGroup.Name != "Top 1 Resource Group" {
		t.Fatalf("weekly star reward group mismatch: %+v", firstReward)
	}
	if len(firstReward.ResourceGroup.Items) != 1 {
		t.Fatalf("weekly star reward group items mismatch: %+v", firstReward.ResourceGroup)
	}
	firstItem := firstReward.ResourceGroup.Items[0]
	if firstItem.ResourceID != 8101 || firstItem.Quantity != 1 || firstItem.DurationMS == 0 || firstItem.Resource.ResourceID != 8101 || firstItem.Resource.ResourceType != "avatar_frame" || firstItem.Resource.Name != "Weekly Star Frame" || firstItem.Resource.AssetURL == "" {
		t.Fatalf("weekly star reward group item must embed resource material: %+v", firstItem)
	}
	if len(envelope.Data.TopEntries) != 1 || envelope.Data.TopEntries[0].User.Username != "weekly_ranker" {
		t.Fatalf("weekly star top entry user mismatch: %+v", envelope.Data.TopEntries)
	}
}

func TestAppearanceAggregatesEquippedResourcesAndBadges(t *testing.T) {
	frameResource := &walletv1.Resource{
		ResourceId:    8101,
		ResourceCode:  "frame_gold",
		ResourceType:  "avatar_frame",
		Name:          "Gold Frame",
		AssetUrl:      "https://cdn.example/frame.png",
		AnimationUrl:  "https://cdn.example/frame.svga",
		MetadataJson:  `{"level":1}`,
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	vehicleResource := &walletv1.Resource{
		ResourceId:    8102,
		ResourceCode:  "vehicle_gold",
		ResourceType:  "vehicle",
		Name:          "Gold Vehicle",
		AssetUrl:      "https://cdn.example/vehicle.png",
		AnimationUrl:  "https://cdn.example/vehicle.svga",
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	profileCardResource := &walletv1.Resource{
		ResourceId:    8104,
		ResourceCode:  "profile_card_gold",
		ResourceType:  "profile_card",
		Name:          "Gold Profile Card",
		AssetUrl:      "https://cdn.example/profile-card.png",
		AnimationUrl:  "https://cdn.example/profile-card.mp4",
		MetadataJson:  `{"profile_card_layout":{"source_width":1136,"source_height":1680,"content_top":117,"content_bottom":1666,"content_height":1550}}`,
		GrantStrategy: "extend_expiry",
		Status:        "active",
	}
	badgeResource := &walletv1.Resource{
		ResourceId:    8103,
		ResourceCode:  "badge_host",
		ResourceType:  "badge",
		Name:          "Host Badge",
		AssetUrl:      "https://cdn.example/badge.png",
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	micSeatResource := &walletv1.Resource{
		ResourceId:    8106,
		ResourceCode:  "mic_seat_wave_gold",
		ResourceType:  "mic_seat_animation",
		Name:          "Gold Mic Wave",
		AssetUrl:      "https://cdn.example/mic-seat.png",
		AnimationUrl:  "https://cdn.example/mic-seat.svga",
		MetadataJson:  `{"usage":"mic_seat"}`,
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	micSeatIconResource := &walletv1.Resource{
		ResourceId:    8107,
		ResourceCode:  "mic_seat_icon_gold",
		ResourceType:  "mic_seat_icon",
		Name:          "Gold Mic Icon",
		AssetUrl:      "https://cdn.example/mic-seat-icon.png",
		GrantStrategy: "set_active_flag",
		Status:        "active",
	}
	walletClient := &fakeWalletClient{
		batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
			Users: []*walletv1.UserEquippedResources{{
				UserId: 42,
				Resources: []*walletv1.UserResourceEntitlement{
					{UserId: 42, EntitlementId: "ent-frame", ResourceId: 8101, Resource: frameResource, Equipped: true, ExpiresAtMs: 1999999999999},
					{UserId: 42, EntitlementId: "ent-vehicle", ResourceId: 8102, Resource: vehicleResource, Equipped: true, ExpiresAtMs: 1999999999999},
					{UserId: 42, EntitlementId: "ent-profile-card", ResourceId: 8104, Resource: profileCardResource, Equipped: true, ExpiresAtMs: 1999999999999},
					{UserId: 42, EntitlementId: "ent-badge", ResourceId: 8103, Resource: badgeResource, Equipped: true, ExpiresAtMs: 1999999999999},
					{UserId: 42, EntitlementId: "ent-mic-seat-icon", ResourceId: 8107, Resource: micSeatIconResource, Equipped: true, ExpiresAtMs: 1999999999999},
					{UserId: 42, EntitlementId: "ent-mic-seat-wave", ResourceId: 8106, Resource: micSeatResource, Equipped: true, ExpiresAtMs: 1999999999999},
				},
			}},
		},
		resourcesByID: map[int64]*walletv1.Resource{8103: badgeResource},
	}
	achievementClient := &fakeAchievementClient{
		badgesResp: &activityv1.ListMyBadgesResponse{
			StripBadges: []*activityv1.BadgeDisplayItem{{
				Slot:          "strip",
				Position:      1,
				BadgeForm:     "icon",
				ResourceId:    8103,
				EntitlementId: "ent-badge",
				SourceType:    "achievement",
				SourceId:      "ach-host",
				PinMode:       "auto",
				UpdatedAtMs:   1778256000000,
			}},
			ServerTimeMs: 1778256000000,
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	handler.SetAchievementClient(achievementClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/appearance", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("appearance status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			UserID      string `json:"user_id"`
			AvatarFrame struct {
				ResourceID   int64  `json:"resource_id"`
				AnimationURL string `json:"animation_url"`
			} `json:"avatar_frame"`
			Vehicle struct {
				ResourceID   int64  `json:"resource_id"`
				AnimationURL string `json:"animation_url"`
			} `json:"vehicle"`
			ProfileCard struct {
				ResourceID   int64  `json:"resource_id"`
				MetadataJSON string `json:"metadata_json"`
			} `json:"profile_card"`
			MicSeatIcon struct {
				ResourceID    int64  `json:"resource_id"`
				AssetURL      string `json:"asset_url"`
				EntitlementID string `json:"entitlement_id"`
			} `json:"mic_seat_icon"`
			MicSeatAnimation struct {
				ResourceID    int64  `json:"resource_id"`
				AnimationURL  string `json:"animation_url"`
				EntitlementID string `json:"entitlement_id"`
			} `json:"mic_seat_animation"`
			EquippedBadges []struct {
				ResourceID int64 `json:"resource_id"`
			} `json:"equipped_badges"`
			Badges []struct {
				ResourceID int64 `json:"resource_id"`
				Resource   struct {
					AssetURL string `json:"asset_url"`
				} `json:"resource"`
			} `json:"badges"`
			Resources []struct {
				ResourceID   int64  `json:"resource_id"`
				ResourceType string `json:"resource_type"`
			} `json:"resources"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode appearance response failed: %v", err)
	}
	if walletClient.lastBatchEquipped == nil || walletClient.lastBatchEquipped.GetUserIds()[0] != 42 {
		t.Fatalf("appearance should batch query equipped resources: %+v", walletClient.lastBatchEquipped)
	}
	if strings.Join(walletClient.lastBatchEquipped.GetResourceTypes(), ",") != "avatar_frame,profile_card,vehicle,badge,mic_seat_icon,mic_seat_animation" {
		t.Fatalf("appearance should query independent mic icon and Voice Wave slots: %+v", walletClient.lastBatchEquipped.GetResourceTypes())
	}
	if achievementClient.lastBadges == nil || achievementClient.lastBadges.GetUserId() != 42 {
		t.Fatalf("appearance should query displayed badges: %+v", achievementClient.lastBadges)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.UserID != "42" || envelope.Data.AvatarFrame.AnimationURL != "https://cdn.example/frame.svga" || envelope.Data.Vehicle.ResourceID != 8102 || envelope.Data.ProfileCard.ResourceID != 8104 || !strings.Contains(envelope.Data.ProfileCard.MetadataJSON, `"content_top":117`) || envelope.Data.MicSeatIcon.ResourceID != 8107 || envelope.Data.MicSeatIcon.AssetURL != "https://cdn.example/mic-seat-icon.png" || envelope.Data.MicSeatIcon.EntitlementID != "ent-mic-seat-icon" || envelope.Data.MicSeatAnimation.ResourceID != 8106 || envelope.Data.MicSeatAnimation.AnimationURL != "https://cdn.example/mic-seat.svga" || envelope.Data.MicSeatAnimation.EntitlementID != "ent-mic-seat-wave" || len(envelope.Data.EquippedBadges) != 1 || len(envelope.Data.Badges) != 1 || envelope.Data.Badges[0].Resource.AssetURL != "https://cdn.example/badge.png" {
		t.Fatalf("appearance response mismatch: %+v", envelope)
	}
	hasMicSeatIconResource := false
	hasMicSeatAnimationResource := false
	for _, resource := range envelope.Data.Resources {
		if resource.ResourceID == 8107 && resource.ResourceType == "mic_seat_icon" {
			hasMicSeatIconResource = true
		}
		if resource.ResourceID == 8106 && resource.ResourceType == "mic_seat_animation" {
			hasMicSeatAnimationResource = true
		}
	}
	if !hasMicSeatIconResource || !hasMicSeatAnimationResource {
		t.Fatalf("appearance resources must include independent mic icon and Voice Wave equipment: %+v", envelope.Data.Resources)
	}
}

func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
	walletClient := &fakeWalletClient{
		listResourcesResp: &walletv1.ListResourcesResponse{
			Resources: []*walletv1.Resource{
				{
					ResourceId:   7001,
					ResourceCode: "emoji_global",
					ResourceType: "emoji_pack",
					Name:         "Global Emoji",
					Status:       "active",
					PreviewUrl:   "https://cdn.example/emoji/global.png",
					AnimationUrl: "https://cdn.example/emoji/global.svga",
					MetadataJson: `{"category":"默认","pricing_type":"free","region_scope":"all"}`,
					SortOrder:    10,
				},
				{
					ResourceId:   7002,
					ResourceCode: "emoji_saudi",
					ResourceType: "emoji_pack",
					Name:         "Saudi Emoji",
					Status:       "active",
					AssetUrl:     "https://cdn.example/emoji/saudi.png",
					AnimationUrl: "https://cdn.example/emoji/saudi.pag",
					MetadataJson: `{"category":"热门","pricing_type":"paid","region_ids":[1001]}`,
					SortOrder:    20,
				},
				{
					ResourceId:   7003,
					ResourceCode: "emoji_egypt",
					ResourceType: "emoji_pack",
					Name:         "Egypt Emoji",
					Status:       "active",
					AssetUrl:     "https://cdn.example/emoji/egypt.png",
					AnimationUrl: "https://cdn.example/emoji/egypt.pag",
					MetadataJson: `{"region_ids":[1002]}`,
					SortOrder:    30,
				},
			},
			Total: 3,
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/emoji-packs?region_id=1001&page=1&page_size=10", nil)
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("emoji packs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Items []struct {
				ResourceID   int64   `json:"resource_id"`
				Category     string  `json:"category"`
				PricingType  string  `json:"pricing_type"`
				CoverURL     string  `json:"cover_url"`
				AnimationURL string  `json:"animation_url"`
				RegionIDs    []int64 `json:"region_ids"`
				IsGlobal     bool    `json:"is_global"`
			} `json:"items"`
			Total int64 `json:"total"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode emoji packs failed: %v", err)
	}
	if walletClient.lastListResources == nil || walletClient.lastListResources.GetResourceType() != "emoji_pack" || !walletClient.lastListResources.GetActiveOnly() {
		t.Fatalf("emoji pack request mismatch: %+v", walletClient.lastListResources)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 {
		t.Fatalf("emoji pack envelope mismatch: %+v", envelope)
	}
	if !envelope.Data.Items[0].IsGlobal || envelope.Data.Items[0].CoverURL != "https://cdn.example/emoji/global.png" || envelope.Data.Items[0].Category != "默认" || envelope.Data.Items[0].PricingType != "free" {
		t.Fatalf("global emoji pack mismatch: %+v", envelope.Data.Items[0])
	}
	if envelope.Data.Items[1].ResourceID != 7002 || envelope.Data.Items[1].AnimationURL != "https://cdn.example/emoji/saudi.pag" || envelope.Data.Items[1].Category != "热门" || envelope.Data.Items[1].PricingType != "paid" || len(envelope.Data.Items[1].RegionIDs) != 1 || envelope.Data.Items[1].RegionIDs[0] != 1001 {
		t.Fatalf("regional emoji pack mismatch: %+v", envelope.Data.Items[1])
	}
}

func TestListResourceShopItemsReturnsActiveSaleItems(t *testing.T) {
	walletClient := &fakeWalletClient{
		resourceShopResp: &walletv1.ListResourceShopItemsResponse{
			Items: []*walletv1.ResourceShopItem{
				{
					ShopItemId:      9001,
					ResourceId:      7001,
					Status:          "active",
					DurationDays:    3,
					PriceType:       "coin",
					CoinPrice:       300,
					EffectiveFromMs: 1_700_000_000_000,
					EffectiveToMs:   1_700_086_400_000,
					SortOrder:       10,
					Resource: &walletv1.Resource{
						ResourceId:   7001,
						ResourceCode: "avatar_frame_gold",
						ResourceType: "avatar_frame",
						Name:         "Gold Frame",
						Status:       "active",
						AssetUrl:     "https://cdn.example/frame.png",
						PreviewUrl:   "https://cdn.example/frame-preview.png",
						PriceType:    "coin",
						CoinPrice:    100,
					},
				},
			},
			Total: 1,
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/resource-shop/items?resource_type=avatar_frame&page=1&page_size=10", nil)
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("resource shop status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Items []struct {
				ShopItemID      int64 `json:"shop_item_id"`
				ResourceID      int64 `json:"resource_id"`
				DurationDays    int32 `json:"duration_days"`
				CoinPrice       int64 `json:"coin_price"`
				EffectiveFromMS int64 `json:"effective_from_ms"`
				Resource        struct {
					ResourceCode string `json:"resource_code"`
					ResourceType string `json:"resource_type"`
					CoinPrice    int64  `json:"coin_price"`
					PreviewURL   string `json:"preview_url"`
				} `json:"resource"`
			} `json:"items"`
			Total int64 `json:"total"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode resource shop failed: %v", err)
	}
	if walletClient.lastResourceShop == nil || walletClient.lastResourceShop.GetResourceType() != "avatar_frame" || !walletClient.lastResourceShop.GetActiveOnly() || walletClient.lastResourceShop.GetPageSize() != 10 {
		t.Fatalf("resource shop request mismatch: %+v", walletClient.lastResourceShop)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 1 || len(envelope.Data.Items) != 1 {
		t.Fatalf("resource shop envelope mismatch: %+v", envelope)
	}
	first := envelope.Data.Items[0]
	if first.ShopItemID != 9001 || first.DurationDays != 3 || first.CoinPrice != 300 || first.Resource.ResourceCode != "avatar_frame_gold" || first.Resource.CoinPrice != 100 || first.Resource.PreviewURL == "" {
		t.Fatalf("resource shop item mismatch: %+v", first)
	}
}

func TestPurchaseResourceShopItemUsesAuthenticatedUserAndCommandID(t *testing.T) {
	walletClient := &fakeWalletClient{
		purchaseShopResp: &walletv1.PurchaseResourceShopItemResponse{
			OrderId:         "rshop_order_1",
			TransactionId:   "wtx_shop_1",
			ResourceGrantId: "rgr_shop_1",
			CoinSpent:       300,
			Balance: &walletv1.AssetBalance{
				AssetType:       "COIN",
				AvailableAmount: 700,
				Version:         2,
			},
			ShopItem: &walletv1.ResourceShopItem{
				ShopItemId:   9001,
				ResourceId:   7001,
				DurationDays: 3,
				PriceType:    "coin",
				CoinPrice:    300,
				Resource: &walletv1.Resource{
					ResourceId:   7001,
					ResourceCode: "avatar_frame_gold",
					ResourceType: "avatar_frame",
					Name:         "Gold Frame",
				},
			},
			Resource: &walletv1.UserResourceEntitlement{
				EntitlementId: "ent_shop_1",
				UserId:        42,
				ResourceId:    7001,
				Status:        "active",
				Quantity:      1,
				Resource: &walletv1.Resource{
					ResourceId:   7001,
					ResourceCode: "avatar_frame_gold",
					ResourceType: "avatar_frame",
				},
			},
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodPost, "/api/v1/resource-shop/items/9001/purchase", bytes.NewReader([]byte(`{"command_id":"cmd-shop-1"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("purchase shop status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if walletClient.lastPurchaseShop == nil || walletClient.lastPurchaseShop.GetCommandId() != "cmd-shop-1" || walletClient.lastPurchaseShop.GetUserId() != 42 || walletClient.lastPurchaseShop.GetShopItemId() != 9001 {
		t.Fatalf("purchase shop request mismatch: %+v", walletClient.lastPurchaseShop)
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			OrderID       string `json:"order_id"`
			TransactionID string `json:"transaction_id"`
			CoinSpent     int64  `json:"coin_spent"`
			Balance       struct {
				AvailableAmount int64 `json:"available_amount"`
			} `json:"balance"`
			Resource struct {
				EntitlementID string `json:"entitlement_id"`
				ResourceID    int64  `json:"resource_id"`
			} `json:"resource"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode purchase shop failed: %v", err)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.OrderID != "rshop_order_1" || envelope.Data.CoinSpent != 300 || envelope.Data.Balance.AvailableAmount != 700 || envelope.Data.Resource.EntitlementID != "ent_shop_1" {
		t.Fatalf("purchase shop envelope mismatch: %+v", envelope)
	}
}

func TestListGiftTabsReturnsActiveTypeConfigs(t *testing.T) {
	walletClient := &fakeWalletClient{
		listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{
			Gifts: []*walletv1.GiftConfig{
				{
					GiftId:       "rose",
					ResourceId:   11,
					Status:       "active",
					Name:         "Rose",
					GiftTypeCode: "noble",
					VipLevel:     4,
					CoinPrice:    100,
					Resource: &walletv1.Resource{
						ResourceId:   11,
						ResourceCode: "gift_rose",
						ResourceType: "gift",
						Name:         "Rose",
						Status:       "active",
					},
				},
			},
			Total: 1,
		},
		listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{
			GiftTypes: []*walletv1.GiftTypeConfig{
				{
					TypeCode:    "noble",
					Name:        "VIP礼物",
					TabKey:      "VIP",
					Status:      "active",
					SortOrder:   10,
					CreatedAtMs: 1_700_000_000_000,
					UpdatedAtMs: 1_700_000_000_100,
				},
				{
					TypeCode:  "lucky",
					Name:      "幸运礼物",
					TabKey:    "Lucky",
					Status:    "active",
					SortOrder: 30,
				},
			},
		},
	}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/gift-tabs", nil)
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("gift tabs status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Items []struct {
				Key          string `json:"key"`
				GiftTypeCode string `json:"gift_type_code"`
				Name         string `json:"name"`
				Label        string `json:"label"`
				TabKey       string `json:"tab_key"`
				Status       string `json:"status"`
				Order        int32  `json:"order"`
				SortOrder    int32  `json:"sort_order"`
				UpdatedAtMS  int64  `json:"updated_at_ms"`
				Gifts        []struct {
					GiftID       string `json:"gift_id"`
					GiftTypeCode string `json:"gift_type_code"`
					VIPLevel     int32  `json:"vip_level"`
					CoinPrice    int64  `json:"coin_price"`
				} `json:"gifts"`
			} `json:"items"`
			Total int `json:"total"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode gift tabs failed: %v", err)
	}
	if walletClient.lastListGiftTypes == nil || walletClient.lastListGiftTypes.GetAppCode() != "lalu" || !walletClient.lastListGiftTypes.GetActiveOnly() {
		t.Fatalf("gift tabs request mismatch: %+v", walletClient.lastListGiftTypes)
	}
	if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetAppCode() != "lalu" || !walletClient.lastListGiftConfigs.GetActiveOnly() || walletClient.lastListGiftConfigs.GetPageSize() != 100 || walletClient.lastListGiftConfigs.GetFilterRegion() {
		t.Fatalf("gift tabs gift preload request mismatch: %+v", walletClient.lastListGiftConfigs)
	}
	if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 {
		t.Fatalf("gift tabs envelope mismatch: %+v", envelope)
	}
	if envelope.Data.Items[0].Key != "noble" || envelope.Data.Items[0].GiftTypeCode != "noble" || envelope.Data.Items[0].Name != "VIP礼物" || envelope.Data.Items[0].Label != "VIP" || envelope.Data.Items[0].TabKey != "VIP" || envelope.Data.Items[0].Order != 10 || envelope.Data.Items[0].SortOrder != 10 || envelope.Data.Items[0].UpdatedAtMS != 1_700_000_000_100 {
		t.Fatalf("first gift tab mismatch: %+v", envelope.Data.Items[0])
	}
	if len(envelope.Data.Items[0].Gifts) != 1 || envelope.Data.Items[0].Gifts[0].GiftID != "rose" || envelope.Data.Items[0].Gifts[0].GiftTypeCode != "noble" || envelope.Data.Items[0].Gifts[0].VIPLevel != 4 || envelope.Data.Items[0].Gifts[0].CoinPrice != 100 {
		t.Fatalf("first gift tab gifts mismatch: %+v", envelope.Data.Items[0].Gifts)
	}
	if len(envelope.Data.Items[1].Gifts) != 0 {
		t.Fatalf("empty gift tab must still be returned with empty gifts: %+v", envelope.Data.Items[1])
	}
}

func TestRoomGiftPanelCachesGiftConfigAndKeepsRealtimeCalls(t *testing.T) {
	giftResource := &walletv1.Resource{
		ResourceId:   11,
		ResourceCode: "gift_rose",
		ResourceType: "gift",
		Name:         "Rose",
		Status:       "active",
		UsageScopes:  []string{"gift"},
		AssetUrl:     "https://cdn.example/rose.svga",
		SortOrder:    1,
	}
	walletClient := &fakeWalletClient{
		resp:               &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
		giftCatalogVersion: 1,
		listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
			TypeCode:  "normal",
			Name:      "普通礼物",
			TabKey:    "Gift",
			Status:    "active",
			SortOrder: 10,
		}}},
		listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
			GiftId:          "rose",
			ResourceId:      giftResource.GetResourceId(),
			Resource:        giftResource,
			Status:          "active",
			Name:            "Rose",
			GiftTypeCode:    "normal",
			PriceVersion:    "v1",
			ChargeAssetType: "COIN",
			CoinPrice:       100,
			HeatValue:       100,
			RegionIds:       []int64{0, 1001},
		}}, Total: 1},
		listUserResourcesResp: &walletv1.ListUserResourcesResponse{Resources: []*walletv1.UserResourceEntitlement{{
			EntitlementId:     "ent-rose",
			UserId:            42,
			ResourceId:        giftResource.GetResourceId(),
			Resource:          giftResource,
			Status:            "active",
			Quantity:          2,
			RemainingQuantity: 2,
		}}},
	}
	queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
		RoomId:          "room-gift-cache",
		OwnerUserId:     88,
		Status:          "active",
		VisibleRegionId: 1001,
	}}}
	handler := NewHandler(&fakeRoomClient{})
	handler.SetRoomQueryClient(queryClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	serveGiftPanel := func(attempt int) *httptest.ResponseRecorder {
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-cache/gift-panel", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
		recorder := httptest.NewRecorder()
		router.ServeHTTP(recorder, request)
		if recorder.Code != http.StatusOK {
			t.Fatalf("gift panel status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
		}
		return recorder
	}

	for attempt := 0; attempt < 2; attempt++ {
		serveGiftPanel(attempt)
	}
	if walletClient.getGiftCatalogCalls != 2 || walletClient.listGiftTypesCalls != 1 || walletClient.listGiftConfigsCalls != 1 {
		t.Fatalf("gift panel same-version config should be cached, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
	}
	if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
		t.Fatalf("gift panel realtime data must not be cached, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
	}
	if walletClient.lastGetGiftCatalog == nil || walletClient.lastGetGiftCatalog.GetAppCode() != "lalu" {
		t.Fatalf("gift panel catalog version request mismatch: %+v", walletClient.lastGetGiftCatalog)
	}
	if walletClient.lastListGiftConfigs == nil || walletClient.lastListGiftConfigs.GetRegionId() != 1001 || walletClient.lastListGiftConfigs.GetPageSize() != 500 || !walletClient.lastListGiftConfigs.GetFilterRegion() {
		t.Fatalf("gift panel list request mismatch: %+v", walletClient.lastListGiftConfigs)
	}
	if walletClient.lastListUserResources == nil || walletClient.lastListUserResources.GetUserId() != 42 || walletClient.lastListUserResources.GetResourceType() != "gift" || !walletClient.lastListUserResources.GetActiveOnly() {
		t.Fatalf("gift panel bag request mismatch: %+v", walletClient.lastListUserResources)
	}

	lilyResource := &walletv1.Resource{
		ResourceId:   12,
		ResourceCode: "gift_lily",
		ResourceType: "gift",
		Name:         "Lily",
		Status:       "active",
		UsageScopes:  []string{"gift"},
		AssetUrl:     "https://cdn.example/lily.svga",
		SortOrder:    2,
	}
	walletClient.giftCatalogVersion = 2
	walletClient.listGiftTypesResp = &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
		TypeCode:  "noble",
		Name:      "VIP礼物",
		TabKey:    "VIP",
		Status:    "active",
		SortOrder: 20,
	}}}
	walletClient.listGiftConfigsResp = &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
		GiftId:          "lily",
		ResourceId:      lilyResource.GetResourceId(),
		Resource:        lilyResource,
		Status:          "active",
		Name:            "Lily",
		GiftTypeCode:    "noble",
		VipLevel:        6,
		PriceVersion:    "v2",
		ChargeAssetType: "COIN",
		CoinPrice:       200,
		HeatValue:       200,
		RegionIds:       []int64{0, 1001},
	}}, Total: 1}
	recorder := serveGiftPanel(2)
	if walletClient.getGiftCatalogCalls != 3 || walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 {
		t.Fatalf("gift panel changed-version config should be refetched, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
	}
	if len(walletClient.balanceRequests) != 3 || walletClient.listUserResourcesCalls != 3 {
		t.Fatalf("gift panel realtime data must stay per-request after version change, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
	}
	var envelope struct {
		Code string `json:"code"`
		Data struct {
			Gifts []struct {
				GiftID       string `json:"gift_id"`
				GiftTypeCode string `json:"gift_type_code"`
				VIPLevel     int32  `json:"vip_level"`
				CoinPrice    int64  `json:"coin_price"`
			} `json:"gifts"`
		} `json:"data"`
	}
	if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode changed-version gift panel failed: %v", err)
	}
	if len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "noble" || envelope.Data.Gifts[0].VIPLevel != 6 {
		t.Fatalf("gift panel VIP level mismatch: %+v", envelope.Data.Gifts)
	}
	if envelope.Code != httpkit.CodeOK || len(envelope.Data.Gifts) != 1 || envelope.Data.Gifts[0].GiftID != "lily" || envelope.Data.Gifts[0].GiftTypeCode != "noble" || envelope.Data.Gifts[0].VIPLevel != 6 || envelope.Data.Gifts[0].CoinPrice != 200 {
		t.Fatalf("gift panel changed-version response should use refetched config: %+v", envelope)
	}
}

func TestRoomGiftPanelFallsBackWhenGiftCatalogVersionIsUnimplemented(t *testing.T) {
	giftResource := &walletv1.Resource{
		ResourceId:   11,
		ResourceCode: "gift_rose",
		ResourceType: "gift",
		Name:         "Rose",
		Status:       "active",
		UsageScopes:  []string{"gift"},
	}
	walletClient := &fakeWalletClient{
		resp:                  &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
		giftCatalogVersionErr: status.Error(codes.Unimplemented, "unknown method GetGiftCatalogVersion"),
		listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{
			TypeCode:  "normal",
			Name:      "普通礼物",
			TabKey:    "Gift",
			Status:    "active",
			SortOrder: 10,
		}}},
		listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
			GiftId:       "rose",
			ResourceId:   giftResource.GetResourceId(),
			Resource:     giftResource,
			Status:       "active",
			Name:         "Rose",
			GiftTypeCode: "normal",
			CoinPrice:    100,
			RegionIds:    []int64{0, 1001},
		}}, Total: 1},
	}
	queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: &roomv1.RoomSnapshot{
		RoomId:          "room-gift-version-fallback",
		OwnerUserId:     88,
		Status:          "active",
		VisibleRegionId: 1001,
	}}}
	handler := NewHandler(&fakeRoomClient{})
	handler.SetRoomQueryClient(queryClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	for attempt := 0; attempt < 2; attempt++ {
		request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-version-fallback/gift-panel", nil)
		request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
		recorder := httptest.NewRecorder()
		router.ServeHTTP(recorder, request)
		if recorder.Code != http.StatusOK {
			t.Fatalf("gift panel fallback status mismatch attempt=%d got=%d body=%s", attempt, recorder.Code, recorder.Body.String())
		}
	}
	if walletClient.getGiftCatalogCalls != 2 || walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 {
		t.Fatalf("unimplemented catalog version should disable config cache, versionCalls=%d typeCalls=%d giftCalls=%d", walletClient.getGiftCatalogCalls, walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls)
	}
	if len(walletClient.balanceRequests) != 2 || walletClient.listUserResourcesCalls != 2 {
		t.Fatalf("gift panel fallback realtime data must stay per-request, balanceCalls=%d bagCalls=%d", len(walletClient.balanceRequests), walletClient.listUserResourcesCalls)
	}
}

func TestRoomGiftPanelCacheSeparatesVisibleRegion(t *testing.T) {
	walletClient := &fakeWalletClient{
		resp:              &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 1000}}},
		listGiftTypesResp: &walletv1.ListGiftTypeConfigsResponse{GiftTypes: []*walletv1.GiftTypeConfig{{TypeCode: "normal", TabKey: "Gift", Status: "active"}}},
		listGiftConfigsResp: &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{{
			GiftId:       "rose",
			ResourceId:   11,
			Resource:     &walletv1.Resource{ResourceId: 11, ResourceCode: "gift_rose", ResourceType: "gift", Name: "Rose", Status: "active"},
			Status:       "active",
			Name:         "Rose",
			GiftTypeCode: "normal",
			CoinPrice:    100,
		}}, Total: 1},
	}
	snapshot := &roomv1.RoomSnapshot{
		RoomId:          "room-gift-region-cache",
		OwnerUserId:     88,
		Status:          "active",
		VisibleRegionId: 1001,
	}
	queryClient := &fakeRoomQueryClient{snapshotResp: &roomv1.GetRoomSnapshotResponse{Room: snapshot}}
	handler := NewHandler(&fakeRoomClient{})
	handler.SetRoomQueryClient(queryClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("first gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	snapshot.VisibleRegionId = 1002
	request = httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-gift-region-cache/gift-panel", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder = httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	if recorder.Code != http.StatusOK {
		t.Fatalf("second gift panel status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
	}
	if walletClient.listGiftTypesCalls != 2 || walletClient.listGiftConfigsCalls != 2 || walletClient.lastListGiftConfigs.GetRegionId() != 1002 {
		t.Fatalf("gift panel config cache must be separated by visible region, typeCalls=%d giftCalls=%d lastGift=%+v", walletClient.listGiftTypesCalls, walletClient.listGiftConfigsCalls, walletClient.lastListGiftConfigs)
	}
}

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 TestVoiceRoomRedPacketClaimRecordsIncludesUserProfiles(t *testing.T) {
	walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{
		ServerTimeMs: 3000,
		Packet: &walletv1.RedPacket{
			PacketId: "packet-1",
			Claims: []*walletv1.RedPacketClaim{
				{ClaimId: "claim-1", PacketId: "packet-1", UserId: 10002, Amount: 300, Status: "claimed", CreatedAtMs: 1000},
				{ClaimId: "claim-2", PacketId: "packet-1", UserId: 10003, Amount: 200, Status: "claimed", CreatedAtMs: 2000},
			},
		},
	}}
	profileClient := &fakeUserProfileClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/claim-records?packetNo=packet-1&page=1&pageSize=1", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-red-packet-claims")
	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.lastGetRedPacket == nil || !walletClient.lastGetRedPacket.GetIncludeClaims() || walletClient.lastGetRedPacket.GetPacketId() != "packet-1" {
		t.Fatalf("red packet detail request mismatch: %+v", walletClient.lastGetRedPacket)
	}
	if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
		t.Fatalf("claim records must batch only visible claim users: %+v", profileClient.lastBatch)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode claim records 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 {
		t.Fatalf("claim records response mismatch: %+v", response)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["account"] != "110002" || first["nickname"] != "user-10002" || first["avatar"] != "https://cdn.example/avatar.png" {
		t.Fatalf("claim record should include display profile fields: %+v", first)
	}
}

func TestVoiceRoomRedPacketDetailIncludesSenderProfile(t *testing.T) {
	walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{
		ServerTimeMs: 3000,
		Packet: &walletv1.RedPacket{
			PacketId:       "packet-1",
			RoomId:         "room-1",
			RegionId:       1001,
			SenderUserId:   10002,
			PacketType:     "normal",
			TotalAmount:    1000,
			PacketCount:    5,
			RemainingCount: 3,
			Status:         "active",
		},
	}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/detail?packetNo=packet-1", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-red-packet-detail")
	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() != 10002 {
		t.Fatalf("detail must fetch sender profile: %+v", profileClient.lastGet)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode red-packet detail failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	packet, packetOK := data["packet"].(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || !packetOK {
		t.Fatalf("detail response mismatch: %+v", response)
	}
	if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" {
		t.Fatalf("detail packet should include sender display fields: %+v", packet)
	}
}

func TestVoiceRoomRedPacketClaimIncludesSenderProfile(t *testing.T) {
	packetBeforeClaim := &walletv1.RedPacket{
		PacketId:       "packet-1",
		RoomId:         "room-1",
		RegionId:       1001,
		SenderUserId:   10002,
		PacketType:     "normal",
		TotalAmount:    1000,
		PacketCount:    5,
		RemainingCount: 3,
		Status:         "active",
	}
	walletClient := &fakeWalletClient{
		getRedPacketResp: &walletv1.GetRedPacketResponse{Packet: packetBeforeClaim},
		claimRedPacketResp: &walletv1.ClaimRedPacketResponse{
			ServerTimeMs: 4000,
			BalanceAfter: 700,
			Packet: &walletv1.RedPacket{
				PacketId:       "packet-1",
				RoomId:         "room-1",
				RegionId:       1001,
				SenderUserId:   10002,
				PacketType:     "normal",
				TotalAmount:    1000,
				PacketCount:    5,
				RemainingCount: 2,
				Status:         "active",
			},
			Claim: &walletv1.RedPacketClaim{ClaimId: "claim-1", PacketId: "packet-1", UserId: 42, Amount: 200, Status: "claimed", CreatedAtMs: 3500},
		},
	}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		42:    &userv1.User{UserId: 42, DisplayUserId: "880042", Username: "claimer-name", Avatar: "https://cdn.example/claimer.png", RegionId: 1001},
		10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001},
	}}
	guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}}
	handler := NewHandlerWithConfig(&fakeRoomClient{}, guardClient, nil, nil, TencentIMConfig{}, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/app/voice-room/red-packet/claim", bytes.NewReader([]byte(`{"packetNo":"packet-1"}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-red-packet-claim")
	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 guardClient.lastPresence == nil || guardClient.lastPresence.GetRoomId() != "room-1" || guardClient.lastPresence.GetUserId() != 42 {
		t.Fatalf("claim must verify room presence before wallet mutation: %+v", guardClient.lastPresence)
	}
	if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 10002 {
		t.Fatalf("claim must fetch claimer then sender profiles before response: %+v", profileClient.getRequests)
	}
	if walletClient.lastClaimRedPacket == nil || walletClient.lastClaimRedPacket.GetPacketId() != "packet-1" || walletClient.lastClaimRedPacket.GetUserId() != 42 {
		t.Fatalf("claim request mismatch: %+v", walletClient.lastClaimRedPacket)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode red-packet claim failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	packet, packetOK := data["packet"].(map[string]any)
	claim, claimOK := data["claim"].(map[string]any)
	if response.Code != httpkit.CodeOK || !ok || !packetOK || !claimOK {
		t.Fatalf("claim response mismatch: %+v", response)
	}
	if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" {
		t.Fatalf("claim packet should include sender display fields: %+v", packet)
	}
	if claim["account"] != "880042" || claim["nickname"] != "claimer-name" || claim["avatar"] != "https://cdn.example/claimer.png" {
		t.Fatalf("claim should keep claimant display fields: %+v", claim)
	}
}

func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
	walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{
		ServerTimeMs: 4000,
		Packets: []*walletv1.RedPacket{
			{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1},
			{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
			{PacketId: "packet-expired", RoomId: "room-1", RegionId: 1001, SenderUserId: 10004, PacketType: "normal", Status: "active", ExpiresAtMs: 4000, RemainingAmount: 100, RemainingCount: 1},
			{PacketId: "packet-empty", RoomId: "room-1", RegionId: 1001, SenderUserId: 10005, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 0, RemainingCount: 0},
			{PacketId: "packet-claimed", RoomId: "room-1", RegionId: 1001, SenderUserId: 10006, PacketType: "normal", Status: "active", ExpiresAtMs: 5000, RemainingAmount: 100, RemainingCount: 1, ClaimedByViewer: true},
		},
	}}
	profileClient := &fakeUserProfileClient{regionID: 1001}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/active?roomId=room-1", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-red-packet-active")
	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.lastListRedPackets == nil || walletClient.lastListRedPackets.GetRoomId() != "room-1" || walletClient.lastListRedPackets.GetViewerUserId() != 42 {
		t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
	}
	if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
		t.Fatalf("active red-packet should batch only currently claimable sender profiles: %+v", profileClient.lastBatch)
	}
	var response httpkit.ResponseEnvelope
	if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
		t.Fatalf("decode active red-packet response failed: %v", err)
	}
	data, ok := response.Data.(map[string]any)
	packets, packetsOK := data["packets"].([]any)
	if response.Code != httpkit.CodeOK || !ok || !packetsOK || len(packets) != 1 {
		t.Fatalf("active red-packet response mismatch: %+v", response)
	}
	first, ok := packets[0].(map[string]any)
	if !ok || first["packetNo"] != "packet-active" || first["status"] != "ACTIVE" || first["senderNickname"] != "user-10002" || first["senderAvatar"] != "https://cdn.example/avatar.png" {
		t.Fatalf("active packet item mismatch: %+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 TestGetMyGiftWallUsesAuthenticatedUserID(t *testing.T) {
	walletClient := &fakeWalletClient{giftWallResp: &walletv1.GetUserGiftWallResponse{
		GiftKindCount:  1,
		GiftTotalCount: 5,
		TotalValue:     50,
		TotalCoinValue: 50,
		Items: []*walletv1.GiftWallItem{{
			GiftId:           "rose",
			GiftName:         "Rose",
			ResourceId:       11,
			GiftTypeCode:     "normal",
			PresentationJson: "{}",
			GiftCount:        5,
			TotalValue:       50,
			TotalCoinValue:   50,
			ChargeAssetType:  "COIN",
			LastPriceVersion: "v1",
			Resource: &walletv1.Resource{
				ResourceId:   11,
				ResourceCode: "gift_rose",
				ResourceType: "gift",
				Name:         "Rose",
				AssetUrl:     "https://cdn.example/rose.png",
			},
			ResourceSnapshotJson: `{"ResourceID":11}`,
			LastReceivedAtMs:     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/users/me/gift-wall", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-gift-wall")
	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.lastGiftWall == nil || walletClient.lastGiftWall.GetUserId() != 42 ||
		walletClient.lastGiftWall.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-gift-wall") {
		t.Fatalf("gift wall request mismatch: %+v", walletClient.lastGiftWall)
	}
	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["gift_total_count"] != float64(5) || data["total_value"] != float64(50) {
		t.Fatalf("gift wall response mismatch: %+v", response)
	}
	item, ok := items[0].(map[string]any)
	resource, resourceOK := item["resource"].(map[string]any)
	if !ok || item["gift_id"] != "rose" || item["gift_count"] != float64(5) || !resourceOK || resource["asset_url"] == "" {
		t.Fatalf("gift wall item mismatch: %+v", item)
	}
}

func TestGetUserGiftWallUsesPathUserID(t *testing.T) {
	walletClient := &fakeWalletClient{giftWallResp: &walletv1.GetUserGiftWallResponse{
		GiftKindCount:  1,
		GiftTotalCount: 3,
		TotalValue:     300,
		TotalCoinValue: 300,
		Items: []*walletv1.GiftWallItem{{
			GiftId:         "castle",
			GiftName:       "Castle",
			GiftCount:      3,
			TotalValue:     300,
			TotalCoinValue: 300,
		}},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/10002/gift-wall", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-user-gift-wall")
	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.lastGiftWall == nil || walletClient.lastGiftWall.GetUserId() != 10002 ||
		walletClient.lastGiftWall.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-user-gift-wall") {
		t.Fatalf("profile gift wall request mismatch: %+v", walletClient.lastGiftWall)
	}
	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["gift_total_count"] != float64(3) || data["total_value"] != float64(300) {
		t.Fatalf("profile gift wall response mismatch: %+v", response)
	}
}

func TestGetUserGiftWallRejectsInvalidPathUserID(t *testing.T) {
	walletClient := &fakeWalletClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/not-a-number/gift-wall", 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 walletClient.lastGiftWall != nil {
		t.Fatalf("invalid user_id must not call wallet-service: %+v", walletClient.lastGiftWall)
	}
	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.CodeInvalidArgument {
		t.Fatalf("error response mismatch: %+v", response)
	}
}

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 TestListDiamondTransactionsPassesAuthenticatedUserAndTimeRange(t *testing.T) {
	walletClient := &fakeWalletClient{diamondTransactionsResp: &walletv1.ListWalletTransactionsResponse{
		Total: 1,
		Transactions: []*walletv1.WalletTransaction{{
			EntryId:            17,
			TransactionId:      "wtx-diamond-1",
			BizType:            "host_period_diamond_income",
			AssetType:          "HOST_PERIOD_DIAMOND",
			AvailableDelta:     8000,
			AvailableAfter:     28000,
			CounterpartyUserId: 99,
			CreatedAtMs:        1_785_600_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/diamond-transactions?page=2&page_size=30&start_at_ms=1785600000000&end_at_ms=1785686400000", 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())
	}
	got := walletClient.lastDiamondTransactions
	if got == nil || got.GetUserId() != 42 || got.GetPage() != 2 || got.GetPageSize() != 30 || got.GetStartAtMs() != 1_785_600_000_000 || got.GetEndAtMs() != 1_785_686_400_000 {
		t.Fatalf("diamond transaction request mismatch: %+v", got)
	}
}

func TestListWalletTransactionsRejectsIncompleteTimeRange(t *testing.T) {
	walletClient := &fakeWalletClient{}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/transactions?start_at_ms=1785600000000", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	if recorder.Code != http.StatusBadRequest || walletClient.lastTransactions != nil {
		t.Fatalf("incomplete time range must be rejected before RPC: status=%d request=%+v", recorder.Code, walletClient.lastTransactions)
	}
}

func TestListWalletTransactionsIncludesCounterpartyProfile(t *testing.T) {
	walletClient := &fakeWalletClient{transactionsResp: &walletv1.ListWalletTransactionsResponse{
		Total: 1,
		Transactions: []*walletv1.WalletTransaction{{
			EntryId:              9,
			TransactionId:        "wtx-seller-1",
			BizType:              "coin_seller_transfer",
			AssetType:            "COIN_SELLER_COIN",
			AvailableDelta:       -10_000,
			AvailableAfter:       1_011,
			CounterpartyUserId:   9001,
			CreatedAtMs:          1_780_000_000_000,
			TransferUsdMinor:     1250,
			TransferCurrencyCode: "USD",
		}},
	}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		9001: {
			UserId:        9001,
			DisplayUserId: "163001",
			Username:      "Alice",
			Avatar:        "https://cdn.example/alice.png",
			AppCode:       "lalu",
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/transactions?asset_type=COIN_SELLER_COIN&page_size=20", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-wallet-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.GetAssetType() != "COIN_SELLER_COIN" || walletClient.lastTransactions.GetPageSize() != 20 {
		t.Fatalf("wallet transaction request mismatch: %+v", walletClient.lastTransactions)
	}
	if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 9001 {
		t.Fatalf("counterparty profile batch mismatch: %+v", profileClient.lastBatch)
	}
	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 {
		t.Fatalf("wallet transaction response mismatch: %+v", response)
	}
	first, ok := items[0].(map[string]any)
	if !ok || first["counterparty_display_user_id"] != "163001" || first["counterparty_username"] != "Alice" || first["counterparty_avatar"] != "https://cdn.example/alice.png" || first["transfer_usd_minor"] != float64(1250) || first["transfer_currency_code"] != "USD" {
		t.Fatalf("counterparty profile fields mismatch: %+v", first)
	}
}

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 TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T) {
	walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{
		PaymentOrderId: "gpay_abc",
		TransactionId:  "wtx_abc",
		Status:         "credited",
		ProductId:      11,
		ProductCode:    "iap_android_11",
		CoinAmount:     1500,
		Balance: &walletv1.AssetBalance{
			AssetType:       "COIN",
			AvailableAmount: 2500,
		},
		ConsumeState: "consumed",
	}}
	profileClient := &fakeUserProfileClient{regionID: 2002}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := `{"command_id":"pay-cmd-1","product_id":11,"product_code":"iap_android_11","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}`
	request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("X-Request-ID", "req-google-confirm")
	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.lastGoogleConfirm == nil ||
		walletClient.lastGoogleConfirm.GetUserId() != 42 ||
		walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
		walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
		walletClient.lastGoogleConfirm.GetProductId() != 11 ||
		walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
		walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
		walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" ||
		walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-1" ||
		walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm") {
		t.Fatalf("google confirm request mismatch: %+v", walletClient.lastGoogleConfirm)
	}
	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["payment_order_id"] != "gpay_abc" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(1500) {
		t.Fatalf("google confirm response mismatch: %+v", response)
	}
}

func TestConfirmGooglePaymentAllowsGoogleProductCodeWithoutProductID(t *testing.T) {
	walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{
		PaymentOrderId: "gpay_first_recharge",
		TransactionId:  "wtx_first_recharge",
		Status:         "credited",
		ProductId:      11,
		ProductCode:    "first_recharge_google_999",
		CoinAmount:     90000,
		Balance: &walletv1.AssetBalance{
			AssetType:       "COIN",
			AvailableAmount: 90000,
		},
		ConsumeState: "consumed",
	}}
	profileClient := &fakeUserProfileClient{regionID: 2002}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
	handler.SetWalletClient(walletClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := `{"command_id":"pay-cmd-first-recharge","product_code":"first_recharge_google_999","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}`
	request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("Content-Type", "application/json")
	request.Header.Set("X-Request-ID", "req-google-confirm-first-recharge")
	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.lastGoogleConfirm == nil ||
		walletClient.lastGoogleConfirm.GetUserId() != 42 ||
		walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
		walletClient.lastGoogleConfirm.GetCountryId() != 840 ||
		walletClient.lastGoogleConfirm.GetProductId() != 0 ||
		walletClient.lastGoogleConfirm.GetProductCode() != "first_recharge_google_999" ||
		walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
		walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" ||
		walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-first-recharge" ||
		walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm-first-recharge") {
		t.Fatalf("google first recharge confirm request mismatch: %+v", walletClient.lastGoogleConfirm)
	}
	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["payment_order_id"] != "gpay_first_recharge" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(90000) {
		t.Fatalf("google first recharge confirm response mismatch: %+v", response)
	}
}

func TestH5RechargeOptionsUsesTokenCountryAndCoinSellerAudience(t *testing.T) {
	walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
		Products: []*walletv1.RechargeProduct{{
			ProductId:    91001,
			ProductName:  "Seller 10 USD",
			AmountMicro:  10_000_000,
			CoinAmount:   880000,
			Platform:     "web",
			AudienceType: "coin_seller",
			Enabled:      true,
		}},
		PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
			MethodId:          810,
			ProviderCode:      "mifapay",
			ProviderName:      "MiFaPay",
			CountryCode:       "SA",
			CountryName:       "Saudi Arabia",
			CurrencyCode:      "SAR",
			PayWay:            "Card",
			PayType:           "MADA",
			MethodName:        "MADA",
			Status:            "active",
			UsdToCurrencyRate: "1.00000000",
			SortOrder:         810,
		}},
		UsdtTrc20Enabled: true,
		UsdtTrc20Address: "TPlatformReceiveAddress",
	}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{42: {
		UserId:             42,
		DisplayUserId:      "163042",
		Username:           "seller42",
		Status:             userv1.UserStatus_USER_STATUS_ACTIVE,
		Country:            "SA",
		CountryDisplayName: "Saudi Arabia",
		RegionId:           7100,
		RegionCode:         "mena-sa",
		RegionName:         "Saudi Arabia",
	}}}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"should-ignore": 900001}}
	hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsCoinSeller: true, CoinSellerStatus: "active"}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	// H5 带 token 时 display_user_id 只能作为无效噪声；gateway 必须用 token 用户重新查国家和币商角色。
	request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=should-ignore", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-h5-seller-options")
	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 identityClient.lastResolve != nil {
		t.Fatalf("token h5 options must not resolve display id: %+v", identityClient.lastResolve)
	}
	if walletClient.lastH5Options == nil ||
		walletClient.lastH5Options.GetTargetUserId() != 42 ||
		walletClient.lastH5Options.GetTargetRegionId() != 7100 ||
		walletClient.lastH5Options.GetTargetCountryCode() != "SA" ||
		walletClient.lastH5Options.GetAudienceType() != "coin_seller" {
		t.Fatalf("h5 options wallet request mismatch: %+v", walletClient.lastH5Options)
	}
	if hostClient.lastRoleSummary == nil || hostClient.lastRoleSummary.GetUserId() != 42 {
		t.Fatalf("h5 options role summary request mismatch: %+v", hostClient.lastRoleSummary)
	}
	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("h5 options response mismatch: %+v", response)
	}
	account := data["account"].(map[string]any)
	methods := data["payment_methods"].([]any)
	if account["audience_type"] != "coin_seller" || account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["pay_type"] != "MADA" {
		t.Fatalf("h5 options response data mismatch: %+v", data)
	}
}

func TestH5RechargeContextRejectsMissingIdentityWithoutToken(t *testing.T) {
	walletClient := &fakeWalletClient{}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: {
		UserId:        66,
		DisplayUserId: "163066",
		Username:      "normal66",
		Status:        userv1.UserStatus_USER_STATUS_ACTIVE,
		Country:       "PH",
		RegionId:      7200,
	}}}
	hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/context", nil)
	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 identityClient.lastResolve != nil {
		t.Fatalf("missing h5 identity must be rejected before user-service lookup: %+v", identityClient.lastResolve)
	}
	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.CodeInvalidArgument || response.Message != "invalid argument" {
		t.Fatalf("missing h5 identity response mismatch: %+v", response)
	}
}

func TestH5RechargeOptionsNormalizesSaudiCountryNameToPaymentCountryCode(t *testing.T) {
	walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
		PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
			MethodId:          810,
			ProviderCode:      "mifapay",
			ProviderName:      "MiFaPay",
			CountryCode:       "SA",
			CountryName:       "Saudi Arabia",
			CurrencyCode:      "SAR",
			PayWay:            "Card",
			PayType:           "MADA",
			MethodName:        "MADA",
			Status:            "active",
			UsdToCurrencyRate: "3.75000000",
		}},
	}}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"sa-name": 77}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{77: {
		UserId:             77,
		DisplayUserId:      "sa-name",
		Username:           "sa_name_user",
		Status:             userv1.UserStatus_USER_STATUS_ACTIVE,
		Country:            "Saudi Arabia",
		CountryDisplayName: "沙特阿拉伯",
		RegionId:           7100,
	}}}
	hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 77}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	// 历史用户资料可能把国家写成英文名或中文展示名；H5 支付链路必须先归一为 SA，再让 wallet-service 按国家返回 MiFaPay 方式。
	request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=sa-name", nil)
	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.lastH5Options == nil || walletClient.lastH5Options.GetTargetCountryCode() != "SA" {
		t.Fatalf("h5 options should normalize country name to SA: %+v", walletClient.lastH5Options)
	}
	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("h5 options response mismatch: %+v", response)
	}
	account := data["account"].(map[string]any)
	methods := data["payment_methods"].([]any)
	if account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["country_code"] != "SA" {
		t.Fatalf("normalized h5 options response data mismatch: %+v", data)
	}
}

func TestH5RechargeOptionsResolvesDisplayUserCountryWithoutToken(t *testing.T) {
	walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
		PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
			MethodId:          1310,
			ProviderCode:      "mifapay",
			ProviderName:      "MiFaPay",
			CountryCode:       "PH",
			CountryName:       "Philippines",
			CurrencyCode:      "PHP",
			PayWay:            "Ewallet",
			PayType:           "Gcash",
			MethodName:        "GCash",
			Status:            "active",
			UsdToCurrencyRate: "1.00000000",
		}},
	}}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163066": 66}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{66: {
		UserId:             66,
		DisplayUserId:      "163066",
		Username:           "normal66",
		Status:             userv1.UserStatus_USER_STATUS_ACTIVE,
		Country:            "PH",
		CountryDisplayName: "Philippines",
		RegionId:           7200,
	}}}
	hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 66}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=163066", nil)
	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 identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163066" {
		t.Fatalf("display user id was not resolved: %+v", identityClient.lastResolve)
	}
	if walletClient.lastH5Options == nil ||
		walletClient.lastH5Options.GetTargetUserId() != 66 ||
		walletClient.lastH5Options.GetTargetRegionId() != 7200 ||
		walletClient.lastH5Options.GetTargetCountryCode() != "PH" ||
		walletClient.lastH5Options.GetAudienceType() != "normal" {
		t.Fatalf("display h5 options wallet request mismatch: %+v", walletClient.lastH5Options)
	}
}

func TestH5RechargeOptionsFallsBackToNumericUserIDWhenDisplayIDIsHeld(t *testing.T) {
	walletClient := &fakeWalletClient{h5OptionsResp: &walletv1.H5RechargeOptionsResponse{
		PaymentMethods: []*walletv1.ThirdPartyPaymentMethod{{
			MethodId:          1810,
			ProviderCode:      "mifapay",
			ProviderName:      "MiFaPay",
			CountryCode:       "SA",
			CountryName:       "Saudi Arabia",
			CurrencyCode:      "SAR",
			PayWay:            "Card",
			PayType:           "MADA",
			MethodName:        "MADA",
			Status:            "active",
			UsdToCurrencyRate: "1.00000000",
		}},
	}}
	identityClient := &fakeUserIdentityClient{resolveErr: xerr.New(xerr.DisplayUserIDNotFound, "not found")}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{123456: {
		UserId:             123456,
		DisplayUserId:      "1111cc1111",
		Username:           "test_123456",
		Status:             userv1.UserStatus_USER_STATUS_ACTIVE,
		Country:            "SA",
		CountryDisplayName: "Saudi Arabia",
		RegionId:           686,
	}}}
	hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 123456}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	// 123456 在用户换靓号后可能不再是 active display_user_id，但 H5 的无 token 入口文案是输入用户 ID；
	// display 解析 NOT_FOUND 时，纯数字输入必须回退到真实 user_id，才能按该用户国家返回沙特支付方式。
	request := httptest.NewRequest(http.MethodGet, "/api/v1/recharge/h5/options?display_user_id=123456", nil)
	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 identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "123456" {
		t.Fatalf("display user id was not tried first: %+v", identityClient.lastResolve)
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 123456 {
		t.Fatalf("numeric user id fallback did not load profile: %+v", profileClient.lastGet)
	}
	if walletClient.lastH5Options == nil ||
		walletClient.lastH5Options.GetTargetUserId() != 123456 ||
		walletClient.lastH5Options.GetTargetRegionId() != 686 ||
		walletClient.lastH5Options.GetTargetCountryCode() != "SA" ||
		walletClient.lastH5Options.GetAudienceType() != "normal" {
		t.Fatalf("numeric h5 options wallet request mismatch: %+v", walletClient.lastH5Options)
	}
	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("h5 options response mismatch: %+v", response)
	}
	account := data["account"].(map[string]any)
	methods := data["payment_methods"].([]any)
	if account["display_user_id"] != "1111cc1111" || account["country_code"] != "SA" || len(methods) != 1 || methods[0].(map[string]any)["pay_type"] != "MADA" {
		t.Fatalf("numeric h5 options response data mismatch: %+v", data)
	}
}

func TestH5RechargeCreateOrderUsesProfileCountryForNormalUser(t *testing.T) {
	walletClient := &fakeWalletClient{h5CreateOrderResp: &walletv1.H5RechargeOrderResponse{Order: &walletv1.ExternalRechargeOrder{
		OrderId:             "h5-order-eg",
		TargetUserId:        55,
		TargetRegionId:      7300,
		TargetCountryCode:   "EG",
		AudienceType:        "normal",
		ProductId:           93001,
		ProductName:         "Normal 1.5 USD",
		CoinAmount:          120000,
		UsdMinorAmount:      150,
		ProviderCode:        "mifapay",
		PaymentMethodId:     1210,
		CountryCode:         "EG",
		CurrencyCode:        "EGP",
		ProviderAmountMinor: 150,
		PayWay:              "Ewallet",
		PayType:             "FawryPay",
		PayUrl:              "https://pay.example/h5-order-eg",
		Status:              "pending",
	}}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{55: {
		UserId:             55,
		DisplayUserId:      "163055",
		Username:           "normal55",
		Status:             userv1.UserStatus_USER_STATUS_ACTIVE,
		CountryId:          818,
		Country:            "EG",
		CountryDisplayName: "Egypt",
		RegionId:           7300,
	}}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(&fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 55}})
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"commandId":"cmd-h5-create-eg","productId":93001,"paymentMethodId":1210,"returnUrl":"https://h5.example/return"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/recharge/h5/orders", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 55))
	request.Header.Set("X-Forwarded-For", "203.0.113.9")
	request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6")
	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.lastH5CreateOrder == nil ||
		walletClient.lastH5CreateOrder.GetCommandId() != "cmd-h5-create-eg" ||
		walletClient.lastH5CreateOrder.GetTargetUserId() != 55 ||
		walletClient.lastH5CreateOrder.GetTargetRegionId() != 7300 ||
		walletClient.lastH5CreateOrder.GetTargetCountryId() != 818 ||
		walletClient.lastH5CreateOrder.GetTargetCountryCode() != "EG" ||
		walletClient.lastH5CreateOrder.GetAudienceType() != "normal" ||
		walletClient.lastH5CreateOrder.GetProductId() != 93001 ||
		walletClient.lastH5CreateOrder.GetProviderCode() != "mifapay" ||
		walletClient.lastH5CreateOrder.GetPaymentMethodId() != 1210 ||
		walletClient.lastH5CreateOrder.GetReturnUrl() != "https://h5.example/return" ||
		walletClient.lastH5CreateOrder.GetLanguage() != "en" ||
		walletClient.lastH5CreateOrder.GetPayerName() != "normal55" ||
		walletClient.lastH5CreateOrder.GetPayerAccount() != "163055" ||
		walletClient.lastH5CreateOrder.GetPayerEmail() != "163055@lalu.com" {
		t.Fatalf("h5 create order wallet request mismatch: %+v", walletClient.lastH5CreateOrder)
	}
	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("h5 create order response mismatch: %+v", response)
	}
	order := data["order"].(map[string]any)
	if order["order_id"] != "h5-order-eg" || order["country_code"] != "EG" || order["pay_type"] != "FawryPay" || order["pay_url"] != "https://pay.example/h5-order-eg" {
		t.Fatalf("h5 create order response data mismatch: %+v", data)
	}
}

func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
	walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
		TransactionId:                "wtx-coin-seller",
		SellerBalanceAfter:           80000,
		TargetBalanceAfter:           80000,
		Amount:                       80000,
		RechargeUsdMinor:             0,
		RechargeCurrencyCode:         "",
		RechargePolicyId:             0,
		RechargePolicyVersion:        "",
		RechargePolicyCoinAmount:     0,
		RechargePolicyUsdMinorAmount: 0,
	}}
	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(0) || data["recharge_policy_id"] != float64(0) {
		t.Fatalf("coin seller transfer response mismatch: %+v", response)
	}
}

func TestCoinSellerSubSellerPermissionUsesCoinSellerProfile(t *testing.T) {
	hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{
		UserId:                  42,
		Status:                  "active",
		MerchantAssetType:       "COIN_SELLER_COIN",
		CanManageSubCoinSellers: true,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, &fakeUserProfileClient{})
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-seller/sub-sellers/permission", nil)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-sub-seller-permission")
	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 permission profile 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["visible"] != true ||
		data["can_invite_sub_seller"] != true ||
		data["can_transfer_sub_seller"] != true ||
		data["canInviteSubSeller"] != true ||
		data["canTransferSubSeller"] != true {
		t.Fatalf("sub seller permission response mismatch: %+v", response)
	}
}

func TestCoinSellerSubSellerTransferChecksRelationBeforeWallet(t *testing.T) {
	walletClient := &fakeWalletClient{subTransferResp: &walletv1.TransferCoinSellerStockToChildResponse{
		TransactionId:      "wtx-sub-seller-transfer",
		ParentBalanceAfter: 7000,
		ChildBalanceAfter:  3000,
		Amount:             1200,
		CreatedAtMs:        1_783_000_000_000,
	}}
	hostClient := &fakeUserHostClient{checkSubSellerResp: &userv1.CheckCoinSellerSubRelationResponse{
		Allowed: true,
		Child: &userv1.CoinSellerListItem{
			UserId:            900002,
			DisplayUserId:     "163002",
			Username:          "sub seller",
			RegionId:          1001,
			Status:            "active",
			MerchantAssetType: "COIN_SELLER_COIN",
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, &fakeUserProfileClient{})
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"command_id":"cmd-sub-seller-transfer","child_user_id":"900002","amount":1200,"reason":"stock refill"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/sub-sellers/transfer", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-sub-seller-transfer")
	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.lastCheckSubSeller == nil ||
		hostClient.lastCheckSubSeller.GetParentUserId() != 42 ||
		hostClient.lastCheckSubSeller.GetChildUserId() != 900002 {
		t.Fatalf("sub seller relation check mismatch: %+v", hostClient.lastCheckSubSeller)
	}
	if walletClient.lastSubTransfer == nil ||
		walletClient.lastSubTransfer.GetCommandId() != "cmd-sub-seller-transfer" ||
		walletClient.lastSubTransfer.GetParentUserId() != 42 ||
		walletClient.lastSubTransfer.GetChildUserId() != 900002 ||
		walletClient.lastSubTransfer.GetRegionId() != 1001 ||
		walletClient.lastSubTransfer.GetAmount() != 1200 ||
		walletClient.lastSubTransfer.GetReason() != "stock refill" ||
		walletClient.lastSubTransfer.GetAppCode() != "lalu" {
		t.Fatalf("sub seller wallet transfer request mismatch: %+v", walletClient.lastSubTransfer)
	}
	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-sub-seller-transfer" ||
		data["parent_balance_after"] != float64(7000) ||
		data["child_balance_after"] != float64(3000) ||
		data["amount"] != float64(1200) ||
		data["created_at_ms"] != float64(1_783_000_000_000) {
		t.Fatalf("sub seller transfer response mismatch: %+v", response)
	}
}

func TestDirectGiftUsesServerSideRegionAndHostScope(t *testing.T) {
	imPublisher := &fakeTencentIMUserMessagePublisher{}
	imImporter := &fakeTencentIMAccountImporter{}
	walletClient := &fakeWalletClient{
		directGiftResp: &walletv1.DebitGiftResponse{
			BillingReceiptId:       "receipt-direct-gift",
			TransactionId:          "wtx-direct-gift",
			CoinSpent:              200,
			ChargeAssetType:        "COIN",
			ChargeAmount:           200,
			GiftTypeCode:           "normal",
			GiftName:               "Rose",
			GiftIconUrl:            "https://cdn.example/gifts/rose.png",
			GiftAnimationUrl:       "https://cdn.example/gifts/rose.mp4",
			GiftEffectTypes:        []string{"mp4"},
			PriceVersion:           "v1",
			BalanceAfter:           800,
			GiftIncomeCoinAmount:   60,
			GiftIncomeBalanceAfter: 460,
			HostPeriodCycleKey:     "2026-06",
		},
	}
	profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 2002}}
	hostClient := &fakeUserHostClient{hostProfile: &userv1.HostProfile{
		UserId:                   900002,
		Status:                   "active",
		RegionId:                 8801,
		CurrentAgencyOwnerUserId: 30001,
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	handler.SetTencentIMAccountImporter(imImporter)
	handler.SetTencentIMUserMessagePublisher(imPublisher)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"commandId":"cmd-direct-gift","targetUserId":900002,"giftId":"rose","giftCount":2,"region_id":9999}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/direct-gifts/send", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-direct-gift")
	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.lastDirectGift == nil ||
		walletClient.lastDirectGift.GetCommandId() != "cmd-direct-gift" ||
		walletClient.lastDirectGift.GetSenderUserId() != 42 ||
		walletClient.lastDirectGift.GetTargetUserId() != 900002 ||
		walletClient.lastDirectGift.GetGiftId() != "rose" ||
		walletClient.lastDirectGift.GetGiftCount() != 2 ||
		walletClient.lastDirectGift.GetRegionId() != 1001 ||
		walletClient.lastDirectGift.GetSenderRegionId() != 1001 ||
		!walletClient.lastDirectGift.GetTargetIsHost() ||
		walletClient.lastDirectGift.GetTargetHostRegionId() != 8801 ||
		walletClient.lastDirectGift.GetTargetAgencyOwnerUserId() != 30001 {
		t.Fatalf("direct gift wallet request mismatch: %+v", walletClient.lastDirectGift)
	}
	if len(walletClient.directGiftRequests) != 1 {
		t.Fatalf("direct gift must execute one authoritative wallet debit: %+v", walletClient.directGiftRequests)
	}
	if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 900002 {
		t.Fatalf("direct gift must resolve sender and target through user-service: %+v", profileClient.getRequests)
	}
	if hostClient.lastHost == nil || hostClient.lastHost.GetUserId() != 900002 {
		t.Fatalf("direct gift target host scope was not resolved: %+v", hostClient.lastHost)
	}
	if len(imImporter.userIDs) != 2 || imImporter.userIDs[0] != 42 || imImporter.userIDs[1] != 900002 {
		t.Fatalf("direct gift first execution must ensure both IM accounts before debit: %+v", imImporter.userIDs)
	}
	if len(imPublisher.messages) != 1 {
		t.Fatalf("direct gift must publish exactly one C2C message: %+v", imPublisher.messages)
	}
	imMessage := imPublisher.messages[0]
	if imMessage.ToAccount != "900002" ||
		imMessage.FromAccount != "42" ||
		imMessage.SyncOtherMachine != 1 ||
		imMessage.EventID != "direct_gift:lalu:cmd-direct-gift" ||
		imMessage.Desc != "gift" ||
		imMessage.Ext != "direct_message_gift" ||
		!imMessage.DeduplicateByEventID {
		t.Fatalf("direct gift C2C message mismatch: %+v", imMessage)
	}
	var imPayload map[string]any
	if err := json.Unmarshal(imMessage.PayloadJSON, &imPayload); err != nil {
		t.Fatalf("decode direct gift im payload failed: %v", err)
	}
	if imPayload["type"] != "gift" ||
		imPayload["event_id"] != "direct_gift:lalu:cmd-direct-gift" ||
		imPayload["billing_receipt_id"] != "receipt-direct-gift" ||
		imPayload["gift_income_coin_amount"] != float64(60) ||
		imPayload["gift_name"] != "Rose" ||
		imPayload["gift_count"] != float64(2) {
		t.Fatalf("direct gift im payload mismatch: %+v", imPayload)
	}
	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)
	message, messageOK := data["message"].(map[string]any)
	if response.Code != httpkit.CodeOK || !ok ||
		data["billing_receipt_id"] != "receipt-direct-gift" ||
		data["event_id"] != "direct_gift:lalu:cmd-direct-gift" ||
		data["coin_balance_after"] != float64(800) ||
		data["gift_income_coin_amount"] != float64(60) ||
		data["gift_income_balance_after"] != float64(460) ||
		data["gift_name"] != "Rose" ||
		!messageOK ||
		message["custom_description"] != "gift" ||
		message["custom_extension"] != "direct_message_gift" ||
		message["message_id"] != "direct_gift:lalu:cmd-direct-gift" {
		t.Fatalf("direct gift response mismatch: %+v", response)
	}
}

func TestDirectGiftFailsClosedWithoutHostAuthority(t *testing.T) {
	walletClient := &fakeWalletClient{}
	profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 1001, 900002: 2002}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, &fakeUserIdentityClient{}, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetTencentIMUserMessagePublisher(&fakeTencentIMUserMessagePublisher{})
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/messages/direct-gifts/send", bytes.NewReader([]byte(`{"command_id":"cmd-direct-no-host-owner","target_user_id":900002,"gift_id":"rose","gift_count":1}`)))
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	request.Header.Set("X-Request-ID", "req-direct-no-host-owner")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-direct-no-host-owner")
	if walletClient.lastDirectGift != nil {
		t.Fatalf("missing Host authority reached debit: %+v", walletClient.lastDirectGift)
	}
}

func TestCoinSellerTransferAllowsSelfTargetAndSkipsDuplicateRegionLookup(t *testing.T) {
	walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
		TransactionId:      "wtx-coin-seller-self",
		SellerBalanceAfter: 1010,
		TargetBalanceAfter: 1,
		Amount:             1,
	}}
	hostClient := &fakeUserHostClient{profile: &userv1.CoinSellerProfile{
		UserId:            42,
		Status:            "active",
		MerchantAssetType: "COIN_SELLER_COIN",
	}}
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163000": 42}}
	profileClient := &fakeUserProfileClient{regionByUserID: map[int64]int64{42: 688}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	handler.SetWalletClient(walletClient)
	handler.SetUserHostClient(hostClient)
	router := handler.Routes(auth.NewVerifier("secret"))
	body := []byte(`{"command_id":"cmd-seller-self","target_display_user_id":"163000","amount":1,"reason":"self recharge"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/coin-seller/transfer", bytes.NewReader(body))
	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 walletClient.lastTransfer == nil ||
		walletClient.lastTransfer.GetSellerUserId() != 42 ||
		walletClient.lastTransfer.GetTargetUserId() != 42 ||
		walletClient.lastTransfer.GetSellerRegionId() != 688 ||
		walletClient.lastTransfer.GetTargetRegionId() != 688 ||
		walletClient.lastTransfer.GetAmount() != 1 {
		t.Fatalf("self wallet transfer request mismatch: %+v", walletClient.lastTransfer)
	}
	if len(profileClient.getRequests) != 1 || profileClient.getRequests[0].GetUserId() != 42 {
		t.Fatalf("self transfer should only lookup seller profile once: %+v", profileClient.getRequests)
	}
}

func TestResolveDisplayUserIDIncludesProfileForH5Lookup(t *testing.T) {
	identityClient := &fakeUserIdentityClient{resolveByDisplay: map[string]int64{"163000": 42}}
	profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
		42: {
			UserId:        42,
			DisplayUserId: "163000",
			Username:      "xcxcxcxc",
			Avatar:        "https://cdn.example/avatar.png",
			RegionId:      688,
			Status:        userv1.UserStatus_USER_STATUS_ACTIVE,
		},
	}}
	handler := NewHandlerWithClients(&fakeRoomClient{}, nil, identityClient, profileClient)
	if handler.userProfileClient == nil {
		t.Fatal("profile client was not attached to gateway handler")
	}
	router := handler.Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodGet, "/api/v1/users/by-display-user-id/163000", nil)
	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 identityClient.lastResolve == nil || identityClient.lastResolve.GetDisplayUserId() != "163000" {
		t.Fatalf("resolve request mismatch: %+v", identityClient.lastResolve)
	}
	if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
		t.Fatalf("profile lookup mismatch: %+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 := response.Data.(map[string]any)
	profile := data["profile"].(map[string]any)
	identity := data["identity"].(map[string]any)
	if data["display_user_id"] != "163000" || identity["display_user_id"] != "163000" || profile["username"] != "xcxcxcxc" || profile["avatar"] != "https://cdn.example/avatar.png" {
		t.Fatalf("resolve display user response mismatch: %+v", data)
	}
}

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)

	assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "country change is cooling down", "req-country-cooldown")

	profileClient = &fakeUserProfileClient{countryErr: xerr.ToGRPCError(xerr.New(
		xerr.CountryChangeIdentityLocked,
		"You already have a host identity. Please contact customer service to change your country.",
	))}
	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-identity-locked")
	recorder = httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelopeMessage(
		t,
		recorder,
		http.StatusForbidden,
		string(xerr.CountryChangeIdentityLocked),
		"You already have a host identity. Please contact customer service to change your country.",
		"req-country-identity-locked",
	)
}

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 || token["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
		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 TestCompleteOnboardingReturnsInvalidInviteCodeMessage(t *testing.T) {
	profileClient := &fakeUserProfileClient{
		completeErr: xerr.ToGRPCError(xerr.New(xerr.InvalidInviteCode, "invalid invite code")),
	}
	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":"BADCODE"}`)
	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-invalid-invite")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelopeMessage(t, recorder, http.StatusBadRequest, string(xerr.InvalidInviteCode), "invalid invite code", "req-onboarding-invalid-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: "contact_info", path: "/api/v1/users/me/contact-info", body: `{"contact_info":"+65"}`},
		{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: "room_contribution_rank", method: http.MethodGet, path: "/api/v1/rooms/room-1/contribution-rank?period=day"},
		{name: "mic_heartbeat", method: http.MethodPost, path: "/api/v1/rooms/mic/heartbeat", body: `{"room_id":"room-1","mic_session_id":"mic-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: "direct_gift", method: http.MethodPost, path: "/api/v1/messages/direct-gifts/send", body: `{"command_id":"cmd-direct-gift","target_user_id":900002,"gift_id":"rose","gift_count":1}`},
		{name: "profiles_batch", method: http.MethodGet, path: "/api/v1/users/profiles:batch?user_ids=42"},
		{name: "voice_room_presence_batch", method: http.MethodGet, path: "/api/v1/users/voice-room-presence:batch?user_ids=43"},
	}

	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 TestUploadAvatarUsesDetectedTypeAndAllowsIncompleteProfile(t *testing.T) {
	uploader := &fakeObjectUploader{}
	handler := NewHandler(&fakeRoomClient{})
	handler.SetObjectUploader(uploader)
	router := handler.Routes(auth.NewVerifier("secret"))
	payload := append([]byte{'R', 'I', 'F', 'F', 0x24, 0, 0, 0, 'W', 'E', 'B', 'P', 'V', 'P', '8', ' '}, bytes.Repeat([]byte{1}, 32)...)
	// Android 相册缓存可能把 WebP/GIF 标成 JPEG；上传对象必须以文件头识别结果命名。
	body, contentType := multipartUploadBody(t, "file", "avatar.jpg", "image/jpeg", 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, ".webp") {
		t.Fatalf("unexpected avatar object key: %q", uploader.lastKey)
	}
	if string(uploader.lastContent) != string(payload) || uploader.lastSizeBytes != int64(len(payload)) || uploader.lastContentType != "image/webp" {
		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/webp" {
		t.Fatalf("unexpected upload data: %+v", data)
	}
}

func TestUploadRegistrationAvatarAllowsPublicPendingAuthUpload(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, "avatar", "avatar.png", "image/png", payload)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/files/registration/avatar/upload", body)
	request.Header.Set("Content-Type", contentType)
	request.Header.Set("X-Request-ID", "req-upload-register-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/registration/") || !strings.HasSuffix(uploader.lastKey, ".png") {
		t.Fatalf("unexpected registration avatar object key: %q", uploader.lastKey)
	}
	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 TestThirdPartyLoginMapsDeviceAlreadyRegisteredMessage(t *testing.T) {
	userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.DeviceAlreadyRegistered, "you device already register 2 account 6400,168430, login failed"))}
	router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"android_91d831f9fa382518","platform":"android"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
	request.Header.Set("X-Request-ID", "req-device-registered")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelopeMessage(t, recorder, http.StatusConflict, string(xerr.DeviceAlreadyRegistered), "you device already register 2 account 6400,168430, login failed", "req-device-registered")
}

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 != "request cannot be completed in current state" {
		t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message)
	}
}

func TestMicUpReturnsExplicitRoomMutedMessage(t *testing.T) {
	// Flutter 只消费 gateway envelope；这里锁定跨 gRPC 边界后的公开 code/message，避免再次退化成通用 permission denied。
	client := &fakeRoomClient{
		micUpErr: xerr.ToGRPCError(xerr.New(xerr.RoomUserMuted, "user is muted")),
	}
	router := NewHandler(client).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(
		http.MethodPost,
		"/api/v1/rooms/mic/up",
		bytes.NewReader([]byte(`{"room_id":"lalu_room-muted","command_id":"mic-up-muted","seat_no":2}`)),
	)
	request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelopeMessage(
		t,
		recorder,
		http.StatusForbidden,
		string(xerr.RoomUserMuted),
		"You have been muted and cannot join the mic.",
		"",
	)
}

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 TestRoutesWriteAccessTokenExpiredOnlyForValidExpiredJWT(t *testing.T) {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id":  "42",
		"sid":      "sess-expired",
		"app_code": "lalu",
		"exp":      time.Now().Add(-time.Minute).Unix(),
	})
	signed, err := token.SignedString([]byte("secret"))
	if err != nil {
		t.Fatalf("sign expired gateway token failed: %v", err)
	}
	router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret"))
	request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`)))
	request.Header.Set("Authorization", "Bearer "+signed)
	request.Header.Set("X-Request-ID", "req-expired-access-token")
	recorder := httptest.NewRecorder()

	router.ServeHTTP(recorder, request)

	assertEnvelope(t, recorder, http.StatusUnauthorized, httpkit.CodeAccessTokenExpired, "req-expired-access-token")
}

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","room_avatar":"https://cdn.example.com/room.png"}`)))
	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","room_avatar":"https://cdn.example.com/room.png"}`)))
	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 TestRoomBackgroundRoutesForwardOwnerRequests(t *testing.T) {
	client := &fakeRoomClient{}
	queryClient := &fakeRoomQueryClient{backgroundsResp: &roomv1.ListRoomBackgroundsResponse{
		Backgrounds: []*roomv1.RoomBackgroundImage{{
			BackgroundId:    101,
			RoomId:          "room-1",
			ImageUrl:        "https://cdn.example.com/bg.png",
			CreatedByUserId: 42,
			CreatedAtMs:     1_700_000_000_003,
			UpdatedAtMs:     1_700_000_000_003,
		}},
		SelectedBackgroundUrl: "https://cdn.example.com/bg.png",
		ServerTimeMs:          1_700_000_000_004,
	}}
	handler := NewHandler(client)
	handler.SetRoomQueryClient(queryClient)
	router := handler.Routes(auth.NewVerifier("secret"))

	saveReq := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/backgrounds/save", bytes.NewReader([]byte(`{"room_id":"room-1","image_url":" https://cdn.example.com/bg.png "}`)))
	saveReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	saveReq.Header.Set("X-Request-ID", "req-bg-save")
	saveRecorder := httptest.NewRecorder()
	router.ServeHTTP(saveRecorder, saveReq)
	if saveRecorder.Code != http.StatusOK {
		t.Fatalf("save status mismatch: got %d body=%s", saveRecorder.Code, saveRecorder.Body.String())
	}
	if client.lastSaveBackground == nil || client.lastSaveBackground.GetRoomId() != "room-1" || client.lastSaveBackground.GetImageUrl() != "https://cdn.example.com/bg.png" || client.lastSaveBackground.GetMeta().GetActorUserId() != 42 {
		t.Fatalf("SaveRoomBackground forwarding mismatch: %+v", client.lastSaveBackground)
	}

	listReq := httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-1/backgrounds", nil)
	listReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	listReq.Header.Set("X-Request-ID", "req-bg-list")
	listRecorder := httptest.NewRecorder()
	router.ServeHTTP(listRecorder, listReq)
	if listRecorder.Code != http.StatusOK {
		t.Fatalf("list status mismatch: got %d body=%s", listRecorder.Code, listRecorder.Body.String())
	}
	if queryClient.lastBackgrounds == nil || queryClient.lastBackgrounds.GetRoomId() != "room-1" || queryClient.lastBackgrounds.GetViewerUserId() != 42 {
		t.Fatalf("ListRoomBackgrounds forwarding mismatch: %+v", queryClient.lastBackgrounds)
	}

	setReq := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/backgrounds/set", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-bg-set","background_id":"101"}`)))
	setReq.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	setReq.Header.Set("X-Request-ID", "req-bg-set")
	setRecorder := httptest.NewRecorder()
	router.ServeHTTP(setRecorder, setReq)
	if setRecorder.Code != http.StatusOK {
		t.Fatalf("set status mismatch: got %d body=%s", setRecorder.Code, setRecorder.Body.String())
	}
	if client.lastSetBackground == nil || client.lastSetBackground.GetMeta().GetRoomId() != "room-1" || client.lastSetBackground.GetMeta().GetCommandId() != "cmd-bg-set" || client.lastSetBackground.GetBackgroundId() != 101 {
		t.Fatalf("SetRoomBackground forwarding mismatch: %+v", client.lastSetBackground)
	}

	var envelope httpkit.ResponseEnvelope
	if err := json.NewDecoder(setRecorder.Body).Decode(&envelope); err != nil {
		t.Fatalf("decode set response failed: %v", err)
	}
	data, ok := envelope.Data.(map[string]any)
	if !ok {
		t.Fatalf("set response data mismatch: %+v", envelope.Data)
	}
	room, ok := data["room"].(map[string]any)
	if !ok || room["background_url"] != "https://cdn.example.com/bg.png" {
		t.Fatalf("set response must expose selected background_url: %+v", data["room"])
	}
}

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","room_avatar":"https://cdn.example.com/room.png"}`},
		{name: "mode", body: `{"room_id":"room-1","command_id":"cmd-create-missing-mode","seat_count":10,"room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`},
		{name: "room_name", body: `{"room_id":"room-1","command_id":"cmd-create-missing-name","seat_count":10,"mode":"voice","room_avatar":"https://cdn.example.com/room.png"}`},
		{name: "room_avatar", body: `{"room_id":"room-1","command_id":"cmd-create-missing-avatar","seat_count":10,"mode":"voice","room_name":"Lobby"}`},
		{name: "negative_seat_count", body: `{"room_id":"room-1","command_id":"cmd-create-negative-seat","seat_count":-1,"mode":"voice","room_name":"Lobby","room_avatar":"https://cdn.example.com/room.png"}`},
	}

	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","room_avatar":"https://cdn.example.com/room.png"}`)
	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","room_avatar":"https://cdn.example.com/room.png"}`)
	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 := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).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_v2_1001" || regionGroup["type"] != "region_broadcast" {
		t.Fatalf("region join group mismatch: %+v", groups[1])
	}

	prefixedRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
		SDKAppID:      1400000000,
		SecretKey:     "secret",
		UserSigTTL:    time.Hour,
		GroupIDPrefix: "test_",
	}, profileClient).Routes(auth.NewVerifier("secret"))
	prefixedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
	prefixedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	prefixedRecorder := httptest.NewRecorder()
	prefixedRouter.ServeHTTP(prefixedRecorder, prefixedRequest)
	if prefixedRecorder.Code != http.StatusOK {
		t.Fatalf("prefixed status mismatch: got %d body=%s", prefixedRecorder.Code, prefixedRecorder.Body.String())
	}
	var prefixedResponse httpkit.ResponseEnvelope
	if err := json.NewDecoder(prefixedRecorder.Body).Decode(&prefixedResponse); err != nil {
		t.Fatalf("decode prefixed response failed: %v", err)
	}
	prefixedData, ok := prefixedResponse.Data.(map[string]any)
	if !ok {
		t.Fatalf("prefixed response data mismatch: %+v", prefixedResponse)
	}
	prefixedGroups, ok := prefixedData["join_groups"].([]any)
	if !ok || len(prefixedGroups) != 2 {
		t.Fatalf("prefixed join_groups mismatch: %+v", prefixedData["join_groups"])
	}
	prefixedRegionGroup, ok := prefixedGroups[1].(map[string]any)
	if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_v2_1001" {
		t.Fatalf("prefixed region join group mismatch: %+v", prefixedGroups[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 TestTencentIMUserSigEnsuresImportedAccount(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	importer := &fakeTencentIMAccountImporter{}
	handler := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
		SDKAppID:   1400000000,
		SecretKey:  "secret",
		UserSigTTL: time.Hour,
	}, profileClient)
	handler.SetTencentIMAccountImporter(importer)
	router := handler.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-import")
	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 importer.lastUserID != 42 || importer.lastNickname != "hy" || importer.lastFaceURL != "https://cdn.example/avatar.png" {
		t.Fatalf("unexpected imported account: %+v", importer)
	}

	importer.err = errors.New("tencent unavailable")
	failedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
	failedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
	failedRequest.Header.Set("X-Request-ID", "req-usersig-import-failed")
	failedRecorder := httptest.NewRecorder()
	router.ServeHTTP(failedRecorder, failedRequest)

	assertEnvelope(t, failedRecorder, http.StatusInternalServerError, httpkit.CodeInternalError, "req-usersig-import-failed")
}

func TestTencentRTCTokenUsesPresenceGuardAndInternalUserID(t *testing.T) {
	previousNow := roomapi.TimeNow
	roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
	defer func() { roomapi.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{}
	guard := &fakeRoomGuardClient{roomAppResp: &roomv1.ResolveRoomAppCodeResponse{AppCode: "fami"}}
	handler := NewHandlerWithConfig(roomClient, guard, 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() != "fami" {
		t.Fatalf("RTC event meta mismatch: %+v", meta)
	}
	if guard.lastRoomApp == nil || guard.lastRoomApp.GetRoomId() != "room_1001" {
		t.Fatalf("RTC callback must resolve app from room owner: %+v", guard.lastRoomApp)
	}
	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.lastRoomApp == nil || guard.lastRoomApp.GetRoomId() != "room-1" {
		t.Fatalf("room ownership request mismatch: %+v", guard.lastRoomApp)
	}
	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)
	}
	if guard.lastPresence.GetAppCode() != "lalu" {
		t.Fatalf("presence guard must use resolved app_code, got %+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 TestTencentIMSendCallbackNeverEntersRoomGuard(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, 0)
	if guard.lastRoomApp != nil {
		t.Fatalf("before-send callback must not resolve room ownership: %+v", guard.lastRoomApp)
	}
	if guard.lastSpeak != nil {
		t.Fatalf("before-send callback must not enter room speak guard: %+v", guard.lastSpeak)
	}
}

func TestTencentIMRoomCallbacksPassthroughGroupsOutsideHyAppOwnership(t *testing.T) {
	guard := &fakeRoomGuardClient{roomAppErr: status.Error(codes.NotFound, "room not found")}
	router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
		SDKAppID: 1400000000,
	}).Routes(auth.NewVerifier("secret"))

	sendBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"other-project-room","From_Account":"external-user"}`)
	sendRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&OptPlatform=RESTAPI", bytes.NewReader(sendBody))
	sendRecorder := httptest.NewRecorder()
	router.ServeHTTP(sendRecorder, sendRequest)
	assertTencentCallback(t, sendRecorder, http.StatusOK, 0)
	if guard.lastSpeak != nil {
		t.Fatalf("external room send must not reach HyApp speak guard: %+v", guard.lastSpeak)
	}

	joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"@TGS#external","Requestor_Account":"external-user"}`)
	joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup&OptPlatform=Android", bytes.NewReader(joinBody))
	joinRecorder := httptest.NewRecorder()
	router.ServeHTTP(joinRecorder, joinRequest)
	assertTencentCallback(t, joinRecorder, http.StatusOK, 0)
	if guard.lastPresence != nil {
		t.Fatalf("external group join must not reach HyApp presence guard: %+v", guard.lastPresence)
	}
}

func TestTencentIMRoomCallbacksFailClosedWhenOwnershipLookupFails(t *testing.T) {
	guard := &fakeRoomGuardClient{roomAppErr: status.Error(codes.Unavailable, "room service unavailable")}
	router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
		SDKAppID: 1400000000,
	}).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", bytes.NewReader(body))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)

	assertTencentCallback(t, recorder, http.StatusOK, 1)
	if guard.lastPresence != nil {
		t.Fatalf("ownership lookup failure must stop before the presence guard: %+v", guard.lastPresence)
	}
}

func TestTencentIMRoomSendCallbackPassthroughDoesNotResolveOwnership(t *testing.T) {
	guard := &fakeRoomGuardClient{roomAppResp: &roomv1.ResolveRoomAppCodeResponse{AppCode: "fami"}}
	router := NewHandlerWithConfig(&fakeRoomClient{}, guard, nil, nil, TencentIMConfig{
		SDKAppID:        1400000000,
		AdminIdentifier: "administrator",
	}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"CallbackCommand":"Group.CallbackBeforeSendMsg","GroupId":"room-1","From_Account":"administrator"}`)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeSendMsg&OptPlatform=RESTAPI", bytes.NewReader(body))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)

	assertTencentCallback(t, recorder, http.StatusOK, 0)
	if guard.lastRoomApp != nil {
		t.Fatalf("before-send passthrough must not resolve room ownership: %+v", guard.lastRoomApp)
	}
	if guard.lastSpeak != nil {
		t.Fatalf("before-send passthrough must not evaluate room speech: %+v", guard.lastSpeak)
	}
}

func TestTencentIMBroadcastCallbacksUseRegionGuardAndSendPassthrough(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, 0)

	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 TestTencentIMBroadcastCallbacksUseConfiguredGroupPrefix(t *testing.T) {
	profileClient := &fakeUserProfileClient{regionID: 1001}
	router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
		SDKAppID:        1400000000,
		AdminIdentifier: "administrator",
		GroupIDPrefix:   "test_",
	}, profileClient).Routes(auth.NewVerifier("secret"))

	joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"test_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)

	oldGroupBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
	oldGroupRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(oldGroupBody))
	oldGroupRecorder := httptest.NewRecorder()
	router.ServeHTTP(oldGroupRecorder, oldGroupRequest)
	// 配置 test_ 代表当前回调只拥有 test_ 命名空间；无前缀群可能属于共享 SDKAppID 的其它环境，必须透传。
	assertTencentCallback(t, oldGroupRecorder, 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 TestTencentIMCallbackAcceptsSignedAuthAndRejectsExpiredRequest(t *testing.T) {
	const callbackToken = "callback-token"
	router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
		SDKAppID:          1400000000,
		CallbackAuthToken: callbackToken,
	}).Routes(auth.NewVerifier("secret"))
	body := []byte(`{"CallbackCommand":"Group.CallbackAfterSendMsg","GroupId":"room-1","From_Account":"42"}`)

	requestTime := strconv.FormatInt(time.Now().Unix(), 10)
	request := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&RequestTime="+requestTime+"&Sign="+tencentIMCallbackSignature(callbackToken, requestTime), bytes.NewReader(body))
	recorder := httptest.NewRecorder()
	router.ServeHTTP(recorder, request)
	assertTencentCallback(t, recorder, http.StatusOK, 0)

	expiredTime := strconv.FormatInt(time.Now().Add(-2*time.Minute).Unix(), 10)
	expiredRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackAfterSendMsg&RequestTime="+expiredTime+"&Sign="+tencentIMCallbackSignature(callbackToken, expiredTime), bytes.NewReader(body))
	expiredRecorder := httptest.NewRecorder()
	router.ServeHTTP(expiredRecorder, expiredRequest)
	assertTencentCallback(t, expiredRecorder, http.StatusOK, 1)
}

func tencentIMCallbackSignature(token string, requestTime string) string {
	digest := sha256.Sum256([]byte(token + requestTime))
	return fmt.Sprintf("%x", digest)
}

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 mustAppEventsBody(t *testing.T, count int) []byte {
	t.Helper()
	events := make([]map[string]any, 0, count)
	for i := 0; i < count; i++ {
		events = append(events, map[string]any{
			"event_id":   "evt-" + strconv.Itoa(i),
			"event_name": "open",
			"device_id":  "dev-1",
		})
	}
	payload, err := json.Marshal(map[string]any{"events": events})
	if err != nil {
		t.Fatalf("marshal app events body failed: %v", err)
	}
	return payload
}

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 struct {
		ActionStatus string `json:"ActionStatus"`
		ErrorInfo    string `json:"ErrorInfo"`
		ErrorCode    int    `json:"ErrorCode"`
	}
	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 struct {
		Code    int    `json:"code"`
		Message string `json:"message,omitempty"`
	}
	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 TestEquipMyResourceUsesRuntimeCommandIDPolicy(t *testing.T) {
	for _, testCase := range []struct {
		name        string
		capability  *appconfig.RuntimeCapabilities
		body        string
		wantStatus  int
		wantCommand string
	}{
		{
			name:       "required rejects missing command id",
			capability: &appconfig.RuntimeCapabilities{ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal, ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyRequired},
			body:       `{}`,
			wantStatus: http.StatusBadRequest,
		},
		{
			name:        "missing config keeps legacy optional default",
			body:        `{}`,
			wantStatus:  http.StatusOK,
			wantCommand: "legacy_equip:",
		},
		{
			name:        "required accepts client command id",
			capability:  &appconfig.RuntimeCapabilities{ActivityLeaderboardScope: appconfig.ActivityLeaderboardScopeAppGlobal, ResourceEquipCommandIDPolicy: appconfig.ResourceEquipCommandIDPolicyRequired},
			body:        `{"command_id":"equip-command-1"}`,
			wantStatus:  http.StatusOK,
			wantCommand: "equip-command-1",
		},
	} {
		t.Run(testCase.name, func(t *testing.T) {
			walletClient := &fakeWalletClient{}
			handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
			handler.SetWalletClient(walletClient)
			capabilities := map[string]appconfig.RuntimeCapabilities{}
			if testCase.capability != nil {
				capabilities["lalu"] = *testCase.capability
			}
			handler.SetAppConfigReader(appconfig.StaticReader{RuntimeCapabilitiesByApp: capabilities})
			router := handler.Routes(auth.NewVerifier("secret"))
			request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/resources/501/equip", strings.NewReader(testCase.body))
			request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
			request.Header.Set("Content-Type", "application/json")
			recorder := httptest.NewRecorder()

			router.ServeHTTP(recorder, request)

			if recorder.Code != testCase.wantStatus {
				t.Fatalf("status mismatch: got=%d want=%d body=%s", recorder.Code, testCase.wantStatus, recorder.Body.String())
			}
			if testCase.wantStatus != http.StatusOK {
				if walletClient.lastEquipUserResource != nil {
					t.Fatalf("rejected request must not call wallet: %+v", walletClient.lastEquipUserResource)
				}
				return
			}
			if walletClient.lastEquipUserResource == nil {
				t.Fatal("successful request did not call wallet")
			}
			if testCase.wantCommand == "legacy_equip:" {
				if !strings.HasPrefix(walletClient.lastEquipUserResource.GetCommandId(), testCase.wantCommand) {
					t.Fatalf("legacy command id mismatch: %+v", walletClient.lastEquipUserResource)
				}
			} else if walletClient.lastEquipUserResource.GetCommandId() != testCase.wantCommand {
				t.Fatalf("command id mismatch: %+v", walletClient.lastEquipUserResource)
			}
		})
	}
}

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

	return signGatewayTokenWithExpiresAt(t, secret, userID, profileCompleted, time.Now().Add(time.Hour))
}

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

	return signGatewayTokenWithAppCodeAndExpiresAt(t, secret, userID, appCode, true, time.Now().Add(time.Hour))
}

func signGatewayTokenWithExpiresAt(t *testing.T, secret string, userID int64, profileCompleted bool, expiresAt time.Time) string {
	t.Helper()

	return signGatewayTokenWithAppCodeAndExpiresAt(t, secret, userID, "lalu", profileCompleted, expiresAt)
}

func signGatewayTokenWithAppCodeAndExpiresAt(t *testing.T, secret string, userID int64, appCode string, profileCompleted bool, expiresAt time.Time) string {
	t.Helper()

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"user_id":           strconv.FormatInt(userID, 10),
		"app_code":          appCode,
		"sid":               "sess-test",
		"profile_completed": profileCompleted,
		"onboarding_status": map[bool]string{true: "completed", false: "profile_required"}[profileCompleted],
		"exp":               expiresAt.Unix(),
	})
	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()
}
