package game

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"encoding/base64"
	"encoding/json"
	"net"
	"strconv"
	"strings"
	"time"

	gamev1 "hyapp.local/api/proto/game/v1"
	userv1 "hyapp.local/api/proto/user/v1"
	walletv1 "hyapp.local/api/proto/wallet/v1"
	"hyapp/pkg/xerr"
	gamedomain "hyapp/services/game-service/internal/domain/game"
)

const (
	// Yomi 返回码必须按厂商文档透传，游戏侧会直接根据这些数字决定是否重试或弹错。
	yomiCodeOK                  = 200
	yomiCodeSystemError         = 9998
	yomiCodeInsufficientBalance = 10003
	yomiCodeGameMaintenance     = 10004
	yomiCodeTokenInvalid        = 10005
	yomiCodeSignatureInvalid    = 10007
	yomiCodeGameNotFound        = 10018
	yomiCodeUserNotFound        = 10019
	yomiCodeIPForbidden         = 10020

	yomiDefaultTokenTTL = 24 * time.Hour
)

// yomiAdapterConfig 是后台 adapter_config_json 的结构化视图。
// 这类厂商字段必须放在 DB 配置里，后续切测试/正式环境或新增游戏档位无需重启服务。
type yomiAdapterConfig struct {
	// uid_mode 控制给厂商的用户标识来源：
	// 空或 user_id 使用内部数字 user_id；display_user_id 使用 App 展示 ID。
	UIDMode         string `json:"uid_mode"`
	DefaultLang     string `json:"default_lang"`
	TokenTTLSeconds int64  `json:"token_ttl_seconds"`
	// game_level_uid 对应 Yomi 房间/场次，没有配置时按文档传 0。
	GameLevelUID int64 `json:"game_level_uid"`
	// game_level_uids 支持同一平台下不同游戏/房间使用不同场次 ID，key 可填 providerGameId 或内部 gameId。
	GameLevelUIDs map[string]int64 `json:"game_level_uids"`
	// plat_payload 透传给 Yomi，适合联调时放环境、渠道或房间附加上下文；不参与服务端业务判断。
	PlatPayload string `json:"plat_payload"`
}

func yomiConfigFromPlatform(value any) yomiAdapterConfig {
	// 启动链路拿到的是 LaunchableGame，回调链路拿到的是 Platform；这里统一解析，避免两处配置规则漂移。
	var raw string
	switch typed := value.(type) {
	case gamedomain.Platform:
		raw = typed.AdapterConfigJSON
	case gamedomain.LaunchableGame:
		raw = typed.AdapterConfigJSON
	}
	var config yomiAdapterConfig
	_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
	config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
	config.DefaultLang = strings.TrimSpace(config.DefaultLang)
	config.PlatPayload = strings.TrimSpace(config.PlatPayload)
	config.GameLevelUIDs = normalizeInt64Map(config.GameLevelUIDs)
	return config
}

func (c yomiAdapterConfig) TokenTTL() time.Duration {
	// Yomi 文档建议 token 至少 24 小时；后台可以调大或调小，但空配置必须有安全默认值。
	if c.TokenTTLSeconds > 0 {
		return time.Duration(c.TokenTTLSeconds) * time.Second
	}
	return yomiDefaultTokenTTL
}

func (c yomiAdapterConfig) GameLevelUIDFor(game gamedomain.LaunchableGame) int64 {
	for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
		if value := c.GameLevelUIDs[strings.TrimSpace(key)]; value > 0 {
			return value
		}
	}
	return c.GameLevelUID
}

func normalizeInt64Map(input map[string]int64) map[string]int64 {
	if len(input) == 0 {
		return nil
	}
	output := make(map[string]int64, len(input))
	for key, value := range input {
		key = strings.TrimSpace(key)
		if key != "" && value > 0 {
			output[key] = value
		}
	}
	if len(output) == 0 {
		return nil
	}
	return output
}

func externalUserID(session gamedomain.LaunchSession, uidMode string) string {
	// 三方通常会把 uid 展示给玩家或写入订单，切换 uid_mode 后必须保持启动和回调校验一致。
	if strings.EqualFold(uidMode, "display_user_id") && strings.TrimSpace(session.DisplayUserID) != "" {
		return strings.TrimSpace(session.DisplayUserID)
	}
	return strconv.FormatInt(session.UserID, 10)
}

