package gameapi

import (
	"bytes"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"

	gamev1 "hyapp.local/api/proto/game/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/logx"
	"hyapp/pkg/xerr"
	"hyapp/services/gateway-service/internal/auth"
	"hyapp/services/gateway-service/internal/transport/http/httpkit"
)

const (
	diceAccessTokenRenewBefore = 10 * time.Minute
	gameProviderTokenTTL       = 24 * time.Hour
	hyappAccessTokenHeader     = "X-Hyapp-Access-Token"
	hyappTokenTypeHeader       = "X-Hyapp-Token-Type"
	hyappExpiresInHeader       = "X-Hyapp-Expires-In"
	selfGameIDDice             = "dice"
	selfGameIDRock             = "rock"
	zgameCallbackPlatformCode  = "zgame"
)

func (h *Handler) listGames(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	user := userResp.GetUser()
	if user == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	if user.GetContentBlacklisted() {
		// 内容黑名单只隐藏游戏目录，不调用 game-service，也不改变账号、房间或钱包状态。
		httpkit.WriteOK(writer, request, gameListData{Games: []gameItemData{}})
		return
	}
	resp, err := h.gameClient.ListGames(request.Context(), &gamev1.ListGamesRequest{
		Meta:           gameMeta(request),
		UserId:         userID,
		Scene:          strings.TrimSpace(request.URL.Query().Get("scene")),
		RoomId:         strings.TrimSpace(request.URL.Query().Get("room_id")),
		RegionId:       user.GetRegionId(),
		Language:       requestLanguage(request),
		ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
	})
	httpkit.Write(writer, request, gameListDataFromProto(resp), err)
}

func (h *Handler) listRecentGames(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	pageSize, ok := recentGamePageSize(request)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	user := userResp.GetUser()
	if user == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	if user.GetContentBlacklisted() {
		// 最近常玩与完整目录使用同一 owner 判定，避免从第二个 tab 绕过内容屏蔽。
		httpkit.WriteOK(writer, request, gameListData{Games: []gameItemData{}})
		return
	}
	// 最近常玩仍传 scene、room、region、language 和 platform，让 game-service 在真实启动记录之外，
	// 继续按当前房间场景和用户区域过滤，避免 App 展示用户已不能进入的游戏。
	resp, err := h.gameClient.ListRecentGames(request.Context(), &gamev1.ListRecentGamesRequest{
		Meta:           gameMeta(request),
		UserId:         userID,
		Scene:          strings.TrimSpace(request.URL.Query().Get("scene")),
		RoomId:         strings.TrimSpace(request.URL.Query().Get("room_id")),
		RegionId:       user.GetRegionId(),
		Language:       requestLanguage(request),
		ClientPlatform: httpkit.FirstHeader(request, "X-App-Platform", "X-Platform"),
		PageSize:       pageSize,
	})
	httpkit.Write(writer, request, gameListDataFromProto(resp), err)
}

func (h *Handler) listExploreWinners(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	pageSize, ok := exploreWinnerPageSize(request)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	// Square 播报只依赖当前 App，不接受客户端传 app_code；game-service 会保证首批机器人获胜事实已落库。
	resp, err := h.gameClient.ListExploreWinners(request.Context(), &gamev1.ListExploreWinnersRequest{
		Meta:     gameMeta(request),
		PageSize: pageSize,
	})
	data := exploreWinnerListDataFromProto(resp)
	// 获胜事实归 game-service，头像昵称归 user-service；资料补全失败时仍返回游戏事实，Flutter 用 user_id 和本地图做兜底。
	h.enrichExploreWinners(request, data.Items)
	httpkit.Write(writer, request, data, err)
}

func (h *Handler) getBridgeScript(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	// 桥接脚本配置是 App 级启动预热信息，不依赖房间和用户区域；保持接口轻量，避免拖慢进 App 主流程。
	resp, err := h.gameClient.GetBridgeScript(request.Context(), &gamev1.GetBridgeScriptRequest{
		Meta: gameMeta(request),
	})
	httpkit.Write(writer, request, gameBridgeScriptDataFromProto(resp), err)
}

