package game

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"google.golang.org/grpc"
	activityv1 "hyapp.local/api/proto/activity/v1"
	gamev1 "hyapp.local/api/proto/game/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	gamedomain "hyapp/services/game-service/internal/domain/game"
)

const defaultScene = gamedomain.SceneVoiceRoom

const (
	selfGameIDDice           = "dice"
	selfGameIDRock           = "rock"
	taskMetricGameDicePlayed = "game_dice_play_count"
	taskMetricGameRockPlayed = "game_rock_play_count"
)

// Repository 隔离 game-service 持久化事实，订单和 session 不能只放内存。
type Repository interface {
	Ping(ctx context.Context) error
	ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, error)
	ListRecentGames(ctx context.Context, query ListRecentGamesQuery) ([]gamedomain.AppGame, error)
	GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
	GetPlatform(ctx context.Context, appCode string, platformCode string) (gamedomain.Platform, error)
	CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error
	GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error)
	GetLaunchSessionByTokenScope(ctx context.Context, appCode string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error)
	InsertCallbackLog(ctx context.Context, log gamedomain.CallbackLog) error
	CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
	MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
	MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error
	CreateRepairOrder(ctx context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error)
	ClaimPendingLevelEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]gamedomain.LevelEventOutbox, error)
	MarkLevelEventDelivered(ctx context.Context, eventID string, nowMS int64) error
	MarkLevelEventFailed(ctx context.Context, eventID string, failureReason string, nextRetryAtMS int64, nowMS int64) error
	ListPlatforms(ctx context.Context, appCode string, status string) ([]gamedomain.Platform, error)
	UpsertPlatform(ctx context.Context, platform gamedomain.Platform) (gamedomain.Platform, error)
	ListCatalog(ctx context.Context, query ListCatalogQuery) ([]gamedomain.CatalogItem, string, error)
	UpsertCatalog(ctx context.Context, item gamedomain.CatalogItem) (gamedomain.CatalogItem, error)
	SetGameStatus(ctx context.Context, appCode string, gameID string, status string, nowMs int64) (gamedomain.CatalogItem, error)
	DeleteCatalog(ctx context.Context, appCode string, gameID string) error
	CanUserAccessGame(ctx context.Context, appCode string, gameID string, userID int64) (bool, error)
	SetGameWhitelistEnabled(ctx context.Context, appCode string, gameID string, enabled bool, nowMS int64) (gamedomain.CatalogItem, error)
	ListGameWhitelistUsers(ctx context.Context, appCode string, gameID string, pageSize int32) ([]gamedomain.WhitelistUser, error)
	AddGameWhitelistUser(ctx context.Context, item gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error)
	DeleteGameWhitelistUser(ctx context.Context, appCode string, gameID string, userID int64) error
}

// WalletClient 是 game-service 调 wallet-service 的最小改账依赖。
type WalletClient interface {
	ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
	GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
}

// UserClient 只暴露平台回调需要的用户展示资料查询能力。
type UserClient interface {
	GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
}

// ActivityClient 是 game-service 向等级系统投递游戏消耗事实的最小依赖。
type ActivityClient interface {
	ConsumeLevelEvent(ctx context.Context, req *activityv1.ConsumeLevelEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error)
	ConsumeTaskEvent(ctx context.Context, req *activityv1.ConsumeTaskEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeTaskEventResponse, error)
}

type Config struct {
	LaunchSessionTTL time.Duration
}

type Service struct {
	config     Config
	repository Repository
	wallet     WalletClient
	user       UserClient
	activity   ActivityClient
	now        func() time.Time

	signatureNonceMu sync.Mutex
	signatureNonces  map[string]int64
}

type ListGamesQuery struct {
	AppCode        string
	UserID         int64
	Scene          string
	RoomID         string
	RegionID       int64
	Language       string
	ClientPlatform string
}

type ListRecentGamesQuery struct {
	AppCode        string
	UserID         int64
	Scene          string
	RoomID         string
	RegionID       int64
	Language       string
	ClientPlatform string
	PageSize       int32
}

type ListCatalogQuery struct {
	AppCode      string
	PlatformCode string
	Status       string
	PageSize     int32
	Cursor       string
}

type LaunchCommand struct {
	AppCode       string
	RequestID     string
	UserID        int64
	DisplayUserID string
	GameID        string
	Scene         string
	RoomID        string
	RegionID      int64
	CountryID     int64
	AccessToken   string
	ProviderToken string
}

type LaunchResult struct {
	SessionID    string
	LaunchURL    string
	ExpiresAtMS  int64
	Orientation  string
	SafeHeight   int32
	ServerTimeMS int64
}

type BridgeScriptResult struct {
	URL          string
	Version      string
	SHA256       string
	ServerTimeMS int64
}

// New 创建 game-service 用例层。
func New(config Config, repository Repository, wallet WalletClient, user UserClient, activity ...ActivityClient) *Service {
	if config.LaunchSessionTTL <= 0 {
		config.LaunchSessionTTL = 15 * time.Minute
	}
	var activityClient ActivityClient
	if len(activity) > 0 {
		activityClient = activity[0]
	}
	return &Service{
		config:     config,
		repository: repository,
		wallet:     wallet,
		user:       user,
		activity:   activityClient,
		now:        time.Now,

		signatureNonces: make(map[string]int64),
	}
}

// Repository 只给 transport/admin 薄封装复用；业务主链路仍通过 Service 方法表达。
func (s *Service) Repository() Repository {
	if s == nil {
		return nil
	}
	return s.repository
}

