package game

import (
	"context"
	"crypto/md5"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"time"

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

const (
	// ZeeOne 文档定义 code=0 成功；HTTP 200 不代表业务成功，游戏侧读取 JSON code。
	zeeoneCodeOK                  = 0
	zeeoneCodeInvalidArgument     = 1
	zeeoneCodeSessionNotFound     = 2
	zeeoneCodeSignatureInvalid    = 3
	zeeoneCodeSessionInvalid      = 5
	zeeoneCodeInsufficientBalance = 6

	zeeoneDefaultTokenTTL       = 24 * time.Hour
	zeeoneSignatureValidWindow  = 15 * time.Second
	zeeoneDefaultSuccessMessage = "succeed"
)

type zeeoneAdapterConfig struct {
	// merchant_id/platform_id 来自 ZeeOne 商户后台；启动 URL 和 appId 校验都依赖这两个值。
	MerchantID      int64  `json:"merchant_id"`
	PlatformID      int64  `json:"platform_id"`
	PlatformIDAlias int64  `json:"platform"`
	AppID           string `json:"app_id"`
	UIDMode         string `json:"uid_mode"`
	DefaultLang     string `json:"default_lang"`
	TokenTTLSeconds int64  `json:"token_ttl_seconds"`
	GameMode        string `json:"game_mode"`
	Extra           string `json:"extra"`
	// game_urls 解决同一个 ZeeOne 商户下每款游戏 H5 域名/路径不同的问题；后台改 JSON 后下一次启动即生效。
	GameURLs map[string]string `json:"game_urls"`
	// launch_url_template 适合 ZeeOne 后续按固定路径批量上新，支持 {provider_game_id}/{providerGameId}/{game_id}/{gameId} 占位。
	LaunchURLTemplate string `json:"launch_url_template"`
}

func zeeoneConfigFromPlatform(value any) zeeoneAdapterConfig {
	var raw string
	switch typed := value.(type) {
	case gamedomain.Platform:
		raw = typed.AdapterConfigJSON
	case gamedomain.LaunchableGame:
		raw = typed.AdapterConfigJSON
	}
	var config zeeoneAdapterConfig
	_ = json.Unmarshal([]byte(strings.TrimSpace(raw)), &config)
	if config.PlatformID == 0 {
		config.PlatformID = config.PlatformIDAlias
	}
	config.AppID = strings.TrimSpace(config.AppID)
	config.UIDMode = strings.ToLower(strings.TrimSpace(config.UIDMode))
	config.DefaultLang = strings.TrimSpace(config.DefaultLang)
	config.GameMode = strings.TrimSpace(config.GameMode)
	config.Extra = strings.TrimSpace(config.Extra)
	config.LaunchURLTemplate = strings.TrimSpace(config.LaunchURLTemplate)
	config.GameURLs = normalizeStringMap(config.GameURLs)
	return config
}

func (c zeeoneAdapterConfig) TokenTTL() time.Duration {
	if c.TokenTTLSeconds > 0 {
		return time.Duration(c.TokenTTLSeconds) * time.Second
	}
	return zeeoneDefaultTokenTTL
}

func (c zeeoneAdapterConfig) ExpectedAppID() string {
	if c.AppID != "" {
		return c.AppID
	}
	if c.MerchantID > 0 && c.PlatformID > 0 {
		return fmt.Sprintf("M%d_P%d", c.MerchantID, c.PlatformID)
	}
	return ""
}

func zeeoneLaunchBaseURL(game gamedomain.LaunchableGame, config zeeoneAdapterConfig) string {
	for _, key := range []string{game.ProviderGameID, game.GameID, strings.ToLower(game.GameID)} {
		if raw := strings.TrimSpace(config.GameURLs[strings.TrimSpace(key)]); raw != "" {
			return raw
		}
	}
	if config.LaunchURLTemplate == "" {
		return ""
	}
	replacer := strings.NewReplacer(
		"{provider_game_id}", strings.TrimSpace(game.ProviderGameID),
		"{providerGameId}", strings.TrimSpace(game.ProviderGameID),
		"{game_id}", strings.TrimSpace(game.GameID),
		"{gameId}", strings.TrimSpace(game.GameID),
	)
	return strings.TrimSpace(replacer.Replace(config.LaunchURLTemplate))
}

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

func (s *Service) handleZeeOneOperation(ctx context.Context, app string, platform gamedomain.Platform, req *gamev1.CallbackRequest, operation string, requestHash string) ([]byte, string, string, bool, string, error) {
	config := zeeoneConfigFromPlatform(platform)
	body, signed, decoded, providerRequestID := s.decodeZeeOneSignedBody(platform, req)
	if !signed {
		raw, contentType := zeeoneJSON(zeeoneCodeSignatureInvalid, "signature invalid", map[string]any{})
		return raw, contentType, strconv.Itoa(zeeoneCodeSignatureInvalid), false, providerRequestID, nil
	}
	if !decoded {
		raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid argument", map[string]any{})
		return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil
	}
	if err := validateZeeOneAppID(config, body.AppID); err != nil {
		raw, contentType := zeeoneJSON(zeeoneCodeInvalidArgument, "invalid appId", map[string]any{})
		return raw, contentType, strconv.Itoa(zeeoneCodeInvalidArgument), signed, providerRequestID, nil
	}
	switch operation {
	case "get_user_info", "userinfo", "user_info":
		return s.handleZeeOneUserInfo(ctx, app, config, req, body)
	case "change_balance", "change_coin", "update_coin":
		return s.handleZeeOneChangeBalance(ctx, app, config, req, body, requestHash)
	case "update_session", "refresh_session":
		return s.handleZeeOneUpdateSession(ctx, app, req.GetPlatformCode(), config, body)
	case "repair_order", "repair":
		return s.handleZeeOneRepairOrder(ctx, app, platform, body)
	default:
		raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{})
		return raw, contentType, strconv.Itoa(zeeoneCodeOK), signed, providerRequestID, nil
	}
}