func (s *Service) handleYomiOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
	// IP 白名单先于解密执行，可以挡住明显非厂商来源的请求，减少密钥探测面。
	if !callbackIPAllowed(req, platform.CallbackIPWhitelist) {
		raw, contentType := yomiJSON(yomiCodeIPForbidden, "ip forbidden", map[string]any{})
		return raw, contentType, strconv.Itoa(yomiCodeIPForbidden), false, "", nil
	}
	// Yomi 请求体是 JSON -> AES-GCM -> base64 URL 编码，body 解不开就按签名错误返回。
	body, err := decryptYomiBody(req.GetRawBody(), platform.CallbackSecretCiphertext)
	if err != nil {
		raw, contentType := yomiJSON(yomiCodeSignatureInvalid, "data signature invalid", map[string]any{})
		return raw, contentType, strconv.Itoa(yomiCodeSignatureInvalid), false, "", nil
	}

	switch operation {
	// operation 来自网关路由尾段，不同部署可能保留 api/ 前缀，所以兼容常见写法。
	case "gettoken", "get_token", "api/gettoken":
		return s.handleYomiGetToken(ctx, app, platform, req, body)
	case "userinfo", "user_info", "api/userinfo":
		return s.handleYomiUserInfo(ctx, app, platform, req, body)
	case "change_balance", "changebalance", "api/change_balance":
		return s.handleYomiChangeBalance(ctx, app, platform, req, body, requestHash)
	case "repair_order", "repairorder", "api/repair_order":
		return s.handleYomiRepairOrder(ctx, app, platform, body)
	default:
		raw, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{})
		return raw, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
	}
}

func (s *Service) handleYomiGetToken(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
	// gettoken 是 Yomi 用启动链接里的 platAuthCode 换平台 token 的闭环校验。
	// 当前实现直接复用启动 token：Yomi 后续 userinfo/change_balance 都必须带同一个 platToken。
	var body struct {
		GameUID      json.Number `json:"gameUid"`
		Lang         string      `json:"lang"`
		PlatAuthCode string      `json:"platAuthCode"`
		PlatUserID   string      `json:"platUserId"`
		PlatPayload  string      `json:"platPayload"`
		PlatRoomID   string      `json:"platRoomId"`
	}
	if err := decodeYomiJSON(raw, &body); err != nil {
		return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
	}
	config := yomiConfigFromPlatform(platform)
	session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatAuthCode, body.GameUID.String())
	// 同时校验 token、用户和游戏，避免 A 游戏拿到的 token 被挪到 B 游戏或别的用户。
	if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
		return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
	}
	snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
	if err != nil {
		return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
	}
	data := map[string]any{
		// platUserId 必须原样返回，Yomi 文档明确要求保持业务含义稳定。
		"platUserId":     body.PlatUserID,
		"platUserOpenId": session.DisplayUserID,
		"nickname":       snapshot.Nickname,
		"avatar":         snapshot.Avatar,
		"balance":        snapshot.Balance,
		"platToken":      body.PlatAuthCode,
		"expireAt":       session.ExpiresAtMS / 1000,
	}
	rawResp, contentType := yomiJSON(yomiCodeOK, "success", data)
	return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
}

func (s *Service) handleYomiUserInfo(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte) ([]byte, string, string, bool, string, error) {
	// userinfo 是游戏侧进入房间或中途刷新玩家资料时调用，只读用户资料和当前金币。
	var body struct {
		PlatUserID  string `json:"platUserId"`
		PlatToken   string `json:"platToken"`
		PlatPayload string `json:"platPayload"`
		PlatRoomID  string `json:"platRoomId"`
	}
	if err := decodeYomiJSON(raw, &body); err != nil {
		return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
	}
	config := yomiConfigFromPlatform(platform)
	session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatToken, "")
	// userinfo 没有 gameUid 时只校验用户；change_balance 会额外校验游戏 ID。
	if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, "") {
		return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, "")
	}
	snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
	if err != nil {
		return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, "")
	}
	data := map[string]any{
		"platUserId":     body.PlatUserID,
		"platUserOpenId": session.DisplayUserID,
		"nickname":       snapshot.Nickname,
		"avatar":         snapshot.Avatar,
		"balance":        snapshot.Balance,
	}
	rawResp, contentType := yomiJSON(yomiCodeOK, "success", data)
	return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, "", nil
}