// ListGames 返回 App 语音房里的跨平台合并游戏列表。
func (s *Service) ListGames(ctx context.Context, query ListGamesQuery) ([]gamedomain.AppGame, int64, error) {
	if query.UserID <= 0 {
		return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	if s.repository == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	query.AppCode = appcode.Normalize(query.AppCode)
	query.Scene = normalizeScene(query.Scene)
	query.Language = strings.ToLower(strings.TrimSpace(query.Language))
	query.ClientPlatform = strings.ToLower(strings.TrimSpace(query.ClientPlatform))
	ctx = appcode.WithContext(ctx, query.AppCode)

	games, err := s.repository.ListGames(ctx, query)
	return games, s.now().UnixMilli(), err
}

// ListRecentGames 返回当前用户最近启动过的语音房游戏，供 App 的 My Games tab 直接展示。
func (s *Service) ListRecentGames(ctx context.Context, query ListRecentGamesQuery) ([]gamedomain.AppGame, int64, error) {
	if query.UserID <= 0 {
		return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
	}
	if s.repository == nil {
		return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	query.AppCode = appcode.Normalize(query.AppCode)
	query.Scene = normalizeScene(query.Scene)
	query.Language = strings.ToLower(strings.TrimSpace(query.Language))
	query.ClientPlatform = strings.ToLower(strings.TrimSpace(query.ClientPlatform))
	query.PageSize = normalizeRecentGamesPageSize(query.PageSize)
	ctx = appcode.WithContext(ctx, query.AppCode)

	// 最近常玩只信任 game_launch_sessions 的真实启动事实，同时在仓储层重新套当前展示规则；
	// 这样旧游戏下架、维护、区域不可见后不会继续出现在 My Games tab。
	games, err := s.repository.ListRecentGames(ctx, query)
	return games, s.now().UnixMilli(), err
}

// GetBridgeScript 返回 App 通用游戏桥接脚本配置，供客户端启动预热和打开游戏面板时做轻量刷新。
func (s *Service) GetBridgeScript(ctx context.Context, appCode string) (BridgeScriptResult, error) {
	if s.repository == nil {
		return BridgeScriptResult{}, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	appCode = appcode.Normalize(appCode)
	ctx = appcode.WithContext(ctx, appCode)

	platforms, err := s.repository.ListPlatforms(ctx, appCode, gamedomain.StatusActive)
	if err != nil {
		return BridgeScriptResult{}, err
	}
	// admin 会把通用桥接脚本同步写入所有平台；这里仍按“大 sort 优先、code 稳定兜底”读取第一份有效配置，
	// 避免数据库返回顺序变化导致 App 在相同配置之间来回切换。
	sort.SliceStable(platforms, func(left, right int) bool {
		if platforms[left].SortOrder != platforms[right].SortOrder {
			return platforms[left].SortOrder > platforms[right].SortOrder
		}
		return strings.Compare(platforms[left].PlatformCode, platforms[right].PlatformCode) < 0
	})
	for _, platform := range platforms {
		config := bridgeScriptConfigFromPlatform(platform)
		if strings.TrimSpace(config.URL) == "" {
			continue
		}
		return BridgeScriptResult{
			URL:          strings.TrimSpace(config.URL),
			Version:      strings.TrimSpace(config.Version),
			SHA256:       strings.TrimSpace(config.SHA256),
			ServerTimeMS: s.now().UnixMilli(),
		}, nil
	}
	return BridgeScriptResult{ServerTimeMS: s.now().UnixMilli()}, nil
}

// LaunchGame 创建短期 H5 启动会话，并把原始钱包能力限制在服务端。
func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (LaunchResult, error) {
	if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" {
		return LaunchResult{}, xerr.New(xerr.InvalidArgument, "launch command is incomplete")
	}
	if s.repository == nil {
		return LaunchResult{}, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	command.AppCode = appcode.Normalize(command.AppCode)
	command.Scene = normalizeScene(command.Scene)
	command.DisplayUserID = strings.TrimSpace(command.DisplayUserID)
	if command.DisplayUserID == "" {
		command.DisplayUserID = strconv.FormatInt(command.UserID, 10)
	}
	ctx = appcode.WithContext(ctx, command.AppCode)

	game, err := s.repository.GetLaunchableGame(ctx, command.AppCode, strings.TrimSpace(command.GameID))
	if err != nil {
		return LaunchResult{}, err
	}
	if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
		return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
	}
	// 列表过滤只改善客户端体验，不能作为访问控制；启动链路必须再次按真实登录 user_id 校验，阻止猜 game_id 绕过白名单。
	allowed, err := s.repository.CanUserAccessGame(ctx, command.AppCode, game.GameID, command.UserID)
	if err != nil {
		return LaunchResult{}, err
	}
	if !allowed {
		return LaunchResult{}, xerr.New(xerr.NotFound, "game not found")
	}
	regionID, countryID := command.RegionID, command.CountryID
	if regionID <= 0 || countryID <= 0 {
		var err error
		// 游戏订单的统计事件只能用启动时的用户维度快照，不能在回调时靠区域桶或当前资料反推国家。
		resolvedRegionID, resolvedCountryID, err := s.resolveUserDimensions(ctx, command.AppCode, command.RequestID, command.UserID)
		if err != nil {
			return LaunchResult{}, err
		}
		if regionID <= 0 {
			regionID = resolvedRegionID
		}
		if countryID <= 0 {
			countryID = resolvedCountryID
		}
	}

	now := s.now()
	sessionID := "game_sess_" + strconv.FormatInt(now.UnixMilli(), 10) + "_" + randomHex(6)
	token := strings.TrimSpace(command.AccessToken)
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) && strings.TrimSpace(command.ProviderToken) != "" {
		// AMG 限制 user_token 最长 512 字节；Gateway 签发的厂商令牌保留可验签的 App/用户归因，
		// 且不会把完整登录 JWT 暴露给厂商。滚动发布期旧 Gateway 没有该字段时仍保留原逻辑。
		token = strings.TrimSpace(command.ProviderToken)
	}
	if token == "" {
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) || strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) || strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
			return LaunchResult{}, xerr.New(xerr.InvalidArgument, "access token is required for provider game launch")
		}
		token = "gt_" + randomHex(24)
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
		// ZGame 文档要求 js_code 是客户服务器派发的短期凭证；不能把 App access token 泄漏到三方 H5 URL。
		token = "gt_" + randomHex(24)
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) && len(token) > 512 {
		// AMG 明确限制 user_token 最长 512 字节；启动前拒绝，避免 H5 打开后才在 /amg/user 出现不可诊断的鉴权失败。
		return LaunchResult{}, xerr.New(xerr.InvalidArgument, "amg user_token exceeds 512 bytes")
	}
	sessionTTL := s.config.LaunchSessionTTL
	// 三方游戏会在 H5 运行期间持续回调 token；厂商适配器可以把默认 15 分钟启动 TTL 拉长。
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
		sessionTTL = maxDuration(sessionTTL, yomiConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
		sessionTTL = maxDuration(sessionTTL, leaderccConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
		sessionTTL = maxDuration(sessionTTL, zeeoneConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
		sessionTTL = maxDuration(sessionTTL, baishunConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
		sessionTTL = maxDuration(sessionTTL, vivaGamesConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
		sessionTTL = maxDuration(sessionTTL, reyouConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
		sessionTTL = maxDuration(sessionTTL, zgameConfigFromPlatform(game).TokenTTL())
	} else if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
		sessionTTL = maxDuration(sessionTTL, amgConfigFromPlatform(game).TokenTTL())
	}
	expiresAt := now.Add(sessionTTL).UnixMilli()
	session := gamedomain.LaunchSession{
		AppCode:        command.AppCode,
		SessionID:      sessionID,
		UserID:         command.UserID,
		DisplayUserID:  command.DisplayUserID,
		RoomID:         strings.TrimSpace(command.RoomID),
		RegionID:       regionID,
		CountryID:      countryID,
		Scene:          command.Scene,
		PlatformCode:   game.PlatformCode,
		GameID:         game.GameID,
		ProviderGameID: game.ProviderGameID,
		// 明文 token 只返回给客户端和厂商，库里只保存 hash，回调时通过 hash 查 session。
		LaunchTokenHash: stableHash(token),
		Status:          gamedomain.SessionActive,
		ExpiresAtMS:     expiresAt,
		CreatedAtMS:     now.UnixMilli(),
		UpdatedAtMS:     now.UnixMilli(),
	}
	if err := s.repository.CreateLaunchSession(ctx, session); err != nil {
		return LaunchResult{}, err
	}

	launchURL, err := buildLaunchURL(game, session, token)
	if err != nil {
		return LaunchResult{}, err
	}
	return LaunchResult{
		SessionID:    sessionID,
		LaunchURL:    launchURL,
		ExpiresAtMS:  expiresAt,
		Orientation:  game.Orientation,
		SafeHeight:   game.SafeHeight,
		ServerTimeMS: now.UnixMilli(),
	}, nil
}

// HandleCallback 保留 provider 原始请求摘要，并对 demo adapter 支持余额查询和金币改账。
func (s *Service) HandleCallback(ctx context.Context, req *gamev1.CallbackRequest) ([]byte, string, error) {
	if req == nil || strings.TrimSpace(req.GetPlatformCode()) == "" || strings.TrimSpace(req.GetOperation()) == "" {
		return nil, "", xerr.New(xerr.InvalidArgument, "callback request is incomplete")
	}
	if s.repository == nil {
		return nil, "", xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	app := appcode.Normalize(req.GetMeta().GetAppCode())
	ctx = appcode.WithContext(ctx, app)
	operation := strings.ToLower(strings.TrimSpace(req.GetOperation()))
	// requestHash 是回调原文摘要，用于审计和钱包幂等校验，避免落库保存完整敏感 body。
	requestHash := stableHashBytes(req.GetRawBody())
	callbackID := "gcb_" + stableHash(fmt.Sprintf("%s|%s|%s|%d", app, req.GetPlatformCode(), requestHash, s.now().UnixNano()))

	signatureValid := false
	providerRequestID := ""
	var responseBody []byte
	var contentType string
	var statusCode string
	var err error
	if platform, platformErr := s.repository.GetPlatform(ctx, app, req.GetPlatformCode()); platformErr == nil {
		// adapter_type 是三方协议边界：新增厂商时只新增适配器，不改钱包/用户主流程。
		switch {
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterYomiV4):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleYomiOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterLeaderCCV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleLeaderCCOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterZeeOneV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZeeOneOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterBaishunV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleBaishunOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterVivaGamesV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleVivaGamesOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterReyouV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleReyouOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterZGameV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleZGameOperation(ctx, app, platform, req, operation, requestHash)
		case strings.EqualFold(platform.AdapterType, gamedomain.AdapterAMGV1):
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = s.handleAMGOperation(ctx, app, platform, req, operation, requestHash)
		default:
			responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
		}
	} else {
		if strings.EqualFold(req.GetPlatformCode(), gamedomain.PlatformCodeZGame) {
			responseBody, contentType, statusCode, signatureValid, providerRequestID, err = zgameAdapterError(zgameCodeInvalidSession, "Invalid Session", false, "")
		} else {
			// 找不到平台时退回 demo，保留旧测试入口行为，避免管理后台未配置时直接打断联调。
			responseBody, contentType, statusCode, err = s.handleDemoOperation(ctx, app, req, operation, requestHash)
		}
	}
	responseHash := stableHashBytes(responseBody)
	logStatus := "succeeded"
	if err != nil {
		logStatus = "failed"
		responseBody, contentType = jsonResponse(map[string]any{"code": string(xerr.CodeOf(err)), "message": xerr.MessageOf(err)})
		statusCode = string(xerr.CodeOf(err))
		responseHash = stableHashBytes(responseBody)
	}
	_ = s.repository.InsertCallbackLog(ctx, gamedomain.CallbackLog{
		AppCode:           app,
		CallbackID:        callbackID,
		PlatformCode:      strings.TrimSpace(req.GetPlatformCode()),
		Operation:         operation,
		RequestID:         req.GetMeta().GetRequestId(),
		ProviderRequestID: providerRequestID,
		SignatureValid:    signatureValid,
		RequestHash:       requestHash,
		ResponseCode:      statusCode,
		ResponseBodyHash:  responseHash,
		Status:            logStatus,
		CreatedAtMS:       s.now().UnixMilli(),
	})
	// InsertCallbackLog 失败不影响厂商响应；回调主链路优先保证游戏侧不超时。
	return responseBody, contentType, err
}

func (s *Service) handleDemoOperation(ctx context.Context, app string, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, error) {
	switch operation {
	case "get_user_info":
		result, err := s.getCallbackUserInfo(ctx, app, req)
		if err != nil {
			return nil, "", "", err
		}
		raw, contentType := jsonResponse(result)
		return raw, contentType, "OK", nil
	case "get_balance":
		if s.wallet == nil {
			return nil, "", "", xerr.New(xerr.Unavailable, "wallet client is not configured")
		}
		var body struct {
			UserID int64 `json:"user_id"`
		}
		if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
			return nil, "", "", xerr.New(xerr.InvalidArgument, "invalid get_balance payload")
		}
		resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
			RequestId:  req.GetMeta().GetRequestId(),
			AppCode:    app,
			UserId:     body.UserID,
			AssetTypes: []string{"COIN"},
		})
		if err != nil {
			return nil, "", "", err
		}
		var balance int64
		for _, item := range resp.GetBalances() {
			if item.GetAssetType() == "COIN" {
				balance = item.GetAvailableAmount()
			}
		}
		raw, contentType := jsonResponse(map[string]any{"code": 0, "balance": balance})
		return raw, contentType, "OK", nil
	case "change_coin":
		result, err := s.applyCallbackCoinChange(ctx, app, req, requestHash)
		if err != nil {
			return nil, "", "", err
		}
		raw, contentType := jsonResponse(result)
		return raw, contentType, "OK", nil
	default:
		raw, contentType := jsonResponse(map[string]any{"code": 0, "message": "ok"})
		return raw, contentType, "OK", nil
	}
}