type zeeoneSignedEnvelope struct {
	ReqBody        string `json:"reqbody"`
	Signature      string `json:"signature"`
	SignatureNonce string `json:"signature_nonce"`
	Timestamp      int64  `json:"timestamp"`
}

type zeeoneBusinessBody struct {
	AppID        string `json:"appId"`
	UserID       string `json:"userId"`
	SessionID    string `json:"sessionId"`
	Extra        string `json:"extra"`
	CurrencyDiff int64  `json:"currency_diff"`
	DiffMsg      string `json:"diff_msg"`
	GameID       int64  `json:"game_id"`
	RoomID       string `json:"room_id"`
	ChangeTimeAt int64  `json:"change_time_at"`
	OrderID      string `json:"order_id"`
	Extend       string `json:"extend"`
}

func (s *Service) decodeZeeOneSignedBody(platform gamedomain.Platform, req *gamev1.CallbackRequest) (zeeoneBusinessBody, bool, bool, string) {
	var envelope zeeoneSignedEnvelope
	if err := json.Unmarshal(req.GetRawBody(), &envelope); err != nil {
		return zeeoneBusinessBody{}, false, false, ""
	}
	envelope.ReqBody = strings.TrimSpace(envelope.ReqBody)
	providerRequestID := zeeoneProviderRequestID(envelope.ReqBody)
	if !s.zeeoneSignatureValid(envelope, platform.CallbackSecretCiphertext) {
		return zeeoneBusinessBody{}, false, false, providerRequestID
	}
	var body zeeoneBusinessBody
	decoder := json.NewDecoder(strings.NewReader(envelope.ReqBody))
	decoder.UseNumber()
	if err := decoder.Decode(&body); err != nil {
		return zeeoneBusinessBody{}, false, true, providerRequestID
	}
	body.normalize()
	return body, true, true, firstNonEmpty(body.OrderID, providerRequestID)
}

func (s *Service) zeeoneSignatureValid(envelope zeeoneSignedEnvelope, secret string) bool {
	secret = strings.TrimSpace(secret)
	if secret == "" || envelope.ReqBody == "" || strings.TrimSpace(envelope.SignatureNonce) == "" || envelope.Timestamp <= 0 {
		return false
	}
	now := s.now()
	requestAt := time.UnixMilli(envelope.Timestamp)
	if requestAt.After(now.Add(zeeoneSignatureValidWindow)) || now.Sub(requestAt) > zeeoneSignatureValidWindow {
		return false
	}
	raw := fmt.Sprintf("%s%s%d%s", strings.TrimSpace(envelope.SignatureNonce), envelope.ReqBody, envelope.Timestamp, secret)
	sum := md5.Sum([]byte(raw))
	expected := hex.EncodeToString(sum[:])
	return strings.EqualFold(strings.TrimSpace(envelope.Signature), expected)
}

func (body *zeeoneBusinessBody) normalize() {
	body.AppID = strings.TrimSpace(body.AppID)
	body.UserID = strings.TrimSpace(body.UserID)
	body.SessionID = strings.TrimSpace(body.SessionID)
	body.Extra = strings.TrimSpace(body.Extra)
	body.DiffMsg = strings.ToLower(strings.TrimSpace(body.DiffMsg))
	body.RoomID = strings.TrimSpace(body.RoomID)
	body.OrderID = strings.TrimSpace(body.OrderID)
	body.Extend = strings.TrimSpace(body.Extend)
}