func (s *Service) handleYomiChangeBalance(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, raw []byte, requestHash string) ([]byte, string, string, bool, string, error) {
	// change_balance 是唯一真正改钱包的 Yomi 回调；requestId 是厂商幂等键，必须落库去重。
	var body struct {
		RequestID    json.Number `json:"requestId"`
		PlatUserID   string      `json:"platUserId"`
		PlatToken    string      `json:"platToken"`
		Gold         int64       `json:"gold"`
		GameUID      json.Number `json:"gameUid"`
		GameLevelUID json.Number `json:"gameLevelUid"`
		ReasonType   int32       `json:"reasonType"`
		GameRoundID  json.Number `json:"gameRoundId"`
		Timestamp    int64       `json:"timestamp"`
		PlatPayload  string      `json:"platPayload"`
		PlatRoomID   string      `json:"platRoomId"`
		GameInfos    []any       `json:"game_infos"`
	}
	if err := decodeYomiJSON(raw, &body); err != nil {
		return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
	}
	providerOrderID := strings.TrimSpace(body.RequestID.String())
	config := yomiConfigFromPlatform(platform)
	session, err := s.validScopedYomiSession(ctx, app, platform.PlatformCode, body.PlatToken, body.GameUID.String())
	// 改账必须绑定 token、uid、gameUid 三个维度，防止跨用户、跨游戏复用合法 token。
	if err != nil || !yomiSessionMatches(session, config, body.PlatUserID, body.GameUID.String()) {
		return yomiAdapterError(yomiCodeTokenInvalid, "token invalid", true, providerOrderID)
	}
	opType := yomiOpType(body.ReasonType)
	// reasonType=1 扣款，reasonType=2 加款；gold=0 是免费局/未中奖的合法结算。
	if providerOrderID == "" || opType == "" || body.Gold < 0 {
		return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
	}
	result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, body.GameRoundID.String(), opType, body.Gold, body.PlatRoomID, requestHash)
	if err != nil {
		// 厂商要求失败也返回当前余额，查不到时 bestEffortCoinBalance 会退化为 0。
		code := yomiCodeFromError(err)
		message := yomiMessageFromError(err)
		balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
		rawResp, contentType := yomiJSON(code, message, map[string]any{"balance": balance})
		return rawResp, contentType, strconv.Itoa(code), true, providerOrderID, nil
	}
	rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{"balance": result.BalanceAfter})
	return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil
}

func (s *Service) handleYomiRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, raw []byte) ([]byte, string, string, bool, string, error) {
	// 补单接口首版只做简单校验和审计落库，不直接补钱，符合当前“先写 Log”边界。
	var body struct {
		RequestID   json.Number `json:"requestId"`
		OrderID     string      `json:"orderId"`
		PlatUserID  string      `json:"platUserId"`
		Gold        int64       `json:"gold"`
		GameUID     json.Number `json:"gameUid"`
		GameRoundID json.Number `json:"gameRoundId"`
		RewardType  int32       `json:"rewardType"`
		WinID       string      `json:"winId"`
		PlatRoomID  string      `json:"platRoomId"`
	}
	if err := decodeYomiJSON(raw, &body); err != nil {
		return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, "")
	}
	providerOrderID := strings.TrimSpace(body.OrderID)
	// 不同厂商/版本可能只给 requestId，这里把 orderId 和 requestId 都兼容成 providerOrderID。
	if providerOrderID == "" {
		providerOrderID = strings.TrimSpace(body.RequestID.String())
	}
	if providerOrderID == "" || strings.TrimSpace(body.PlatUserID) == "" || strings.TrimSpace(body.GameUID.String()) == "" || body.Gold < 0 {
		return yomiAdapterError(yomiCodeSystemError, "invalid payload", true, providerOrderID)
	}
	_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
		AppCode:         app,
		RepairID:        "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+providerOrderID),
		PlatformCode:    platform.PlatformCode,
		ProviderOrderID: providerOrderID,
		Reason:          "yomi_repair_order",
		Status:          gamedomain.RepairStatusLogged,
	})
	if err != nil {
		return yomiAdapterError(yomiCodeFromError(err), yomiMessageFromError(err), true, providerOrderID)
	}
	rawResp, contentType := yomiJSON(yomiCodeOK, "success", map[string]any{})
	return rawResp, contentType, strconv.Itoa(yomiCodeOK), true, providerOrderID, nil
}