func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *gamev1.CallbackRequest) (map[string]any, error) {
	if s.user == nil || s.wallet == nil {
		return nil, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
	}
	var body struct {
		UserID int64 `json:"user_id"`
	}
	if err := json.Unmarshal(req.GetRawBody(), &body); err != nil || body.UserID <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "invalid get_user_info payload")
	}
	userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
		Meta: &userv1.RequestMeta{
			RequestId: req.GetMeta().GetRequestId(),
			Caller:    "game-service",
			SentAtMs:  s.now().UnixMilli(),
			AppCode:   app,
		},
		UserId: body.UserID,
	})
	if err != nil {
		return nil, err
	}
	balanceResp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
		RequestId:  req.GetMeta().GetRequestId(),
		AppCode:    app,
		UserId:     body.UserID,
		AssetTypes: []string{"COIN"},
	})
	if err != nil {
		return nil, err
	}
	var balance int64
	for _, item := range balanceResp.GetBalances() {
		if item.GetAssetType() == "COIN" {
			balance = item.GetAvailableAmount()
			break
		}
	}
	user := userResp.GetUser()
	return map[string]any{
		"code":            0,
		"user_id":         body.UserID,
		"display_user_id": user.GetDisplayUserId(),
		"nickname":        user.GetUsername(),
		"avatar":          user.GetAvatar(),
		"country":         user.GetCountry(),
		"region_id":       user.GetRegionId(),
		"balance":         balance,
	}, nil
}