func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	gameID := strings.TrimSpace(request.PathValue("game_id"))
	if gameID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	var body struct {
		Scene  string `json:"scene"`
		RoomID string `json:"room_id"`
	}
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	// 厂商令牌由已经完成 access-token 鉴权的 gateway 签发；它只带回调归因需要的最小 claims，
	// game-service 会按 adapter 决定是否使用，避免 AMG 因完整 App JWT 超过 512 字节而拒绝启动。
	providerToken, err := h.jwtVerifier.IssueGameProviderToken(appcode.FromContext(request.Context()), userID, gameProviderTokenTTL)
	if err != nil {
		httpkit.WriteError(writer, request, http.StatusInternalServerError, httpkit.CodeInternalError, "internal server error")
		return
	}
	resp, err := h.gameClient.LaunchGame(request.Context(), &gamev1.LaunchGameRequest{
		Meta:          gameMeta(request),
		UserId:        userID,
		DisplayUserId: userResp.GetUser().GetDisplayUserId(),
		GameId:        gameID,
		Scene:         body.Scene,
		RoomId:        body.RoomID,
		AccessToken:   bearerAccessToken(request),
		CountryId:     userResp.GetUser().GetCountryId(),
		ProviderToken: providerToken,
	})
	httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err)
}

func (h *Handler) getDiceConfig(writer http.ResponseWriter, request *http.Request) {
	h.getSelfGameConfig(writer, request, selfGameIDDice, false)
}

func (h *Handler) getRPSConfig(writer http.ResponseWriter, request *http.Request) {
	h.getSelfGameConfig(writer, request, selfGameIDRock, true)
}

func (h *Handler) getSelfGameConfig(writer http.ResponseWriter, request *http.Request, defaultGameID string, fixedGameID bool) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	gameID, ok := selfGameIDFromInput(writer, request, request.URL.Query().Get("game_id"), defaultGameID, fixedGameID)
	if !ok {
		return
	}
	// 配置接口只返回当前 App 的可下注档位和费率，不接受客户端传 app_code；rps 路径固定映射 rock，避免误读 room-rps 或 dice。
	resp, err := h.gameClient.GetDiceConfig(request.Context(), &gamev1.GetDiceConfigRequest{
		Meta:   gameMeta(request),
		GameId: gameID,
	})
	if err == nil {
		h.renewDiceAccessTokenIfNeeded(writer, request)
	}
	httpkit.Write(writer, request, diceConfigDataFromProto(resp), err)
}

func (h *Handler) matchDice(writer http.ResponseWriter, request *http.Request) {
	h.matchSelfGame(writer, request, selfGameIDDice, false)
}

func (h *Handler) matchRPS(writer http.ResponseWriter, request *http.Request) {
	h.matchSelfGame(writer, request, selfGameIDRock, true)
}

func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Request, defaultGameID string, fixedGameID bool) {
	if h.gameClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	var body struct {
		GameID     string `json:"game_id"`
		RoomID     string `json:"room_id"`
		StakeCoin  int64  `json:"stake_coin"`
		Gesture    string `json:"gesture"`
		RPSGesture string `json:"rps_gesture"`
	}
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	gameID, ok := selfGameIDFromInput(writer, request, body.GameID, defaultGameID, fixedGameID)
	if !ok {
		return
	}
	if body.StakeCoin <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	rpsGesture, ok := selfGameRPSGestureFromInput(writer, request, gameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true)
	if !ok {
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	resp, err := h.gameClient.MatchDice(request.Context(), &gamev1.MatchDiceRequest{
		Meta:       gameMeta(request),
		UserId:     userID,
		GameId:     gameID,
		RoomId:     strings.TrimSpace(body.RoomID),
		RegionId:   userResp.GetUser().GetRegionId(),
		CountryId:  userResp.GetUser().GetCountryId(),
		StakeCoin:  body.StakeCoin,
		RpsGesture: rpsGesture,
	})
	h.writeDiceMatch(writer, request, resp, err)
}

func (h *Handler) createDiceMatch(writer http.ResponseWriter, request *http.Request) {
	h.createSelfGameMatch(writer, request, selfGameIDDice, false)
}

func (h *Handler) createRPSMatch(writer http.ResponseWriter, request *http.Request) {
	h.createSelfGameMatch(writer, request, selfGameIDRock, true)
}

func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.Request, defaultGameID string, fixedGameID bool) {
	if h.gameClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	// gateway 只接受 H5 的展示输入；user_id、region_id、app_code 和 request_id 都由网关上下文补齐。
	var body struct {
		GameID     string `json:"game_id"`
		RoomID     string `json:"room_id"`
		StakeCoin  int64  `json:"stake_coin"`
		MinPlayers int32  `json:"min_players"`
		MaxPlayers int32  `json:"max_players"`
		Gesture    string `json:"gesture"`
		RPSGesture string `json:"rps_gesture"`
	}
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	gameID, ok := selfGameIDFromInput(writer, request, body.GameID, defaultGameID, fixedGameID)
	if !ok {
		return
	}
	if body.StakeCoin <= 0 {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	rpsGesture, ok := selfGameRPSGestureFromInput(writer, request, gameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true)
	if !ok {
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	// region_id 来自 user-service 当前用户资料，不信任客户端 body，避免跨区域创建可结算局。
	resp, err := h.gameClient.CreateDiceMatch(request.Context(), &gamev1.CreateDiceMatchRequest{
		Meta:       gameMeta(request),
		UserId:     userID,
		GameId:     gameID,
		RoomId:     strings.TrimSpace(body.RoomID),
		RegionId:   userResp.GetUser().GetRegionId(),
		CountryId:  userResp.GetUser().GetCountryId(),
		StakeCoin:  body.StakeCoin,
		MinPlayers: body.MinPlayers,
		MaxPlayers: body.MaxPlayers,
		RpsGesture: rpsGesture,
	})
	h.writeDiceMatch(writer, request, resp, err)
}

func (h *Handler) joinDiceMatch(writer http.ResponseWriter, request *http.Request) {
	h.joinSelfGameMatch(writer, request, selfGameIDDice)
}

func (h *Handler) joinRPSMatch(writer http.ResponseWriter, request *http.Request) {
	h.joinSelfGameMatch(writer, request, selfGameIDRock)
}

func (h *Handler) joinSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	matchID := strings.TrimSpace(request.PathValue("match_id"))
	if matchID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	rpsGesture := ""
	if expectedGameID == selfGameIDRock {
		var body struct {
			Gesture    string `json:"gesture"`
			RPSGesture string `json:"rps_gesture"`
		}
		if !httpkit.Decode(writer, request, &body) {
			return
		}
		var ok bool
		rpsGesture, ok = selfGameRPSGestureFromInput(writer, request, expectedGameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), true)
		if !ok {
			return
		}
	}
	// join 不接受 body 里的 user_id 或 stake_coin；参与者身份来自 JWT，押注金额来自 match 当前事实。
	resp, err := h.gameClient.JoinDiceMatch(request.Context(), &gamev1.JoinDiceMatchRequest{
		Meta:    gameMeta(request),
		UserId:  auth.UserIDFromContext(request.Context()),
		MatchId: matchID,
		// rps 入口复用 self-game match 表，但必须把路径期望的 game_id 传给 game-service 复核，避免拿 dice match_id 串到 rock 流程。
		GameId:     expectedGameID,
		RpsGesture: rpsGesture,
	})
	h.writeDiceMatch(writer, request, resp, err)
}