type yomiCoinChangeResult struct {
	BalanceAfter     int64
	IdempotentReplay bool
}

func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, session gamedomain.LaunchSession, providerOrderID string, providerRoundID string, opType string, amount int64, roomID string, requestHash string) (yomiCoinChangeResult, error) {
	regionID, countryID := session.RegionID, session.CountryID
	if regionID <= 0 || countryID <= 0 {
		// Baishun/Lingxian/Reyou/ZeeOne 允许直接把 App access token 当厂商会话，此时 JWT 只携带用户身份，
		// 没有启动会话里的区域快照。钱包改账前补齐主数据维度，保证订单和 GameOrderSettled 不会落入 region_id=0。
		resolvedRegionID, resolvedCountryID, err := s.resolveUserDimensions(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
		if err != nil {
			return yomiCoinChangeResult{}, err
		}
		if regionID <= 0 {
			regionID = resolvedRegionID
		}
		if countryID <= 0 {
			countryID = resolvedCountryID
		}
	}
	// order_id 用平台订单号稳定派生，保证厂商重试、服务重启、并发请求都命中同一条订单。
	order := gamedomain.GameOrder{
		AppCode:         app,
		OrderID:         "gord_" + stableHash(app+"|"+req.GetPlatformCode()+"|"+providerOrderID),
		PlatformCode:    strings.TrimSpace(req.GetPlatformCode()),
		ProviderOrderID: providerOrderID,
		ProviderRoundID: providerRoundID,
		GameID:          session.GameID,
		ProviderGameID:  session.ProviderGameID,
		UserID:          session.UserID,
		RoomID:          strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
		RegionID:        regionID,
		CountryID:       countryID,
		OpType:          opType,
		CoinAmount:      amount,
		Status:          gamedomain.OrderStatusWalletApplying,
		RequestHash:     requestHash,
	}
	order, exists, err := s.repository.CreateGameOrder(ctx, order)
	if err != nil {
		return yomiCoinChangeResult{}, err
	}
	if exists && order.Status == gamedomain.OrderStatusSucceeded {
		// 已成功订单按幂等重放处理，直接返回历史余额，不能再次调用钱包。
		return yomiCoinChangeResult{BalanceAfter: order.WalletBalanceAfter, IdempotentReplay: true}, nil
	}
	if amount == 0 {
		// Yomi 文档允许投注/返奖金额为 0；这类订单仍需标记成功，但不能产生钱包流水。
		balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
		if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, "", balance, s.now().UnixMilli()); err != nil {
			return yomiCoinChangeResult{}, err
		}
		return yomiCoinChangeResult{BalanceAfter: balance}, nil
	}
	if s.wallet == nil {
		return yomiCoinChangeResult{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
	}
	// wallet-service 持有真实余额，game-service 只传入平台订单和幂等命令 ID。
	walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
		RequestId:       req.GetMeta().GetRequestId(),
		AppCode:         app,
		CommandId:       "game:" + strings.TrimSpace(req.GetPlatformCode()) + ":" + providerOrderID,
		UserId:          session.UserID,
		PlatformCode:    strings.TrimSpace(req.GetPlatformCode()),
		GameId:          session.GameID,
		ProviderOrderId: providerOrderID,
		ProviderRoundId: providerRoundID,
		OpType:          opType,
		CoinAmount:      amount,
		RoomId:          firstNonEmpty(roomID, session.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 yomiCoinChangeResult{}, err
	}
	if err := s.repository.MarkOrderSucceeded(ctx, app, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMs); err != nil {
		return yomiCoinChangeResult{}, err
	}
	return yomiCoinChangeResult{BalanceAfter: walletResp.GetBalanceAfter()}, nil
}

type yomiUserSnapshot struct {
	// 厂商只需要展示资料和金币，不把内部用户完整模型暴露给适配器。
	Nickname string
	Avatar   string
	Balance  int64
}

func (s *Service) yomiUserSnapshot(ctx context.Context, app string, req *gamev1.CallbackRequest, userID int64) (yomiUserSnapshot, error) {
	// 用户资料走 user-service，金币走 wallet-service，避免 game-service 缓存造成余额不准。
	if s.user == nil || s.wallet == nil {
		return yomiUserSnapshot{}, xerr.New(xerr.Unavailable, "user or wallet client is not configured")
	}
	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: userID,
	})
	if err != nil {
		return yomiUserSnapshot{}, err
	}
	return yomiUserSnapshot{
		Nickname: userResp.GetUser().GetUsername(),
		Avatar:   userResp.GetUser().GetAvatar(),
		Balance:  s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), userID),
	}, nil
}