func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) {
	regionID, _, err := s.resolveUserDimensions(ctx, app, requestID, userID)
	return regionID, err
}

// resolveUserDimensions 只在入口没有携带完整维度时兜底读取 user-service；
// 统计事实随后持久化快照，避免后续用户换国家导致历史订单归因漂移。
func (s *Service) resolveUserDimensions(ctx context.Context, app string, requestID string, userID int64) (int64, int64, error) {
	if userID <= 0 || s.user == nil {
		return 0, 0, nil
	}
	userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
		Meta: &userv1.RequestMeta{
			RequestId: requestID,
			Caller:    "game-service",
			SentAtMs:  s.now().UnixMilli(),
			AppCode:   app,
		},
		UserId: userID,
	})
	if err != nil {
		return 0, 0, err
	}
	user := userResp.GetUser()
	return user.GetRegionId(), user.GetCountryId(), nil
}

func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
	if s.wallet == nil {
		return nil, xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	var body struct {
		UserID          int64  `json:"user_id"`
		GameID          string `json:"game_id"`
		ProviderGameID  string `json:"provider_game_id"`
		ProviderOrderID string `json:"provider_order_id"`
		ProviderRoundID string `json:"provider_round_id"`
		OpType          string `json:"op_type"`
		CoinAmount      int64  `json:"coin_amount"`
		RoomID          string `json:"room_id"`
	}
	if err := json.Unmarshal(req.GetRawBody(), &body); err != nil {
		return nil, xerr.New(xerr.InvalidArgument, "invalid change_coin payload")
	}
	body.GameID = strings.TrimSpace(body.GameID)
	body.ProviderOrderID = strings.TrimSpace(body.ProviderOrderID)
	body.OpType = strings.TrimSpace(body.OpType)
	if body.UserID <= 0 || body.GameID == "" || body.ProviderOrderID == "" || body.OpType == "" || body.CoinAmount <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "change_coin payload is incomplete")
	}
	if body.ProviderGameID == "" {
		game, err := s.repository.GetLaunchableGame(ctx, app, body.GameID)
		if err != nil {
			return nil, err
		}
		body.ProviderGameID = game.ProviderGameID
	}
	regionID, countryID, err := s.resolveUserDimensions(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
	if err != nil {
		return nil, err
	}
	order := gamedomain.GameOrder{
		AppCode:         app,
		OrderID:         "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+body.ProviderOrderID),
		PlatformCode:    strings.TrimSpace(req.GetPlatformCode()),
		ProviderOrderID: body.ProviderOrderID,
		ProviderRoundID: body.ProviderRoundID,
		GameID:          body.GameID,
		ProviderGameID:  body.ProviderGameID,
		UserID:          body.UserID,
		RoomID:          strings.TrimSpace(body.RoomID),
		RegionID:        regionID,
		CountryID:       countryID,
		OpType:          body.OpType,
		CoinAmount:      body.CoinAmount,
		Status:          gamedomain.OrderStatusWalletApplying,
		RequestHash:     requestHash,
	}
	order, exists, err := s.repository.CreateGameOrder(ctx, order)
	if err != nil {
		return nil, err
	}
	if exists && order.Status == gamedomain.OrderStatusSucceeded {
		return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": order.WalletTransactionID, "balance_after": order.WalletBalanceAfter, "idempotent_replay": true}, nil
	}

	walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
		RequestId:       req.GetMeta().GetRequestId(),
		AppCode:         app,
		CommandId:       "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + body.ProviderOrderID,
		UserId:          body.UserID,
		PlatformCode:    strings.TrimSpace(req.GetPlatformCode()),
		GameId:          body.GameID,
		ProviderOrderId: body.ProviderOrderID,
		ProviderRoundId: body.ProviderRoundID,
		OpType:          body.OpType,
		CoinAmount:      body.CoinAmount,
		RoomId:          body.RoomID,
		RequestHash:     requestHash,
	})
	nowMs := s.now().UnixMilli()
	if err != nil {
		status := gamedomain.OrderStatusFailed
		if xerr.IsCode(err, xerr.IdempotencyConflict) {
			status = gamedomain.OrderStatusConflict
		}
		_ = s.repository.MarkOrderFailed(ctx, app, order.OrderID, status, string(xerr.ReasonFromGRPC(err)), err.Error(), nowMs)
		return nil, err
	}
	if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
		return nil, err
	}
	return map[string]any{"code": 0, "order_id": order.OrderID, "wallet_transaction_id": walletResp.GetWalletTransactionId(), "balance_after": walletResp.GetBalanceAfter(), "idempotent_replay": walletResp.GetIdempotentReplay()}, nil
}