func (h *Handler) getDiceMatch(writer http.ResponseWriter, request *http.Request) {
	h.getSelfGameMatch(writer, request, selfGameIDDice)
}

func (h *Handler) getRPSMatch(writer http.ResponseWriter, request *http.Request) {
	h.getSelfGameMatch(writer, request, selfGameIDRock)
}

func (h *Handler) getSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	matchID := strings.TrimSpace(request.PathValue("match_id"))
	if matchID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	// 查询接口只是透传当前局事实，前端轮询或 IM 事件后的刷新都可以复用这条路径。
	resp, err := h.gameClient.GetDiceMatch(request.Context(), &gamev1.GetDiceMatchRequest{
		Meta:    gameMeta(request),
		UserId:  auth.UserIDFromContext(request.Context()),
		MatchId: matchID,
		// 查询同样带上路径 game_id，防止 rps 页面仅凭 match_id 读到 dice 局详情并继续轮询。
		GameId: expectedGameID,
	})
	h.writeDiceMatch(writer, request, resp, err)
}

func (h *Handler) rollDiceMatch(writer http.ResponseWriter, request *http.Request) {
	h.rollSelfGameMatch(writer, request, selfGameIDDice)
}

func (h *Handler) rollRPSMatch(writer http.ResponseWriter, request *http.Request) {
	h.rollSelfGameMatch(writer, request, selfGameIDRock)
}

func (h *Handler) rollSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	matchID := strings.TrimSpace(request.PathValue("match_id"))
	if matchID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	rpsGesture := ""
	if expectedGameID == selfGameIDRock {
		var body struct {
			Gesture    string `json:"gesture"`
			RPSGesture string `json:"rps_gesture"`
		}
		if !decodeOptionalJSONBody(writer, request, &body) {
			return
		}
		var ok bool
		rpsGesture, ok = selfGameRPSGestureFromInput(writer, request, expectedGameID, firstRPSGestureInput(body.Gesture, body.RPSGesture), false)
		if !ok {
			return
		}
	}
	// roll 不接收骰子点数、结果或派奖金额；这些全部由 game-service 服务端随机和结算。
	resp, err := h.gameClient.RollDiceMatch(request.Context(), &gamev1.RollDiceMatchRequest{
		Meta:    gameMeta(request),
		UserId:  auth.UserIDFromContext(request.Context()),
		MatchId: matchID,
		// roll 是资金入口，必须在 game-service 抢结算锁前验证当前路径只能操作自己的自研游戏局。
		GameId:     expectedGameID,
		RpsGesture: rpsGesture,
	})
	h.writeDiceMatch(writer, request, resp, err)
}