func (s *Service) bestEffortCoinBalance(ctx context.Context, app string, requestID string, userID int64) int64 {
	// 余额查询失败时不阻断错误响应；厂商协议更需要一个可解析的 balance 字段。
	if s.wallet == nil || userID <= 0 {
		return 0
	}
	resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
		RequestId:  requestID,
		AppCode:    app,
		UserId:     userID,
		AssetTypes: []string{"COIN"},
	})
	if err != nil {
		return 0
	}
	for _, item := range resp.GetBalances() {
		if item.GetAssetType() == "COIN" {
			return item.GetAvailableAmount()
		}
	}
	return 0
}

func (s *Service) validYomiSession(ctx context.Context, app string, token string) (gamedomain.LaunchSession, error) {
	// 启动 token 明文只出现在启动 URL 和厂商回调里，数据库里按 hash 查询。
	if strings.TrimSpace(token) == "" {
		return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
	}
	session, err := s.repository.GetLaunchSessionByToken(ctx, app, token)
	if err != nil {
		return gamedomain.LaunchSession{}, err
	}
	if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() {
		return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
	}
	return session, nil
}

func (s *Service) validScopedYomiSession(ctx context.Context, app string, platformCode string, token string, providerGameID string) (gamedomain.LaunchSession, error) {
	// 同一个 App token 可能同时启动多个厂商游戏，所以 Yomi 回调必须带平台和可选 gameUid 查询，不能只按 token 命中任意旧 session。
	if strings.TrimSpace(token) == "" {
		return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "token invalid")
	}
	session, err := s.repository.GetLaunchSessionByTokenScope(ctx, app, token, platformCode, providerGameID)
	if err != nil {
		return gamedomain.LaunchSession{}, err
	}
	if session.Status != gamedomain.SessionActive || session.ExpiresAtMS <= s.now().UnixMilli() {
		return gamedomain.LaunchSession{}, xerr.New(xerr.SessionExpired, "token expired")
	}
	return session, nil
}

func yomiSessionMatches(session gamedomain.LaunchSession, config yomiAdapterConfig, platUserID string, gameUID string) bool {
	// gameUID 允许为空是为了兼容 userinfo；只要厂商传了就必须和启动会话里的 provider_game_id 一致。
	if strings.TrimSpace(gameUID) != "" && strings.TrimSpace(gameUID) != strings.TrimSpace(session.ProviderGameID) {
		return false
	}
	expectedUserID := externalUserID(session, config.UIDMode)
	return strings.TrimSpace(platUserID) == expectedUserID
}

func yomiOpType(reasonType int32) string {
	// 转成钱包服务理解的 debit/credit，厂商枚举不向下游扩散。
	switch reasonType {
	case 1:
		return "debit"
	case 2:
		return "credit"
	default:
		return ""
	}
}

func decodeYomiJSON(raw []byte, out any) error {
	// UseNumber 避免 requestId/gameRoundId 这类 64 位数字经过 float64 丢精度。
	decoder := json.NewDecoder(strings.NewReader(string(raw)))
	decoder.UseNumber()
	return decoder.Decode(out)
}

func decryptYomiBody(raw []byte, secret string) ([]byte, error) {
	// 文档给的 AppSecret 是 32 字节字符串，正好对应 AES-256 key。
	secret = strings.TrimSpace(secret)
	if secret == "" {
		return nil, xerr.New(xerr.InvalidArgument, "yomi secret is empty")
	}
	decoded, err := decodeYomiBase64(strings.TrimSpace(string(raw)))
	if err != nil {
		return nil, err
	}
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	if len(decoded) <= gcm.NonceSize() {
		return nil, xerr.New(xerr.InvalidArgument, "yomi payload is too short")
	}
	// Yomi 把 IV/nonce 放在密文前 12 位，剩余部分是 GCM ciphertext+tag。
	nonce := decoded[:gcm.NonceSize()]
	ciphertext := decoded[gcm.NonceSize():]
	return gcm.Open(nil, nonce, ciphertext, nil)
}