// ProcessLevelEventOutboxBatch 把成功游戏扣费订单异步投递给 activity-service 的游戏等级轨道和任务轨道。
func (s *Service) ProcessLevelEventOutboxBatch(ctx context.Context, runID string, workerID string, batchSize int, lockTTL time.Duration) (claimed int32, processed int32, success int32, failure int32, hasMore bool, err error) {
	if s.repository == nil {
		return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "game repository is not configured")
	}
	if s.activity == nil {
		return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "activity client is not configured")
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	if lockTTL <= 0 {
		lockTTL = 30 * time.Second
	}
	if strings.TrimSpace(workerID) == "" {
		workerID = "game-level-relay"
	}
	events, err := s.repository.ClaimPendingLevelEvents(ctx, workerID, s.now().UnixMilli(), lockTTL, batchSize)
	if err != nil {
		return 0, 0, 0, 0, false, err
	}
	for _, event := range events {
		processed++
		eventCtx := appcode.WithContext(ctx, event.AppCode)
		// 等级和任务共用同一条游戏扣币 outbox；先投递成长等级，保留原有等级体系的消费顺序和失败语义。
		resp, relayErr := s.activity.ConsumeLevelEvent(eventCtx, &activityv1.ConsumeLevelEventRequest{
			Meta: &activityv1.RequestMeta{
				RequestId: event.EventID,
				Caller:    "game-service",
				SentAtMs:  s.now().UnixMilli(),
				AppCode:   event.AppCode,
			},
			EventId:         event.EventID,
			SourceEventId:   event.OrderID,
			SourceService:   "game-service",
			SourceEventType: "GameCoinDebited",
			UserId:          event.UserID,
			Track:           "game",
			MetricType:      "game_spend_coin",
			ValueDelta:      event.CoinAmount,
			OccurredAtMs:    event.OccurredAtMS,
			DimensionsJson:  event.PayloadJSON,
		})
		if relayErr != nil {
			failure++
			nextRetry := s.now().Add(time.Minute).UnixMilli()
			_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, xerr.MessageOf(relayErr), nextRetry, s.now().UnixMilli())
			continue
		}
		if resp.GetStatus() != "consumed" && resp.GetStatus() != "duplicate" {
			failure++
			nextRetry := s.now().Add(time.Minute).UnixMilli()
			_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, "activity returned "+resp.GetStatus(), nextRetry, s.now().UnixMilli())
			continue
		}
		// 任务轨道使用独立 event_id 后缀，避免和成长等级事件共用幂等键；payload_json 原样透传给 dimension_filter_json 匹配 game_id 等维度。
		taskResp, relayErr := s.activity.ConsumeTaskEvent(eventCtx, &activityv1.ConsumeTaskEventRequest{
			Meta: &activityv1.RequestMeta{
				RequestId: event.EventID + ":task",
				Caller:    "game-service",
				SentAtMs:  s.now().UnixMilli(),
				AppCode:   event.AppCode,
			},
			EventId:        event.EventID + ":task",
			EventType:      "GameCoinDebited",
			SourceService:  "game-service",
			UserId:         event.UserID,
			MetricType:     "game_spend_coin",
			Value:          event.CoinAmount,
			OccurredAtMs:   event.OccurredAtMS,
			DimensionsJson: event.PayloadJSON,
		})
		if relayErr != nil {
			failure++
			nextRetry := s.now().Add(time.Minute).UnixMilli()
			_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, xerr.MessageOf(relayErr), nextRetry, s.now().UnixMilli())
			continue
		}
		if taskResp.GetStatus() != "consumed" && taskResp.GetStatus() != "skipped" && taskResp.GetStatus() != "duplicate" {
			failure++
			nextRetry := s.now().Add(time.Minute).UnixMilli()
			_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, "task returned "+taskResp.GetStatus(), nextRetry, s.now().UnixMilli())
			continue
		}
		if playMetric := selfGamePlayCountMetric(event.GameID); playMetric != "" {
			// dice/rock 的“玩 1 局”是独立每日任务口径，不能复用扣币金额；仍使用同一条扣费 outbox 派生，保证每个真人扣款参与者最多累计 1 次。
			playResp, relayErr := s.activity.ConsumeTaskEvent(eventCtx, &activityv1.ConsumeTaskEventRequest{
				Meta: &activityv1.RequestMeta{
					RequestId: event.EventID + ":task:play_count",
					Caller:    "game-service",
					SentAtMs:  s.now().UnixMilli(),
					AppCode:   event.AppCode,
				},
				EventId:        event.EventID + ":task:play_count",
				EventType:      "SelfGamePlayed",
				SourceService:  "game-service",
				UserId:         event.UserID,
				MetricType:     playMetric,
				Value:          1,
				OccurredAtMs:   event.OccurredAtMS,
				DimensionsJson: event.PayloadJSON,
			})
			if relayErr != nil {
				failure++
				nextRetry := s.now().Add(time.Minute).UnixMilli()
				_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, xerr.MessageOf(relayErr), nextRetry, s.now().UnixMilli())
				continue
			}
			if playResp.GetStatus() != "consumed" && playResp.GetStatus() != "skipped" && playResp.GetStatus() != "duplicate" {
				failure++
				nextRetry := s.now().Add(time.Minute).UnixMilli()
				_ = s.repository.MarkLevelEventFailed(eventCtx, event.EventID, "play task returned "+playResp.GetStatus(), nextRetry, s.now().UnixMilli())
				continue
			}
		}
		// 只有成长、扣币任务和自研游戏局数任务都达到终态才标记 delivered，防止“游戏等级已加、每日任务未加”的半完成 outbox。
		if err := s.repository.MarkLevelEventDelivered(eventCtx, event.EventID, s.now().UnixMilli()); err != nil {
			failure++
			continue
		}
		success++
	}
	return int32(len(events)), processed, success, failure, len(events) == batchSize, nil
}