func validateZeeOneAppID(config zeeoneAdapterConfig, appID string) error {
	expected := config.ExpectedAppID()
	if expected == "" || strings.TrimSpace(appID) == "" {
		return nil
	}
	if strings.TrimSpace(appID) != expected {
		return xerr.New(xerr.InvalidArgument, "appId mismatch")
	}
	return nil
}

func (s *Service) handleZeeOneUserInfo(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
	session, err := s.validZeeOneSession(ctx, app, req.GetPlatformCode(), body.SessionID, body.GameID)
	if err != nil {
		return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
	}
	if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
		return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "")
	}
	snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
	if err != nil {
		return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
	}
	data := map[string]any{
		"userId":     firstNonEmpty(body.UserID, externalUserID(session, config.UIDMode)),
		"name":       snapshot.Nickname,
		"avatar":     snapshot.Avatar,
		"balance":    snapshot.Balance,
		"sessionId":  body.SessionID,
		"expireDate": session.ExpiresAtMS,
	}
	if body.Extra != "" {
		data["extra"] = body.Extra
	}
	raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, data)
	return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil
}

func (s *Service) handleZeeOneChangeBalance(ctx context.Context, app string, config zeeoneAdapterConfig, req *gamev1.CallbackRequest, body zeeoneBusinessBody, requestHash string) ([]byte, string, string, bool, string, error) {
	if body.OrderID == "" || body.UserID == "" || body.SessionID == "" || body.GameID <= 0 || body.DiffMsg == "" {
		return zeeoneAdapterError(zeeoneCodeInvalidArgument, "invalid argument", true, body.OrderID)
	}
	session, err := s.validZeeOneSession(ctx, app, req.GetPlatformCode(), body.SessionID, body.GameID)
	if err != nil {
		return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID)
	}
	if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
		return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, body.OrderID)
	}
	if session.ProviderGameID == "" {
		session.ProviderGameID = strconv.FormatInt(body.GameID, 10)
	}
	if session.GameID == "" {
		session.GameID = session.ProviderGameID
	}
	opType, amount := zeeoneOpType(body.CurrencyDiff)
	providerOrderID := zeeoneCoinChangeProviderOrderID(body.OrderID, opType)
	result, err := s.applyYomiCoinChange(ctx, app, req, session, providerOrderID, strconv.FormatInt(body.ChangeTimeAt, 10), opType, amount, body.RoomID, requestHash)
	if err != nil {
		code := zeeoneCodeFromError(err)
		balance := s.bestEffortCoinBalance(ctx, app, req.GetMeta().GetRequestId(), session.UserID)
		raw, contentType := zeeoneJSON(code, zeeoneMessageFromError(err), map[string]any{"currency_balance": balance})
		return raw, contentType, strconv.Itoa(code), true, body.OrderID, nil
	}
	raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{"currency_balance": result.BalanceAfter})
	return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil
}

func (s *Service) handleZeeOneUpdateSession(ctx context.Context, app string, platformCode string, config zeeoneAdapterConfig, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
	session, err := s.validZeeOneSession(ctx, app, platformCode, body.SessionID, body.GameID)
	if err != nil {
		return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, "")
	}
	if !zeeoneSessionMatches(session, config, body.UserID, body.GameID) {
		return zeeoneAdapterError(zeeoneCodeSessionInvalid, "sessionId invalid", true, "")
	}
	raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{
		"sessionId":  body.SessionID,
		"expireDate": session.ExpiresAtMS,
	})
	return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, "", nil
}

func (s *Service) handleZeeOneRepairOrder(ctx context.Context, app string, platform gamedomain.Platform, body zeeoneBusinessBody) ([]byte, string, string, bool, string, error) {
	if body.OrderID != "" {
		_, _, err := s.repository.CreateRepairOrder(ctx, gamedomain.RepairOrder{
			AppCode:         app,
			RepairID:        "grep_" + stableHash(app+"|"+platform.PlatformCode+"|"+body.OrderID),
			PlatformCode:    platform.PlatformCode,
			ProviderOrderID: body.OrderID,
			Reason:          "zeeone_repair_order",
			Status:          gamedomain.RepairStatusLogged,
		})
		if err != nil {
			return zeeoneAdapterError(zeeoneCodeFromError(err), zeeoneMessageFromError(err), true, body.OrderID)
		}
	}
	raw, contentType := zeeoneJSON(zeeoneCodeOK, zeeoneDefaultSuccessMessage, map[string]any{})
	return raw, contentType, strconv.Itoa(zeeoneCodeOK), true, body.OrderID, nil
}