func decodeYomiBase64(value string) ([]byte, error) {
	// 文档写 URL base64，但测试工具经常混用 padded/raw/std，这里按宽松解析提升联调成功率。
	encodings := []*base64.Encoding{
		base64.URLEncoding,
		base64.RawURLEncoding,
		base64.StdEncoding,
		base64.RawStdEncoding,
	}
	var lastErr error
	for _, encoding := range encodings {
		decoded, err := encoding.DecodeString(value)
		if err == nil {
			return decoded, nil
		}
		lastErr = err
	}
	return nil, lastErr
}

func callbackIPAllowed(req *gamev1.CallbackRequest, whitelist []string) bool {
	// 空白名单表示不限制；配置后支持单 IP 和 CIDR，便于同时兼容测试环境和正式机房。
	if len(whitelist) == 0 {
		return true
	}
	ip := net.ParseIP(callbackRequestIP(req))
	if ip == nil {
		return false
	}
	for _, raw := range whitelist {
		value := strings.TrimSpace(raw)
		if value == "" {
			continue
		}
		if _, network, err := net.ParseCIDR(value); err == nil && network.Contains(ip) {
			return true
		}
		if allowed := net.ParseIP(value); allowed != nil && allowed.Equal(ip) {
			return true
		}
	}
	return false
}

func callbackRequestIP(req *gamev1.CallbackRequest) string {
	// 网关会把真实来源 IP 写在转发头里；优先取第一段 X-Forwarded-For。
	for _, key := range []string{"X-Forwarded-For", "x-forwarded-for"} {
		if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
			return strings.TrimSpace(strings.Split(raw, ",")[0])
		}
	}
	for _, key := range []string{"X-Real-IP", "x-real-ip"} {
		if raw := strings.TrimSpace(req.GetHeaders()[key]); raw != "" {
			return raw
		}
	}
	host, _, err := net.SplitHostPort(strings.TrimSpace(req.GetRemoteAddr()))
	if err == nil {
		return host
	}
	return strings.TrimSpace(req.GetRemoteAddr())
}

func yomiAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
	// 返回值同时服务“HTTP 响应”和“回调日志”，所以这里保留签名状态和平台请求号。
	raw, contentType := yomiJSON(code, message, map[string]any{})
	return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}

func yomiJSON(code int, message string, data any) ([]byte, string) {
	// Yomi 成功 message 固定 success；失败 message 给具体错误原因，data 必须始终是对象。
	if strings.TrimSpace(message) == "" {
		message = "success"
	}
	if data == nil {
		data = map[string]any{}
	}
	return jsonResponse(map[string]any{"code": code, "message": message, "data": data})
}

func yomiCodeFromError(err error) int {
	// 内部错误码在适配器边界转换成厂商错误码，避免污染业务服务的错误模型。
	switch {
	case err == nil:
		return yomiCodeOK
	case xerr.IsCode(err, xerr.InsufficientBalance):
		return yomiCodeInsufficientBalance
	case xerr.IsCode(err, xerr.SessionExpired), xerr.IsCode(err, xerr.Unauthorized):
		return yomiCodeTokenInvalid
	case xerr.IsCode(err, xerr.NotFound):
		return yomiCodeUserNotFound
	case xerr.IsCode(err, xerr.Conflict), xerr.IsCode(err, xerr.IdempotencyConflict):
		return yomiCodeSystemError
	default:
		return yomiCodeSystemError
	}
}

func yomiMessageFromError(err error) string {
	switch yomiCodeFromError(err) {
	case yomiCodeInsufficientBalance:
		return "gold not enough"
	case yomiCodeTokenInvalid:
		return "token invalid"
	case yomiCodeUserNotFound:
		return "user not found"
	default:
		if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
			return xerr.MessageOf(err)
		}
		return "system error"
	}
}

func firstNonEmpty(values ...string) string {
	// roomId 这类字段优先使用回调里的实时值，缺失时回退启动会话里的值。
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}