func (h *Handler) cancelDiceMatch(writer http.ResponseWriter, request *http.Request) {
	h.cancelSelfGameMatch(writer, request, selfGameIDDice)
}

func (h *Handler) cancelRPSMatch(writer http.ResponseWriter, request *http.Request) {
	h.cancelSelfGameMatch(writer, request, selfGameIDRock)
}

func (h *Handler) cancelSelfGameMatch(writer http.ResponseWriter, request *http.Request, expectedGameID string) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	matchID := strings.TrimSpace(request.PathValue("match_id"))
	if matchID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	resp, err := h.gameClient.CancelDiceMatch(request.Context(), &gamev1.CancelDiceMatchRequest{
		Meta:    gameMeta(request),
		UserId:  auth.UserIDFromContext(request.Context()),
		MatchId: matchID,
		// 取消等待局也要校验 game_id，否则用户可以从 rps 路径取消自己创建的 dice 等待局。
		GameId: expectedGameID,
	})
	h.writeDiceMatch(writer, request, resp, err)
}

func (h *Handler) getRoomRPSConfig(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	// 配置查询只取当前请求上下文里的 app_code；App 不能上传 app_code，否则同一客户端可以越权读取其它马甲包的礼物档位。
	// gateway 不缓存配置，避免后台刚改完礼物档位或开关后，房间入口还拿到旧的下注金额和倒计时。
	resp, err := h.gameClient.GetRoomRPSConfig(request.Context(), &gamev1.GetRoomRPSConfigRequest{
		Meta: gameMeta(request),
	})
	httpkit.Write(writer, request, roomRPSConfigDataFromProto(resp), err)
}

func (h *Handler) listRoomRPSChallenges(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	roomID := strings.TrimSpace(request.URL.Query().Get("room_id"))
	if roomID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	// room_id 必须来自 query，表示当前房间的公开挑战列表；用户身份只来自 JWT，不能让前端传 user_id 旁路查看其它人的私有状态。
	// page_size 在 gateway 层先做正数保护，防止空值、负数或非数字继续透传到 game-service 造成分页语义不一致。
	// status 只是筛选条件，最终哪些状态可见、游标如何解释、过期挑战是否需要补偿收敛，都必须由 game-service 按订单事实判断。
	resp, err := h.gameClient.ListRoomRPSChallenges(request.Context(), &gamev1.ListRoomRPSChallengesRequest{
		Meta:     gameMeta(request),
		UserId:   auth.UserIDFromContext(request.Context()),
		RoomId:   roomID,
		Status:   strings.TrimSpace(request.URL.Query().Get("status")),
		PageSize: pageSize,
		Cursor:   strings.TrimSpace(request.URL.Query().Get("cursor")),
	})
	data := roomRPSChallengeListDataFromProto(resp)
	// 挑战事实来自 game-service，头像昵称来自 user-service；资料补全失败时仍返回订单，保证 IM 丢失后的补拉不被展示层依赖拖垮。
	h.enrichRoomRPSChallenges(request, data.Challenges)
	httpkit.Write(writer, request, data, err)
}

func (h *Handler) createRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil || h.userProfileClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	// 发起挑战只接受房间、礼物档位和本人手势；发起人 user_id、app_code、region_id 都由服务端上下文补齐。
	// 这样做可以避免客户端伪造他人发起挑战，也能让后续钱包托管、风控和房间语区统计使用同一份可信身份。
	var body struct {
		RoomID  string          `json:"room_id"`
		GiftID  json.RawMessage `json:"gift_id"`
		Gesture string          `json:"gesture"`
	}
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	gesture, ok := normalizeRoomRPSGesture(body.Gesture)
	giftID := roomRPSGiftIDFromRaw(body.GiftID)
	if strings.TrimSpace(body.RoomID) == "" || giftID == "" || !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	userID := auth.UserIDFromContext(request.Context())
	// 当前用户资料是 region_id 的可信来源；即使客户端已经在房间里，仍不能让客户端自己上传 region_id 参与订单归属。
	userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
		Meta:   httpkit.UserMeta(request, ""),
		UserId: userID,
	})
	if err != nil {
		httpkit.WriteRPCError(writer, request, err)
		return
	}
	// gateway 只做协议适配和可信上下文拼装；礼物档位是否启用、金币托管是否成功、重复 command 如何收敛，全部在 game-service 事务内完成。
	resp, err := h.gameClient.CreateRoomRPSChallenge(request.Context(), &gamev1.CreateRoomRPSChallengeRequest{
		Meta:       gameMeta(request),
		UserId:     userID,
		RoomId:     strings.TrimSpace(body.RoomID),
		RegionId:   userResp.GetUser().GetRegionId(),
		GiftId:     legacyRoomRPSGiftID(giftID),
		GiftIdText: giftID,
		Gesture:    gesture,
	})
	h.writeRoomRPSChallenge(writer, request, resp, err)
}