func selfGamePlayCountMetric(gameID string) string {
	switch strings.TrimSpace(gameID) {
	case selfGameIDDice:
		return taskMetricGameDicePlayed
	case selfGameIDRock:
		return taskMetricGameRockPlayed
	default:
		return ""
	}
}

func normalizeScene(scene string) string {
	scene = strings.ToLower(strings.TrimSpace(scene))
	if scene == "" {
		return defaultScene
	}
	return scene
}

func normalizeRecentGamesPageSize(pageSize int32) int32 {
	if pageSize <= 0 {
		return 4
	}
	if pageSize > 20 {
		return 20
	}
	return pageSize
}

func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSession, token string) (string, error) {
	base := strings.TrimSpace(game.APIBaseURL)
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
		// 灵仙不同游戏使用独立 H5 地址；保留平台 URL 作为旧配置兜底。
		if configuredBase := leaderccLaunchBaseURL(game, leaderccConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
		if base == "" {
			return "", nil
		}
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
		// ZeeOne 的测试/正式游戏经常是同商户多条 H5 地址，优先读后台 JSON 的按游戏覆盖配置。
		if configuredBase := zeeoneLaunchBaseURL(game, zeeoneConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
		// 百顺 H5 地址可能来自 one_game_info/download_url，也支持后台按游戏覆盖，避免按环境写死。
		if configuredBase := baishunLaunchBaseURL(game, baishunConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
		// VIVAGAMES 文档说明游戏 URL 由厂商提供；后台按 game_id 保存 URL，启动时只拼客户端 getConfig 需要的热配置。
		if configuredBase := vivaGamesLaunchBaseURL(game, vivaGamesConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
		// 热游 H5 地址由厂商按游戏提供；后台配置 game_urls 后，上线新游戏无需改服务端配置文件。
		if configuredBase := reyouLaunchBaseURL(game, reyouConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
		// ZGame H5 地址由厂商提供；固定回调路径只影响服务端回调，启动页仍按后台 game_urls/template 热配置。
		if configuredBase := zgameLaunchBaseURL(game, zgameConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
		// AMG 按 gameId 分配逐游戏 H5 地址；后台 game_urls 优先于平台级兜底地址。
		if configuredBase := amgLaunchBaseURL(game, amgConfigFromPlatform(game)); configuredBase != "" {
			base = configuredBase
		}
	}
	if base == "" {
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
			return "", xerr.New(xerr.InvalidArgument, "yomi auth url is required")
		}
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
			return "", xerr.New(xerr.InvalidArgument, "zeeone launch url is required")
		}
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
			return "", xerr.New(xerr.InvalidArgument, "baishun launch url is required")
		}
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
			return "", xerr.New(xerr.InvalidArgument, "vivagames launch url is required")
		}
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
			return "", xerr.New(xerr.InvalidArgument, "reyou launch url is required")
		}
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
			return "", xerr.New(xerr.InvalidArgument, "zgame launch url is required")
		}
		if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
			return "", xerr.New(xerr.InvalidArgument, "amg launch url is required")
		}
		// demo 平台可以没有真实厂商域名，保留本地默认地址用于开发自测。
		base = "https://game.example.local/h5"
	}
	parsed, err := url.Parse(base)
	if err != nil {
		return "", xerr.New(xerr.InvalidArgument, "platform launch url is invalid")
	}
	query := parsed.Query()
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterYomiV4) {
		// Yomi 的启动地址是 auth/gateway，用户合法性通过 platAuthCode 在服务端 gettoken 二次校验。
		config := yomiConfigFromPlatform(game)
		lang := config.DefaultLang
		if lang == "" {
			lang = "en"
		}
		query.Set("gameUid", session.ProviderGameID)
		query.Set("platAuthCode", token)
		query.Set("platUserId", externalUserID(session, config.UIDMode))
		query.Set("lang", lang)
		// Yomi gameLevelUid 语义类似房间/场次，没有后台配置时必须显式传 0。
		if gameLevelUID := config.GameLevelUIDFor(game); gameLevelUID > 0 {
			query.Set("gameLevelUid", strconv.FormatInt(gameLevelUID, 10))
		} else {
			query.Set("gameLevelUid", "0")
		}
		if config.PlatPayload != "" {
			query.Set("platPayload", config.PlatPayload)
		}
		if session.RoomID != "" {
			query.Set("platRoomId", session.RoomID)
		}
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterLeaderCCV1) {
		// 灵仙启动只需要 uid/token/lang/roomid，后续 userinfo/change_coin 通过 token + MD5 签名校验。
		config := leaderccConfigFromPlatform(game)
		lang := config.DefaultLang
		if lang == "" {
			lang = "en"
		}
		query.Set("uid", externalUserID(session, config.UIDMode))
		query.Set("token", token)
		query.Set("lang", lang)
		if session.RoomID != "" {
			query.Set("roomid", session.RoomID)
		}
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterZeeOneV1) {
		// ZeeOne 直接加载指定游戏 H5，sessionId 使用 App access token，merchant/platform 来自后台热配置。
		config := zeeoneConfigFromPlatform(game)
		lang := config.DefaultLang
		if lang == "" {
			lang = "en"
		}
		query.Set("sessionId", token)
		query.Set("language", lang)
		if config.MerchantID > 0 {
			query.Set("merchant", strconv.FormatInt(config.MerchantID, 10))
		}
		if config.PlatformID > 0 {
			query.Set("platform", strconv.FormatInt(config.PlatformID, 10))
		}
		if session.RoomID != "" {
			query.Set("sid", session.RoomID)
		}
		if config.GameMode != "" {
			query.Set("gameMode", config.GameMode)
		}
		if config.Extra != "" {
			query.Set("extra", config.Extra)
		}
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
		// 百顺前端规范以 NativeBridge.getConfig 为准；这里把同一份配置也拼到 URL，方便 Flutter/H5 壳层读取。
		config := baishunConfigFromPlatform(game)
		query.Set("bridge_keys", "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl")
		query.Set("appChannel", config.AppChannel)
		if config.AppID > 0 {
			query.Set("appId", strconv.FormatInt(config.AppID, 10))
		}
		query.Set("userId", externalUserID(session, config.UIDMode))
		query.Set("code", token)
		query.Set("isDataByUrl", "1")
		if session.RoomID != "" {
			query.Set("roomId", session.RoomID)
		}
		query.Set("gameMode", config.GameModeValue())
		query.Set("language", config.LanguageValue())
		query.Set("sceneMode", strconv.FormatInt(config.SceneMode, 10))
		if config.CurrencyIcon != "" {
			query.Set("currencyIcon", config.CurrencyIcon)
		}
		query.Set("gsp", strconv.FormatInt(config.GSP, 10))
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterVivaGamesV1) {
		// VIVAGAMES H5 通过 NativeBridge.getConfig 取 appId/userId/code；这里把同一份配置拼到 URL，供 App 壳层回写给 H5。
		config := vivaGamesConfigFromPlatform(game)
		query.Set("bridge_keys", "appId,userId,code,metadata,language,coinType,amountRate,bgmSwitch,gsp,currencyIcon,gameConfig,gameId")
		if config.AppID > 0 {
			query.Set("appId", strconv.FormatInt(config.AppID, 10))
		}
		query.Set("userId", externalUserID(session, config.UIDMode))
		query.Set("code", token)
		query.Set("gameId", strings.TrimSpace(session.ProviderGameID))
		query.Set("metadata", config.MetadataValue(session))
		query.Set("language", config.LanguageValue())
		query.Set("coinType", strconv.FormatInt(config.CoinType, 10))
		query.Set("amountRate", strconv.FormatInt(config.AmountRateValue(), 10))
		query.Set("bgmSwitch", strconv.FormatInt(config.BGMSwitchValue(), 10))
		query.Set("gsp", strconv.FormatInt(config.GSPValue(), 10))
		if config.CurrencyIcon != "" {
			query.Set("currencyIcon", config.CurrencyIcon)
			query.Set("gameConfig", vivaGamesGameConfigQuery(config.CurrencyIcon))
		}
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterReyouV1) {
		// 热游文档只要求 uid/token/lang 三个启动参数；服务端回调再用 token 绑定用户、游戏和钱包订单。
		config := reyouConfigFromPlatform(game)
		query.Set("uid", externalUserID(session, config.UIDMode))
		query.Set("token", token)
		query.Set("lang", config.LanguageValue())
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterZGameV1) {
		// laluparty 是厂商文档约定通道；H5 通过 App 注入的 ThirdPlatformBridge 取用户信息和 login js_code。
		// 启动 URL 仍带 open_id/js_code，供 App Bridge 复用同一份短期凭证，避免客户端再发起一次派码请求。
		config := zgameConfigFromPlatform(game)
		openID := externalUserID(session, config.UIDMode)
		query.Set("channelName", "laluparty")
		query.Set("open_id", openID)
		query.Set("openId", openID)
		query.Set("js_code", token)
		query.Set("jscode", token)
		query.Set("gameId", strings.TrimSpace(session.ProviderGameID))
		query.Set("game_id", strings.TrimSpace(session.ProviderGameID))
		callbackBase := config.CallbackBaseValue()
		query.Set("callbackBase", callbackBase)
		query.Set("callback_base", callbackBase)
		if config.DefaultLang != "" {
			query.Set("lang", config.DefaultLang)
		}
		if session.RoomID != "" {
			query.Set("roomId", session.RoomID)
			query.Set("room_id", session.RoomID)
		}
		if config.MerchantPayload != "" {
			query.Set("merchantPayload", config.MerchantPayload)
			query.Set("merchant_payload", config.MerchantPayload)
		}
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	if strings.EqualFold(game.AdapterType, gamedomain.AdapterAMGV1) {
		config := amgConfigFromPlatform(game)
		if config.AppKey == "" {
			return "", xerr.New(xerr.InvalidArgument, "amg app_key is required")
		}
		// AMG 文档固定使用 user_token/app_key；可选项只在后台明确配置后才拼接，避免改变厂商默认 UI。
		query.Set("user_token", token)
		query.Set("app_key", config.AppKey)
		if config.Ext != "" {
			query.Set("ext", config.Ext)
		}
		if session.RoomID != "" {
			query.Set("room_id", session.RoomID)
		}
		if config.Mini > 0 {
			query.Set("mini", strconv.Itoa(config.Mini))
		}
		if config.DefaultLang != "" {
			query.Set("lang", config.DefaultLang)
		}
		if config.AppAudio > 0 {
			query.Set("app_audio", strconv.Itoa(config.AppAudio))
		}
		if config.Debug > 0 {
			query.Set("debug", strconv.Itoa(config.Debug))
		}
		if config.UseLang > 0 {
			query.Set("use_lang", strconv.Itoa(config.UseLang))
		}
		if config.Loading != "" {
			query.Set("loading", config.Loading)
		}
		appendBridgeScriptQuery(query, game)
		parsed.RawQuery = query.Encode()
		return parsed.String(), nil
	}
	// demo adapter 保持内部调试参数完整，方便用一个简单 H5 验证 session 和订单链路。
	query.Set("session_id", session.SessionID)
	query.Set("session_token", token)
	query.Set("game_id", session.GameID)
	query.Set("provider_game_id", session.ProviderGameID)
	query.Set("platform_code", session.PlatformCode)
	query.Set("user_id", strconv.FormatInt(session.UserID, 10))
	query.Set("display_user_id", session.DisplayUserID)
	query.Set("scene", session.Scene)
	query.Set("room_id", session.RoomID)
	appendBridgeScriptQuery(query, game)
	parsed.RawQuery = query.Encode()
	return parsed.String(), nil
}