func (s *Service) validZeeOneSession(ctx context.Context, app string, platformCode string, sessionID string, gameID int64) (gamedomain.LaunchSession, error) {
	sessionID = strings.TrimSpace(sessionID)
	if sessionID == "" {
		return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found")
	}
	// ZeeOne 约定 sessionId 直接使用 App access token；同一个登录 token 可能启动多款游戏。
	// 因此 JWT 必须优先解析，否则会先命中 game_launch_sessions 里旧游戏的 hash，导致 game_id 校验误判。
	if strings.Count(sessionID, ".") == 2 {
		if session, err := s.appSessionFromAccessToken(app, sessionID); err == nil {
			return session, nil
		}
	}
	providerGameID := ""
	if gameID > 0 {
		providerGameID = strconv.FormatInt(gameID, 10)
	}
	// App access token 过期后仍可能处于三方游戏局中；启动链路已经把同一 token 的厂商、游戏和 24h TTL
	// 落到 game_launch_sessions。这里必须按平台和游戏收窄，避免同一 access token 打开多款 ZeeOne 游戏时拿到旧会话。
	if session, err := s.validScopedYomiSession(ctx, app, platformCode, sessionID, providerGameID); err == nil {
		return session, nil
	}
	if strings.Count(sessionID, ".") == 2 {
		return s.appSessionFromAccessToken(app, sessionID)
	}
	return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "sessionId not found")
}

func zeeoneSessionMatches(session gamedomain.LaunchSession, config zeeoneAdapterConfig, userID string, gameID int64) bool {
	if gameID > 0 && strings.TrimSpace(session.ProviderGameID) != "" && strconv.FormatInt(gameID, 10) != strings.TrimSpace(session.ProviderGameID) {
		return false
	}
	userID = strings.TrimSpace(userID)
	return userID == "" ||
		userID == externalUserID(session, config.UIDMode) ||
		userID == strings.TrimSpace(session.DisplayUserID) ||
		userID == strconv.FormatInt(session.UserID, 10)
}

func zeeoneOpType(diff int64) (string, int64) {
	if diff < 0 {
		return "debit", -diff
	}
	return "credit", diff
}

func zeeoneCoinChangeProviderOrderID(orderID string, opType string) string {
	orderID = strings.TrimSpace(orderID)
	opType = strings.ToLower(strings.TrimSpace(opType))
	if orderID == "" || opType == "" {
		return orderID
	}
	return orderID + ":" + opType
}

func zeeoneProviderRequestID(reqBody string) string {
	var body struct {
		OrderID string `json:"order_id"`
	}
	_ = json.Unmarshal([]byte(reqBody), &body)
	return strings.TrimSpace(body.OrderID)
}

func zeeoneAdapterError(code int, message string, signatureValid bool, providerRequestID string) ([]byte, string, string, bool, string, error) {
	raw, contentType := zeeoneJSON(code, message, map[string]any{})
	return raw, contentType, strconv.Itoa(code), signatureValid, providerRequestID, nil
}

func zeeoneJSON(code int, message string, data any) ([]byte, string) {
	if strings.TrimSpace(message) == "" && code == zeeoneCodeOK {
		message = zeeoneDefaultSuccessMessage
	}
	payload := map[string]any{"code": code, "message": strings.TrimSpace(message)}
	if data != nil {
		payload["data"] = data
	}
	return jsonResponse(payload)
}

func zeeoneCodeFromError(err error) int {
	switch {
	case err == nil:
		return zeeoneCodeOK
	case xerr.IsCode(err, xerr.InsufficientBalance):
		return zeeoneCodeInsufficientBalance
	case xerr.IsCode(err, xerr.SessionExpired):
		return zeeoneCodeSessionInvalid
	case xerr.IsCode(err, xerr.Unauthorized):
		return zeeoneCodeSessionNotFound
	default:
		return zeeoneCodeInvalidArgument
	}
}

func zeeoneMessageFromError(err error) string {
	switch zeeoneCodeFromError(err) {
	case zeeoneCodeInsufficientBalance:
		return "coin not enough"
	case zeeoneCodeSessionNotFound:
		return "sessionId not found"
	case zeeoneCodeSessionInvalid:
		return "sessionId invalid"
	default:
		if err != nil && strings.TrimSpace(xerr.MessageOf(err)) != "" {
			return xerr.MessageOf(err)
		}
		return "invalid argument"
	}
}