func roomRPSGiftIDFromRaw(raw json.RawMessage) string {
	raw = json.RawMessage(bytes.TrimSpace(raw))
	if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
		return ""
	}
	var text string
	if err := json.Unmarshal(raw, &text); err == nil {
		return strings.TrimSpace(text)
	}
	var number json.Number
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.UseNumber()
	if err := decoder.Decode(&number); err == nil {
		return strings.TrimSpace(number.String())
	}
	return ""
}

func legacyRoomRPSGiftID(giftID string) int64 {
	value, err := strconv.ParseInt(strings.TrimSpace(giftID), 10, 64)
	if err != nil || value <= 0 {
		return 0
	}
	return value
}

func (h *Handler) acceptRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	// 应战入口只需要 challenge_id 和本人手势；房间、押注礼物、发起人和超时时间都从订单事实读取，不能相信客户端 body。
	challengeID := strings.TrimSpace(request.PathValue("challenge_id"))
	var body struct {
		Gesture string `json:"gesture"`
	}
	if !httpkit.Decode(writer, request, &body) {
		return
	}
	gesture, ok := normalizeRoomRPSGesture(body.Gesture)
	if challengeID == "" || !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	// game-service 必须在一个事务里判断 pending、未超时、未被本人重复应战、钱包托管成功；gateway 不做影子状态判断，避免并发窗口不一致。
	resp, err := h.gameClient.AcceptRoomRPSChallenge(request.Context(), &gamev1.AcceptRoomRPSChallengeRequest{
		Meta:        gameMeta(request),
		UserId:      auth.UserIDFromContext(request.Context()),
		ChallengeId: challengeID,
		Gesture:     gesture,
	})
	h.writeRoomRPSChallenge(writer, request, resp, err)
}

func (h *Handler) getRoomRPSChallenge(writer http.ResponseWriter, request *http.Request) {
	if h.gameClient == nil {
		httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
		return
	}
	challengeID := strings.TrimSpace(request.PathValue("challenge_id"))
	if challengeID == "" {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return
	}
	// 详情查询主要服务 IM 事件后的补拉和断线恢复；权限判断必须落在 game-service，防止只凭 challenge_id 猜测访问其它房间订单。
	resp, err := h.gameClient.GetRoomRPSChallenge(request.Context(), &gamev1.GetRoomRPSChallengeRequest{
		Meta:        gameMeta(request),
		UserId:      auth.UserIDFromContext(request.Context()),
		ChallengeId: challengeID,
	})
	h.writeRoomRPSChallenge(writer, request, resp, err)
}

func (h *Handler) writeDiceMatch(writer http.ResponseWriter, request *http.Request, resp *gamev1.DiceMatchResponse, err error) {
	if err != nil {
		httpkit.Write(writer, request, diceMatchData{}, err)
		return
	}
	data := diceMatchDataFromProto(resp)
	h.enrichDiceMatchParticipants(request, &data.Match)
	h.renewDiceAccessTokenIfNeeded(writer, request)
	httpkit.Write(writer, request, data, nil)
}

func (h *Handler) writeRoomRPSChallenge(writer http.ResponseWriter, request *http.Request, resp *gamev1.RoomRPSChallengeResponse, err error) {
	if err != nil {
		// 失败时返回空 data 结构和统一错误 envelope；不要用半截挑战对象误导 Flutter 展示“已发起/已应战”。
		httpkit.Write(writer, request, roomRPSChallengeData{}, err)
		return
	}
	data := roomRPSChallengeDataFromProto(resp)
	// 单个挑战返回也补齐双方头像昵称，保证 HTTP 响应和后续房间 IM 使用同一套展示字段。
	h.enrichRoomRPSChallenges(request, []*roomRPSChallengeItemData{&data.Challenge})
	httpkit.Write(writer, request, data, nil)
}

func selfGameIDFromInput(writer http.ResponseWriter, request *http.Request, rawGameID string, defaultGameID string, fixedGameID bool) (string, bool) {
	gameID := strings.TrimSpace(rawGameID)
	if gameID == "" {
		gameID = defaultGameID
	}
	if fixedGameID && gameID != defaultGameID {
		// rps 是独立 H5 入口，但当前产品只开放 rock 这一条自研猜拳 game_id；这里拒绝串到 dice 或 room-rps。
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return "", false
	}
	return gameID, true
}

func firstRPSGestureInput(gesture string, rpsGesture string) string {
	if strings.TrimSpace(gesture) != "" {
		return gesture
	}
	return rpsGesture
}