func maxDuration(left time.Duration, right time.Duration) time.Duration {
	if right > left {
		return right
	}
	return left
}

func (s *Service) rememberSignatureNonce(scope string, nonce string, requestAt time.Time, ttl time.Duration) bool {
	if s == nil {
		return false
	}
	scope = strings.TrimSpace(scope)
	nonce = strings.TrimSpace(nonce)
	if scope == "" || nonce == "" {
		return false
	}
	s.signatureNonceMu.Lock()
	defer s.signatureNonceMu.Unlock()
	if s.signatureNonces == nil {
		s.signatureNonces = make(map[string]int64)
	}
	nowMS := s.now().UnixMilli()
	for key, expiresAtMS := range s.signatureNonces {
		if expiresAtMS <= nowMS {
			delete(s.signatureNonces, key)
		}
	}
	key := scope + ":" + nonce
	if s.signatureNonces[key] > nowMS {
		return false
	}
	s.signatureNonces[key] = requestAt.Add(ttl).UnixMilli()
	return true
}

func jsonResponse(value any) ([]byte, string) {
	body, err := json.Marshal(value)
	if err != nil {
		return []byte(`{"code":"INTERNAL_ERROR","message":"internal error"}`), "application/json"
	}
	return body, "application/json"
}

func randomHex(size int) string {
	bytes := make([]byte, size)
	if _, err := rand.Read(bytes); err != nil {
		return strconv.FormatInt(time.Now().UnixNano(), 16)
	}
	return hex.EncodeToString(bytes)
}

func stableHash(value string) string {
	sum := sha256.Sum256([]byte(value))
	return hex.EncodeToString(sum[:])
}

func stableHashBytes(value []byte) string {
	sum := sha256.Sum256(value)
	return hex.EncodeToString(sum[:])
}