func selfGameRPSGestureFromInput(writer http.ResponseWriter, request *http.Request, gameID string, rawGesture string, required bool) (string, bool) {
	if gameID != selfGameIDRock {
		return "", true
	}
	if strings.TrimSpace(rawGesture) == "" && !required {
		return "", true
	}
	gesture, ok := normalizeSelfRPSGesture(rawGesture)
	if !ok {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
		return "", false
	}
	return gesture, true
}

func normalizeSelfRPSGesture(value string) (string, bool) {
	// self-rps 的手势在入 game-service 前统一成小写英文枚举；dice 路径不会调用这里，避免污染骰子玩法。
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "rock", "paper", "scissors":
		return strings.ToLower(strings.TrimSpace(value)), true
	default:
		return "", false
	}
}

func decodeOptionalJSONBody(writer http.ResponseWriter, request *http.Request, out any) bool {
	if request.Body == nil {
		return true
	}
	raw, err := io.ReadAll(request.Body)
	if err != nil {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
		return false
	}
	if strings.TrimSpace(string(raw)) == "" {
		return true
	}
	if err := httpkit.DecodeJSON(bytes.NewReader(raw), out); err != nil {
		httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
		return false
	}
	return true
}

func normalizeRoomRPSGesture(value string) (string, bool) {
	// 手势在 gateway 层归一为小写英文枚举；game-service 只接收稳定值，避免 Flutter 大小写或本地化文案污染订单事实。
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "rock", "paper", "scissors":
		return strings.ToLower(strings.TrimSpace(value)), true
	default:
		return "", false
	}
}

func (h *Handler) renewDiceAccessTokenIfNeeded(writer http.ResponseWriter, request *http.Request) {
	if h.userAuthClient == nil {
		return
	}
	expiresAtMS := auth.AccessTokenExpiresAtMSFromContext(request.Context())
	if expiresAtMS <= 0 || time.Until(time.UnixMilli(expiresAtMS)) > diceAccessTokenRenewBefore {
		// 旧测试 token 或距离过期还很久的 token 不做续签，避免每秒轮询把 user-service 和登录审计打满。
		return
	}
	sessionID := auth.SessionIDFromContext(request.Context())
	if strings.TrimSpace(sessionID) == "" {
		return
	}
	resp, err := h.userAuthClient.AppHeartbeat(request.Context(), &userv1.AppHeartbeatRequest{
		Meta:      httpkit.UserMeta(request, httpkit.FirstHeader(request, "X-Device-ID", "X-HY-Device-ID")),
		UserId:    auth.UserIDFromContext(request.Context()),
		SessionId: sessionID,
	})
	if err != nil {
		// 游戏事实响应已经成功，续签失败只记录日志；客户端下一次仍可走 refresh token 兜底。
		logx.Warn(request.Context(), "dice_access_token_renew_failed", slog.String("component", "gateway_game"), slog.String("session_id", sessionID), slog.String("error", err.Error()))
		return
	}
	token := resp.GetToken()
	accessToken := strings.TrimSpace(token.GetAccessToken())
	if accessToken == "" {
		return
	}
	writer.Header().Set(hyappAccessTokenHeader, accessToken)
	writer.Header().Set(hyappTokenTypeHeader, strings.TrimSpace(token.GetTokenType()))
	writer.Header().Set(hyappExpiresInHeader, strconv.FormatInt(token.GetExpiresInSec(), 10))
}

func (h *Handler) handleGameCallback(writer http.ResponseWriter, request *http.Request) {
	raw, err := io.ReadAll(request.Body)
	if err != nil {
		http.Error(writer, "invalid request body", http.StatusBadRequest)
		return
	}
	platformCode := strings.TrimSpace(request.PathValue("platform_code"))
	operation := strings.TrimSpace(request.PathValue("operation"))
	// reyou 的线上回调现在存在两种入口：根路径兼容入口和 /game-callbacks/reyou/* 通用入口。
	// 通用入口同样先按 token 归属做分流，chatapp3 token 原样回源 chatapp3，其他 token 才交给 hyapp 自己的游戏服务。
	if h.tryProxyChatapp3HotgameCallback(writer, request, raw, platformCode, operation) {
		return
	}
	h.handleGameCallbackRaw(writer, request, raw, platformCode, operation)
}

func (h *Handler) handleZGameCallback(writer http.ResponseWriter, request *http.Request) {
	raw, err := io.ReadAll(request.Body)
	if err != nil {
		http.Error(writer, "invalid request body", http.StatusBadRequest)
		return
	}
	operation := strings.Trim(strings.TrimSpace(request.PathValue("operation")), "/")
	operation = strings.TrimPrefix(operation, "api/")
	if operation == "" {
		http.Error(writer, "invalid callback operation", http.StatusBadRequest)
		return
	}
	// ZGame 后台只能配置固定 callback 前缀；gateway 只把固定尾段映射为 adapter operation，具体协议和钱包语义仍归 game-service。
	h.handleGameCallbackRaw(writer, request, raw, zgameCallbackPlatformCode, operation)
}

func (h *Handler) handleAMGUser(writer http.ResponseWriter, request *http.Request) {
	// AMG 获取玩家信息是 GET 且没有 body；Authorization 中的游戏 token 仍由统一回调入口验签并用于 App 归因。
	h.handleGameCallbackRaw(writer, request, nil, "amg", "user")
}

func (h *Handler) handleAMGSettlement(writer http.ResponseWriter, request *http.Request) {
	raw, err := io.ReadAll(request.Body)
	if err != nil {
		http.Error(writer, "invalid request body", http.StatusBadRequest)
		return
	}
	// settlement 的 RSA 解密、订单幂等和钱包改账都归 game-service；gateway 只保留厂商原始 JSON。
	h.handleGameCallbackRaw(writer, request, raw, "amg", "settlement")
}

func (h *Handler) handleGameCallbackRaw(writer http.ResponseWriter, request *http.Request, raw []byte, platformCode string, operation string) {
	if h.gameClient == nil {
		http.Error(writer, "upstream service error", http.StatusBadGateway)
		return
	}
	scopedRequest, ok := h.withVerifiedCallbackToken(request, raw)
	if !ok {
		http.Error(writer, "unauthorized", http.StatusUnauthorized)
		return
	}
	request = scopedRequest
	resp, err := h.gameClient.HandleCallback(request.Context(), &gamev1.CallbackRequest{
		Meta:         gameMeta(request),
		PlatformCode: strings.TrimSpace(platformCode),
		Operation:    strings.TrimSpace(operation),
		RawBody:      raw,
		Headers:      flattenedHeaders(request),
		Query:        flattenedQuery(request),
		RemoteAddr:   request.RemoteAddr,
	})
	if err != nil {
		status := xerr.SpecOf(xerr.ReasonFromGRPC(err)).HTTPStatus
		http.Error(writer, xerr.SpecOf(xerr.ReasonFromGRPC(err)).PublicMessage, status)
		return
	}
	contentType := strings.TrimSpace(resp.GetContentType())
	if contentType == "" {
		contentType = "application/json"
	}
	writer.Header().Set("Content-Type", contentType)
	writer.WriteHeader(http.StatusOK)
	_, _ = writer.Write(resp.GetRawBody())
}

func (h *Handler) withVerifiedCallbackToken(request *http.Request, raw []byte) (*http.Request, bool) {
	jwtCandidates := callbackJWTTokenCandidates(callbackTokenCandidates(request, raw))
	if len(jwtCandidates) == 0 {
		// 回调 body 里常见的 code、js_code、token 可能是三方业务码或老平台 session；不是 HyApp JWT 时不参与归因，
		// 避免把 code=0、chatapp3 sign token 或游戏方随机串误判为当前 App 用户。
		return request, true
	}
	if h.jwtVerifier == nil {
		return request, false
	}
	for _, token := range jwtCandidates {
		claims, err := h.jwtVerifier.VerifyGameCallbackToken("Bearer " + token)
		if err != nil {
			continue
		}
		// 游戏回调是公网入口，callback 自带 app_code/header 都不能作为归因事实；只有 gateway 验过签的
		// HyApp access/provider token 才能覆盖 request context，后续 gameMeta 会透传签名内的 app_code/user_id。
		return request.WithContext(auth.WithClaims(request.Context(), claims)), true
	}
	return request, false
}

func callbackJWTTokenCandidates(candidates []string) []string {
	jwtCandidates := make([]string, 0, len(candidates))
	seen := make(map[string]struct{}, len(candidates))
	for _, candidate := range candidates {
		token := normalizeCallbackTokenValue(candidate)
		if !callbackTokenLooksLikeJWT(token) {
			continue
		}
		if _, ok := seen[token]; ok {
			continue
		}
		seen[token] = struct{}{}
		jwtCandidates = append(jwtCandidates, token)
	}
	return jwtCandidates
}

func callbackTokenCandidates(request *http.Request, raw []byte) []string {
	candidates := make([]string, 0, 8)
	seen := make(map[string]struct{}, 8)
	add := func(value string) {
		token := normalizeCallbackTokenValue(value)
		if token == "" {
			return
		}
		if _, ok := seen[token]; ok {
			return
		}
		seen[token] = struct{}{}
		candidates = append(candidates, token)
	}

	add(request.Header.Get("Authorization"))
	for _, headerName := range []string{hyappAccessTokenHeader, "X-Access-Token", "X-Token", "X-Session-Token", "X-Game-Token"} {
		add(request.Header.Get(headerName))
	}
	for key, values := range request.URL.Query() {
		if !callbackTokenFieldName(key) {
			continue
		}
		for _, value := range values {
			add(value)
		}
	}
	appendCallbackBodyTokenCandidates(raw, add)
	return candidates
}

func appendCallbackBodyTokenCandidates(raw []byte, add func(string)) {
	raw = bytes.TrimSpace(raw)
	if len(raw) == 0 {
		return
	}
	var payload any
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.UseNumber()
	if err := decoder.Decode(&payload); err != nil {
		return
	}
	walkCallbackTokenJSON("", payload, add)
}

func walkCallbackTokenJSON(fieldName string, value any, add func(string)) {
	switch typed := value.(type) {
	case map[string]any:
		for key, child := range typed {
			walkCallbackTokenJSON(key, child, add)
		}
	case []any:
		for _, child := range typed {
			walkCallbackTokenJSON(fieldName, child, add)
		}
	case string:
		if callbackTokenFieldName(fieldName) {
			add(typed)
		}
	}
}

var callbackTokenKeyReplacer = strings.NewReplacer("_", "", "-", "", " ", "")

var callbackTokenFieldNames = map[string]struct{}{
	"authorization":     {},
	"token":             {},
	"accesstoken":       {},
	"hyapptoken":        {},
	"hyappaccesstoken":  {},
	"authtoken":         {},
	"session":           {},
	"sessionid":         {},
	"sessiontoken":      {},
	"jscode":            {},
	"authcode":          {},
	"platauthcode":      {},
	"plattoken":         {},
	"sstoken":           {},
	"useraccesstoken":   {},
	"userauthorization": {},
}

func callbackTokenFieldName(fieldName string) bool {
	normalized := callbackTokenKeyReplacer.Replace(strings.ToLower(strings.TrimSpace(fieldName)))
	_, ok := callbackTokenFieldNames[normalized]
	return ok
}

func normalizeCallbackTokenValue(value string) string {
	token := strings.TrimSpace(value)
	if len(token) >= len("Bearer ") && strings.EqualFold(token[:len("Bearer ")], "Bearer ") {
		token = strings.TrimSpace(token[len("Bearer "):])
	}
	return strings.Trim(strings.TrimSpace(token), `"'`)
}

func callbackTokenLooksLikeJWT(token string) bool {
	parts := strings.Split(token, ".")
	if len(parts) != 3 {
		return false
	}
	for _, part := range parts {
		if strings.TrimSpace(part) == "" {
			return false
		}
	}
	return true
}

func gameMeta(request *http.Request) *gamev1.RequestMeta {
	return &gamev1.RequestMeta{
		RequestId:     httpkit.RequestIDFromContext(request.Context()),
		Caller:        "gateway-service",
		GatewayNodeId: "",
		ActorUserId:   auth.UserIDFromContext(request.Context()),
		SentAtMs:      time.Now().UnixMilli(),
		AppCode:       appcode.FromContext(request.Context()),
	}
}

func requestLanguage(request *http.Request) string {
	value := httpkit.FirstHeader(request, "X-App-Language", "X-Language")
	if value == "" {
		value = request.Header.Get("Accept-Language")
	}
	if index := strings.Index(value, ","); index >= 0 {
		value = value[:index]
	}
	return strings.TrimSpace(value)
}

func recentGamePageSize(request *http.Request) (int32, bool) {
	if strings.TrimSpace(request.URL.Query().Get("limit")) != "" {
		return httpkit.PositiveInt32Query(request, "limit", 4)
	}
	return httpkit.PositiveInt32Query(request, "page_size", 4)
}

func exploreWinnerPageSize(request *http.Request) (int32, bool) {
	pageSize, ok := httpkit.PositiveInt32Query(request, "limit", 20)
	if !ok {
		return 0, false
	}
	if pageSize > 50 {
		pageSize = 50
	}
	return pageSize, true
}

func bearerAccessToken(request *http.Request) string {
	header := strings.TrimSpace(request.Header.Get("Authorization"))
	if !strings.HasPrefix(header, "Bearer ") {
		return ""
	}
	return strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
}

func flattenedHeaders(request *http.Request) map[string]string {
	headers := make(map[string]string, len(request.Header))
	for key, values := range request.Header {
		if len(values) > 0 {
			headers[key] = values[0]
		}
	}
	return headers
}

func flattenedQuery(request *http.Request) map[string]string {
	query := request.URL.Query()
	result := make(map[string]string, len(query))
	for key, values := range query {
		if len(values) > 0 {
			result[key] = values[0]
		}
	}
	return result
}
