package game

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/md5"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"net/url"
	"strconv"
	"strings"
	"testing"
	"time"

	jwt "github.com/golang-jwt/jwt/v5"
	"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/xerr"
	gamedomain "hyapp/services/game-service/internal/domain/game"
)

func TestLaunchGameCreatesSessionAndRejectsMaintenance(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "demo_rocket_001",
				PlatformCode:   "demo",
				ProviderGameID: "rocket_001",
				GameName:       "Rocket",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus: gamedomain.StatusActive,
			APIBaseURL:     "https://provider.example/h5",
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "demo_rocket_001",
		Scene:         gamedomain.SceneVoiceRoom,
		RoomID:        "room_1",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.SessionID == "" || result.ExpiresAtMS != 1700000900000 || !strings.Contains(result.LaunchURL, "session_token=") {
		t.Fatalf("launch result mismatch: %+v", result)
	}
	if repo.session.GameID != "demo_rocket_001" || repo.session.UserID != 42 || repo.session.RegionID != 100 || repo.session.LaunchTokenHash == "" {
		t.Fatalf("launch session was not persisted: %+v", repo.session)
	}

	repo.launchable.PlatformStatus = gamedomain.StatusMaintenance
	_, err = svc.LaunchGame(context.Background(), LaunchCommand{AppCode: "lalu", UserID: 42, GameID: "demo_rocket_001"})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("maintenance game should reject launch, got %v", err)
	}
}

func TestLaunchGameRejectsUserOutsideEnabledWhitelist(t *testing.T) {
	allowed := false
	repo := &fakeRepository{
		accessAllowed: &allowed,
		launchable: gamedomain.LaunchableGame{
			CatalogItem:    gamedomain.CatalogItem{AppCode: "lalu", GameID: "private_slot", PlatformCode: "demo", ProviderGameID: "private_1", GameName: "Private", Status: gamedomain.StatusActive},
			PlatformStatus: gamedomain.StatusActive,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})

	_, err := svc.LaunchGame(context.Background(), LaunchCommand{AppCode: "lalu", UserID: 42, GameID: "private_slot"})
	if !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("user outside enabled whitelist must not discover or launch game, got %v", err)
	}
	if len(repo.sessions) != 0 {
		t.Fatalf("denied launch must not create a session: %+v", repo.sessions)
	}
}

func TestListRecentGamesNormalizesQueryAndDefaultsLimit(t *testing.T) {
	repo := &fakeRepository{
		recentGames: []gamedomain.AppGame{
			{GameID: "viva_2", Name: "Rich Forever"},
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	games, serverTimeMS, err := svc.ListRecentGames(context.Background(), ListRecentGamesQuery{
		UserID:         42,
		Scene:          "",
		Language:       " EN-US ",
		ClientPlatform: " Android ",
	})
	if err != nil {
		t.Fatalf("ListRecentGames failed: %v", err)
	}
	if serverTimeMS != 1700000000000 || len(games) != 1 || games[0].GameID != "viva_2" {
		t.Fatalf("recent games response mismatch: time=%d games=%+v", serverTimeMS, games)
	}
	if repo.lastRecentQuery.AppCode != "lalu" || repo.lastRecentQuery.Scene != gamedomain.SceneVoiceRoom || repo.lastRecentQuery.PageSize != 4 || repo.lastRecentQuery.Language != "en-us" || repo.lastRecentQuery.ClientPlatform != "android" {
		t.Fatalf("recent query normalization mismatch: %+v", repo.lastRecentQuery)
	}
}

func TestGetBridgeScriptUsesFirstActivePlatformConfig(t *testing.T) {
	repo := &fakeRepository{
		platforms: []gamedomain.Platform{
			{
				AppCode:           "lalu",
				PlatformCode:      "vivagames",
				Status:            gamedomain.StatusActive,
				SortOrder:         20,
				AdapterConfigJSON: `{"bridge_script_url":"https://cdn.example.test/bridge-viva.js","bridge_script_version":"20260608.2","bridge_script_sha256":"def"}`,
			},
			{
				AppCode:           "lalu",
				PlatformCode:      "baishun",
				Status:            gamedomain.StatusActive,
				SortOrder:         10,
				AdapterConfigJSON: `{"bridgeScriptUrl":"https://cdn.example.test/bridge.js","bridgeScriptVersion":"20260608.1","bridgeScriptSha256":"abc"}`,
			},
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.GetBridgeScript(context.Background(), " LALU ")
	if err != nil {
		t.Fatalf("GetBridgeScript failed: %v", err)
	}
	if result.URL != "https://cdn.example.test/bridge-viva.js" || result.Version != "20260608.2" || result.SHA256 != "def" || result.ServerTimeMS != 1700000000000 {
		t.Fatalf("bridge script mismatch: %+v", result)
	}
	if repo.lastListPlatformsAppCode != "lalu" || repo.lastListPlatformsStatus != gamedomain.StatusActive {
		t.Fatalf("ListPlatforms query mismatch: app=%q status=%q", repo.lastListPlatformsAppCode, repo.lastListPlatformsStatus)
	}
}

func TestLaunchGameBuildsYomiAuthURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "yomi_fish_001",
				PlatformCode:   "yomi",
				ProviderGameID: "33",
				GameName:       "Fish",
				Status:         gamedomain.StatusActive,
				Orientation:    "landscape",
			},
			PlatformStatus:    gamedomain.StatusActive,
			APIBaseURL:        "https://api-testv2.yomigame.games/gate/gateway/auth",
			AdapterType:       gamedomain.AdapterYomiV4,
			AdapterConfigJSON: `{"default_lang":"en","token_ttl_seconds":86400,"uid_mode":"user_id","game_level_uids":{"33":17},"plat_payload":"{\"env\":\"test\"}"}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "yomi_fish_001",
		RoomID:        "room_1",
		AccessToken:   "app_access_token_yomi",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700086400000 {
		t.Fatalf("yomi launch must use adapter token ttl, got %+v", result)
	}
	parsed, err := url.Parse(result.LaunchURL)
	if err != nil {
		t.Fatalf("parse yomi launch url failed: %v", err)
	}
	query := parsed.Query()
	expected := map[string]string{
		"gameUid":      "33",
		"platUserId":   "42",
		"lang":         "en",
		"gameLevelUid": "17",
		"platRoomId":   "room_1",
		"platAuthCode": "app_access_token_yomi",
		"platPayload":  `{"env":"test"}`,
	}
	for key, want := range expected {
		if got := query.Get(key); got != want {
			t.Fatalf("yomi launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
		}
	}
}

func TestLaunchGameRequiresYomiAuthURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "yomi_fish_001",
				PlatformCode:   "yomi",
				ProviderGameID: "33",
				GameName:       "Fish",
				Status:         gamedomain.StatusActive,
			},
			PlatformStatus:    gamedomain.StatusActive,
			AdapterType:       gamedomain.AdapterYomiV4,
			AdapterConfigJSON: `{"default_lang":"en"}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	_, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:     "lalu",
		UserID:      42,
		GameID:      "yomi_fish_001",
		AccessToken: "app_access_token_yomi",
	})
	if err == nil || !strings.Contains(xerr.MessageOf(err), "yomi auth url is required") {
		t.Fatalf("LaunchGame must reject missing yomi auth url, got %v", err)
	}
}

func TestLaunchGameBuildsLeaderCCURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "lingxian_rocket_001",
				PlatformCode:   "lingxian",
				ProviderGameID: "101",
				GameName:       "Rocket",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus:    gamedomain.StatusActive,
			APIBaseURL:        "https://games.leadercc.com/test/index.html",
			AdapterType:       gamedomain.AdapterLeaderCCV1,
			AdapterConfigJSON: `{"default_lang":"zh-CN","token_ttl_seconds":86400,"uid_mode":"display_user_id"}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "lingxian_rocket_001",
		RoomID:        "room_1",
		AccessToken:   "app_access_token_lingxian",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700086400000 {
		t.Fatalf("leadercc launch must use adapter token ttl, got %+v", result)
	}
	for _, part := range []string{"uid=420001", "token=app_access_token_lingxian", "lang=zh-CN", "roomid=room_1"} {
		if !strings.Contains(result.LaunchURL, part) {
			t.Fatalf("leadercc launch url missing %s: %s", part, result.LaunchURL)
		}
	}
}

func TestLaunchGameUsesLeaderCCPerGameURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode: "lalu", GameID: "lingxian_9", PlatformCode: "lingxian",
				ProviderGameID: "9", GameName: "GREEDY BOX", Status: gamedomain.StatusActive,
			},
			PlatformStatus: gamedomain.StatusActive,
			AdapterType:    gamedomain.AdapterLeaderCCV1,
			AdapterConfigJSON: `{
				"default_lang":"en","uid_mode":"display_user_id",
				"game_urls":{"9":"https://gztest.leadercc.com/lalu_games/greedy_box_half/index.html?pl=lalu"}
			}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode: "lalu", UserID: 42, DisplayUserID: "420001", GameID: "lingxian_9",
		RoomID: "room_1", AccessToken: "app_access_token_lingxian",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	parsed, err := url.Parse(result.LaunchURL)
	if err != nil {
		t.Fatalf("parse launch URL: %v", err)
	}
	if parsed.Host != "gztest.leadercc.com" || parsed.Path != "/lalu_games/greedy_box_half/index.html" {
		t.Fatalf("launch base mismatch: %s", result.LaunchURL)
	}
	for key, want := range map[string]string{"pl": "lalu", "uid": "420001", "token": "app_access_token_lingxian", "lang": "en", "roomid": "room_1"} {
		if got := parsed.Query().Get(key); got != want {
			t.Fatalf("query %s = %q, want %q URL=%s", key, got, want, result.LaunchURL)
		}
	}
}

func TestLaunchGameBuildsZeeOneURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "zeeone_gold_olympus",
				PlatformCode:   "zeeone",
				ProviderGameID: "1021",
				GameName:       "Gold of Olympus",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus:    gamedomain.StatusActive,
			APIBaseURL:        "https://fallback.example.invalid/game.html",
			AdapterType:       gamedomain.AdapterZeeOneV1,
			AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032,"default_lang":"en","token_ttl_seconds":86400,"game_urls":{"1021":"https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html?merchant=0&platform=0"}}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "zeeone_gold_olympus",
		RoomID:        "01",
		AccessToken:   "app_access_token_zeeone",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700086400000 {
		t.Fatalf("zeeone launch must use adapter token ttl, got %+v", result)
	}
	if !strings.HasPrefix(result.LaunchURL, "https://dx3kkv99b7clg.cloudfront.net/test/1021_gold_of_olympus/index.html?") {
		t.Fatalf("zeeone launch url must use per-game configured url: %s", result.LaunchURL)
	}
	for _, part := range []string{"sessionId=app_access_token_zeeone", "language=en", "merchant=307715", "platform=2032", "sid=01"} {
		if !strings.Contains(result.LaunchURL, part) {
			t.Fatalf("zeeone launch url missing %s: %s", part, result.LaunchURL)
		}
	}
}

func TestLaunchGameRequiresZeeOneLaunchURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "zeeone_unknown",
				PlatformCode:   "zeeone",
				ProviderGameID: "9999",
				GameName:       "Unknown ZeeOne Game",
				Status:         gamedomain.StatusActive,
			},
			PlatformStatus:    gamedomain.StatusActive,
			AdapterType:       gamedomain.AdapterZeeOneV1,
			AdapterConfigJSON: `{"merchant_id":307715,"platform_id":2032}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	_, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:     "lalu",
		UserID:      42,
		GameID:      "zeeone_unknown",
		AccessToken: "app_access_token_zeeone",
	})
	if err == nil || !strings.Contains(xerr.MessageOf(err), "zeeone launch url is required") {
		t.Fatalf("LaunchGame must reject missing zeeone launch url, got %v", err)
	}
}

func TestLaunchGameBuildsBaishunURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "baishun_greedy",
				PlatformCode:   "baishun",
				ProviderGameID: "1006",
				GameName:       "Greedy",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus:    gamedomain.StatusActive,
			APIBaseURL:        "https://fallback.example.invalid/game.html",
			AdapterType:       gamedomain.AdapterBaishunV1,
			AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh","default_lang":"2","token_ttl_seconds":86400,"game_mode":"3","scene_mode":1,"currency_icon":"https://cdn.example/coin.png","gsp":101,"game_urls":{"1006":"https://game-cn-test.jieyou.shop/game-packages/greedy/index.html"}}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "baishun_greedy",
		RoomID:        "room_1",
		AccessToken:   "app_access_token_baishun",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700086400000 {
		t.Fatalf("baishun launch must use adapter token ttl, got %+v", result)
	}
	parsed, err := url.Parse(result.LaunchURL)
	if err != nil {
		t.Fatalf("parse baishun launch url failed: %v", err)
	}
	if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://game-cn-test.jieyou.shop/game-packages/greedy/index.html" {
		t.Fatalf("baishun launch url must use per-game configured url: %s", result.LaunchURL)
	}
	query := parsed.Query()
	expected := map[string]string{
		"appChannel":   "mesh",
		"appId":        "21397507",
		"userId":       "42",
		"code":         "app_access_token_baishun",
		"roomId":       "room_1",
		"gameMode":     "3",
		"language":     "2",
		"sceneMode":    "1",
		"isDataByUrl":  "1",
		"bridge_keys":  "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl",
		"currencyIcon": "https://cdn.example/coin.png",
		"gsp":          "101",
	}
	for key, want := range expected {
		if got := query.Get(key); got != want {
			t.Fatalf("baishun launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
		}
	}
}

func TestBaishunConfigGameModeAcceptsNumberAndString(t *testing.T) {
	cases := []struct {
		name string
		raw  string
		want string
	}{
		{
			name: "string_value_from_existing_config",
			raw:  `{"game_mode":"2"}`,
			want: "2",
		},
		{
			name: "numeric_value_from_admin_json_editor",
			raw:  `{"game_mode":2}`,
			want: "2",
		},
		{
			name: "missing_value_keeps_baishun_default",
			raw:  `{}`,
			want: "3",
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			config := baishunConfigFromPlatform(gamedomain.LaunchableGame{AdapterConfigJSON: tc.raw})
			if got := config.GameModeValue(); got != tc.want {
				t.Fatalf("baishun gameMode mismatch: got %q want %q raw=%s", got, tc.want, tc.raw)
			}
		})
	}
}

func TestLaunchGameBuildsVivaGamesURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "vivagames_2",
				PlatformCode:   "vivagames",
				ProviderGameID: "2",
				GameName:       "Viva Demo",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus: gamedomain.StatusActive,
			APIBaseURL:     "https://fallback.example.invalid/game.html",
			AdapterType:    gamedomain.AdapterVivaGamesV1,
			AdapterConfigJSON: `{
				"app_id":1012127974,
				"default_lang":"en-US",
				"token_ttl_seconds":86400,
				"uid_mode":"display_user_id",
				"amount_rate":1,
				"bgm_switch":0,
				"gsp":101,
				"currency_icon":"https://cdn.example/coin.png",
				"bridge_script_url":"https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
				"bridge_script_version":"20260608.1",
				"bridge_script_sha256":"78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
				"game_urls":{"2":"https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html?game_id=2&isShow=1"}
			}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "vivagames_2",
		RoomID:        "room_1",
		AccessToken:   "app_access_token_viva",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700086400000 {
		t.Fatalf("vivagames launch must use adapter token ttl, got %+v", result)
	}
	parsed, err := url.Parse(result.LaunchURL)
	if err != nil {
		t.Fatalf("parse vivagames launch url failed: %v", err)
	}
	if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://dev-rich2.s3.ap-southeast-1.amazonaws.com/richForever.html" {
		t.Fatalf("vivagames launch url must use per-game configured url: %s", result.LaunchURL)
	}
	query := parsed.Query()
	expected := map[string]string{
		"appId":                 "1012127974",
		"userId":                "420001",
		"code":                  "app_access_token_viva",
		"gameId":                "2",
		"metadata":              `{"room_id":"room_1"}`,
		"language":              "en-US",
		"coinType":              "0",
		"amountRate":            "1",
		"bgmSwitch":             "0",
		"gsp":                   "101",
		"currencyIcon":          "https://cdn.example/coin.png",
		"gameConfig":            `{"currencyIcon":"https://cdn.example/coin.png"}`,
		"bridge_keys":           "appId,userId,code,metadata,language,coinType,amountRate,bgmSwitch,gsp,currencyIcon,gameConfig,gameId,bridge_script_url,bridge_script_version,bridge_script_sha256",
		"bridge_script_url":     "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
		"bridgeScriptUrl":       "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
		"bridge_script_version": "20260608.1",
		"bridgeScriptVersion":   "20260608.1",
		"bridge_script_sha256":  "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
		"bridgeScriptSha256":    "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
	}
	for key, want := range expected {
		if got := query.Get(key); got != want {
			t.Fatalf("vivagames launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
		}
	}
}

func TestLaunchGameBuildsReyouURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "reyou_101",
				PlatformCode:   "reyou",
				ProviderGameID: "101",
				GameName:       "Reyou Demo",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus: gamedomain.StatusActive,
			APIBaseURL:     "https://fallback.example.invalid/game.html",
			AdapterType:    gamedomain.AdapterReyouV1,
			AdapterConfigJSON: `{
				"default_lang":"en",
				"token_ttl_seconds":86400,
				"uid_mode":"display_user_id",
				"bridge_script_url":"https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
				"bridge_script_version":"20260608.1",
				"bridge_script_sha256":"78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
				"game_urls":{"101":"https://games.reyou.example/test/box/half/?game_id=101"}
			}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "reyou_101",
		RoomID:        "room_1",
		AccessToken:   "app_access_token_reyou",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700086400000 {
		t.Fatalf("reyou launch must use adapter token ttl, got %+v", result)
	}
	parsed, err := url.Parse(result.LaunchURL)
	if err != nil {
		t.Fatalf("parse reyou launch url failed: %v", err)
	}
	if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://games.reyou.example/test/box/half/" {
		t.Fatalf("reyou launch url must use per-game configured url: %s", result.LaunchURL)
	}
	expected := map[string]string{
		"game_id":               "101",
		"uid":                   "420001",
		"token":                 "app_access_token_reyou",
		"lang":                  "en",
		"bridge_script_url":     "https://cdn.example/game-bridge/voice-room-game-bridge.v1.js",
		"bridge_script_version": "20260608.1",
		"bridge_script_sha256":  "78f0d2d0b52d5711c1f2a9e8d3af8a9d3db7e03c83f5bd8d2b8e629f1f5b6c13",
	}
	for key, want := range expected {
		if got := parsed.Query().Get(key); got != want {
			t.Fatalf("reyou launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
		}
	}
}

func TestLaunchGameBuildsZGameURL(t *testing.T) {
	token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700007200)
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "zgame_1001",
				PlatformCode:   gamedomain.PlatformCodeZGame,
				ProviderGameID: "1001",
				GameName:       "ZGame Demo",
				Status:         gamedomain.StatusActive,
				Orientation:    "portrait",
			},
			PlatformStatus: gamedomain.StatusActive,
			APIBaseURL:     "https://fallback.example.invalid/zgame.html",
			AdapterType:    gamedomain.AdapterZGameV1,
			AdapterConfigJSON: `{
				"default_lang":"en",
				"token_ttl_seconds":7200,
				"uid_mode":"display_user_id",
				"merchant_payload":"room_game",
				"callback_base":"https://api.example.test/api/v1/zgame/callback",
				"bridge_script_url":"https://cdn.example/zgame-bridge.js",
				"bridge_script_version":"20260629.1",
				"game_urls":{"1001":"https://zgame.example/h5/1001"}
			}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "zgame_1001",
		RoomID:        "room_1",
		AccessToken:   token,
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.ExpiresAtMS != 1700007200000 {
		t.Fatalf("zgame launch must use adapter token ttl, got %+v", result)
	}
	parsed, err := url.Parse(result.LaunchURL)
	if err != nil {
		t.Fatalf("parse zgame launch url failed: %v", err)
	}
	if parsed.Scheme+"://"+parsed.Host+parsed.Path != "https://zgame.example/h5/1001" {
		t.Fatalf("zgame launch url must use per-game configured url: %s", result.LaunchURL)
	}
	jsCode := parsed.Query().Get("js_code")
	if jsCode == "" || !strings.HasPrefix(jsCode, "gt_") || jsCode == token {
		t.Fatalf("zgame launch must expose a scoped js_code instead of app token: %q", jsCode)
	}
	if repo.session.LaunchTokenHash != stableHash(jsCode) {
		t.Fatalf("zgame launch session must be bound to scoped js_code")
	}
	expected := map[string]string{
		"channelName":           "laluparty",
		"openId":                "420001",
		"open_id":               "420001",
		"js_code":               jsCode,
		"jscode":                jsCode,
		"gameId":                "1001",
		"game_id":               "1001",
		"callbackBase":          "https://api.example.test/api/v1/zgame/callback",
		"callback_base":         "https://api.example.test/api/v1/zgame/callback",
		"lang":                  "en",
		"roomId":                "room_1",
		"room_id":               "room_1",
		"merchantPayload":       "room_game",
		"merchant_payload":      "room_game",
		"bridge_script_url":     "https://cdn.example/zgame-bridge.js",
		"bridgeScriptUrl":       "https://cdn.example/zgame-bridge.js",
		"bridge_script_version": "20260629.1",
		"bridgeScriptVersion":   "20260629.1",
	}
	for key, want := range expected {
		if got := parsed.Query().Get(key); got != want {
			t.Fatalf("zgame launch query %s mismatch: got %q want %q url=%s", key, got, want, result.LaunchURL)
		}
	}
	for _, key := range []string{"token", "code", "session", "cv", "channel_name"} {
		if got := parsed.Query().Get(key); got != "" {
			t.Fatalf("zgame launch query must not expose %s=%q url=%s", key, got, result.LaunchURL)
		}
	}
}

func TestLaunchGameRequiresBaishunLaunchURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "baishun_unknown",
				PlatformCode:   "baishun",
				ProviderGameID: "9999",
				GameName:       "Unknown Baishun Game",
				Status:         gamedomain.StatusActive,
			},
			PlatformStatus:    gamedomain.StatusActive,
			AdapterType:       gamedomain.AdapterBaishunV1,
			AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh"}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	_, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:     "lalu",
		UserID:      42,
		GameID:      "baishun_unknown",
		AccessToken: "app_access_token_baishun",
	})
	if err == nil || !strings.Contains(xerr.MessageOf(err), "baishun launch url is required") {
		t.Fatalf("LaunchGame must reject missing baishun launch url, got %v", err)
	}
}

func TestLaunchGameAllowsLeaderCCWithoutLaunchURL(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "lingxian_rocket_001",
				PlatformCode:   "lingxian",
				ProviderGameID: "101",
				GameName:       "Rocket",
				Status:         gamedomain.StatusActive,
			},
			PlatformStatus:    gamedomain.StatusActive,
			AdapterType:       gamedomain.AdapterLeaderCCV1,
			AdapterConfigJSON: `{"token_ttl_seconds":86400}`,
		},
	}
	svc := New(Config{LaunchSessionTTL: 15 * time.Minute}, repo, &fakeWallet{}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	result, err := svc.LaunchGame(context.Background(), LaunchCommand{
		AppCode:       "lalu",
		UserID:        42,
		DisplayUserID: "420001",
		GameID:        "lingxian_rocket_001",
		AccessToken:   "app_access_token_lingxian",
	})
	if err != nil {
		t.Fatalf("LaunchGame failed: %v", err)
	}
	if result.LaunchURL != "" || repo.session.LaunchTokenHash != stableHash("app_access_token_lingxian") {
		t.Fatalf("leadercc without launch url should only create session: result=%+v session=%+v", result, repo.session)
	}
}

func TestHandleLeaderCCUserInfoValidatesSignAndToken(t *testing.T) {
	key := "leadercc-test-key"
	token := "gt_lingxian_token"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "lingxian",
			AdapterType:              gamedomain.AdapterLeaderCCV1,
			CallbackSecretCiphertext: key,
			CallbackIPWhitelist:      []string{"3.1.174.194"},
			AdapterConfigJSON:        `{"uid_mode":"display_user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_lingxian",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "lingxian",
			GameID:          "lingxian_rocket_001",
			ProviderGameID:  "101",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1680}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	sign := leaderccTestSign(key, "101", "420001", token, "room_1")
	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-user", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "userinfo",
		RawBody:      []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","roomId":"room_1","sign":"` + sign + `"}`),
		RemoteAddr:   "203.0.113.10:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1680`) {
		t.Fatalf("leadercc user response mismatch: %s", raw)
	}
}

func TestHandleLeaderCCUserInfoAcceptsAppAccessTokenWithoutLaunchSession(t *testing.T) {
	key := "leadercc-test-key"
	token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "lingxian",
			AdapterType:              gamedomain.AdapterLeaderCCV1,
			CallbackSecretCiphertext: key,
			AdapterConfigJSON:        `{"uid_mode":"display_user_id"}`,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1680}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	sign := leaderccTestSign(key, "101", "420001", token, "room_1")
	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-app-token-user", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "userinfo",
		RawBody:      []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","roomId":"room_1","sign":"` + sign + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1680`) {
		t.Fatalf("leadercc app-token user response mismatch: %s", raw)
	}
	if user.lastGet.GetUserId() != 42 {
		t.Fatalf("user request must use token user_id, got %+v", user.lastGet)
	}
}

func TestHandleLeaderCCChangeCoinAndRepairOrder(t *testing.T) {
	key := "leadercc-test-key"
	token := "gt_lingxian_token"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "lingxian",
			AdapterType:              gamedomain.AdapterLeaderCCV1,
			CallbackSecretCiphertext: key,
			AdapterConfigJSON:        `{"uid_mode":"user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_lingxian",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "lingxian",
			GameID:          "lingxian_rocket_001",
			ProviderGameID:  "101",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1560}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	changeSign := leaderccTestSign(key, "order_1", "101", "round_1", "42", "120", "1", "2", token, "", "room_1")
	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-change", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "change_coin",
		RawBody:      []byte(`{"orderId":"order_1","gameId":"101","roundId":"round_1","uid":"42","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1560`) {
		t.Fatalf("leadercc change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetCommandId() != "game:lingxian:order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 {
		t.Fatalf("wallet command mismatch: %+v", wallet.lastApply)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-change-replay", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "change_coin",
		RawBody:      []byte(`{"orderId":"order_1","gameId":"101","roundId":"round_1","uid":"42","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("duplicate HandleCallback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"errorCode":10003`) || !strings.Contains(string(raw), `"code":10003`) || !strings.Contains(string(raw), `"coin":1560`) {
		t.Fatalf("leadercc duplicate change response mismatch: %s", raw)
	}

	repairSign := leaderccTestSign(key, "repair_1", "101", "round_2", "42", "50", "2", "", "room_1")
	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-repair", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "repair_order",
		RawBody:      []byte(`{"orderId":"repair_1","gameId":"101","roundId":"round_2","uid":"42","coin":50,"rewardType":2,"winId":"","roomId":"room_1","sign":"` + repairSign + `"}`),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"errorCode":0`) {
		t.Fatalf("repair response mismatch: %s", raw)
	}
	if repo.repair.ProviderOrderID != "repair_1" || repo.repair.Reason != "leadercc_repair_order" {
		t.Fatalf("repair order mismatch: %+v", repo.repair)
	}
}

func TestHandleLeaderCCChangeCoinAcceptsAppAccessTokenAndTokenOnlyProbe(t *testing.T) {
	key := "leadercc-test-key"
	token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "lingxian",
			AdapterType:              gamedomain.AdapterLeaderCCV1,
			CallbackSecretCiphertext: key,
			AdapterConfigJSON:        `{"uid_mode":"display_user_id"}`,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1560}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	probeRaw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-token-probe", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "change_coin",
		RawBody:      []byte(`{"token":"` + token + `"}`),
	})
	if err != nil {
		t.Fatalf("token-only probe failed: %v", err)
	}
	if !strings.Contains(string(probeRaw), `"code":0`) || wallet.lastApply != nil {
		t.Fatalf("token-only probe must return ok without applying wallet: raw=%s wallet=%+v", probeRaw, wallet.lastApply)
	}

	changeSign := leaderccTestSign(key, "order_app_token_1", "101", "round_1", "420001", "120", "1", "2", token, "", "room_1")
	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-leadercc-app-token-change", AppCode: "lalu"},
		PlatformCode: "lingxian",
		Operation:    "change_coin",
		RawBody:      []byte(`{"orderId":"order_app_token_1","gameId":"101","roundId":"round_1","uid":"420001","coin":120,"type":1,"rewardType":2,"token":"` + token + `","winId":"","roomId":"room_1","sign":"` + changeSign + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"coin":1560`) {
		t.Fatalf("leadercc app-token change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetUserId() != 42 || wallet.lastApply.GetGameId() != "101" || wallet.lastApply.GetCommandId() != "game:lingxian:order_app_token_1" {
		t.Fatalf("wallet command must use token user and request game: %+v", wallet.lastApply)
	}
	if repo.order.RegionID != 100 {
		t.Fatalf("app-token game order must resolve current user region before publishing: %+v", repo.order)
	}
}

func TestHandleYomiGetTokenAndUserInfo(t *testing.T) {
	secret := "kEdOjdxgOUCJjG1L6kl3fYEIgMFxQOza"
	token := "app_access_token_yomi"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "yomi",
			AdapterType:              gamedomain.AdapterYomiV4,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"uid_mode":"display_user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_yomi",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "yomi",
			GameID:          "yomi_fish_001",
			ProviderGameID:  "33",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	getTokenBody := encryptYomiTestBody(t, secret, `{"gameUid":33,"lang":"en","platAuthCode":"app_access_token_yomi","platUserId":"420001","platPayload":"","platRoomId":"room_1"}`)
	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-yomi-gettoken", AppCode: "lalu"},
		PlatformCode: "yomi",
		Operation:    "gettoken",
		RawBody:      getTokenBody,
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback gettoken failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platToken":"app_access_token_yomi"`) || !strings.Contains(string(raw), `"balance":1880`) {
		t.Fatalf("yomi gettoken response mismatch: %s", raw)
	}
	if user.lastGet.GetUserId() != 42 {
		t.Fatalf("yomi gettoken must query token user, got %+v", user.lastGet)
	}

	userInfoBody := encryptYomiTestBody(t, secret, `{"platUserId":"420001","platToken":"app_access_token_yomi","platPayload":"","platRoomId":"room_1"}`)
	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-yomi-userinfo", AppCode: "lalu"},
		PlatformCode: "yomi",
		Operation:    "userinfo",
		RawBody:      userInfoBody,
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback userinfo failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platUserId":"420001"`) || !strings.Contains(string(raw), `"avatar":"https://cdn.example/avatar.png"`) {
		t.Fatalf("yomi userinfo response mismatch: %s", raw)
	}
}

func TestHandleYomiGetTokenScopesSharedAccessTokenByPlatformAndGame(t *testing.T) {
	secret := "12345678901234567890123456789012"
	token := "shared_app_access_token"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "yomi",
			AdapterType:              gamedomain.AdapterYomiV4,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"uid_mode":"display_user_id"}`,
		},
		sessions: []gamedomain.LaunchSession{
			{
				AppCode:         "lalu",
				SessionID:       "game_sess_yomi_463",
				UserID:          42,
				DisplayUserID:   "420001",
				RoomID:          "room_463",
				PlatformCode:    "yomi",
				GameID:          "yomi_463",
				ProviderGameID:  "463",
				LaunchTokenHash: stableHash(token),
				Status:          gamedomain.SessionActive,
				ExpiresAtMS:     1700086400000,
				CreatedAtMS:     1700000000000,
			},
			{
				AppCode:         "lalu",
				SessionID:       "game_sess_yomi_464",
				UserID:          42,
				DisplayUserID:   "420001",
				RoomID:          "room_464",
				PlatformCode:    "yomi",
				GameID:          "yomi_464",
				ProviderGameID:  "464",
				LaunchTokenHash: stableHash(token),
				Status:          gamedomain.SessionActive,
				ExpiresAtMS:     1700086400000,
				CreatedAtMS:     1700000001000,
			},
			{
				AppCode:         "lalu",
				SessionID:       "game_sess_reyou_1",
				UserID:          42,
				DisplayUserID:   "420001",
				RoomID:          "room_reyou",
				PlatformCode:    "reyou",
				GameID:          "reyou_1",
				ProviderGameID:  "1",
				LaunchTokenHash: stableHash(token),
				Status:          gamedomain.SessionActive,
				ExpiresAtMS:     1700086400000,
				CreatedAtMS:     1700000002000,
			},
		},
	}
	svc := New(Config{}, repo, &fakeWallet{balanceAfter: 1880}, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-yomi-scoped-gettoken", AppCode: "lalu"},
		PlatformCode: "yomi",
		Operation:    "gettoken",
		RawBody:      encryptYomiTestBody(t, secret, `{"gameUid":463,"lang":"en","platAuthCode":"shared_app_access_token","platUserId":"420001","platPayload":"","platRoomId":"room_463"}`),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"platToken":"shared_app_access_token"`) {
		t.Fatalf("yomi scoped gettoken response mismatch: %s", raw)
	}
}

func TestHandleYomiChangeBalanceDecryptsAndAppliesWallet(t *testing.T) {
	secret := "12345678901234567890123456789012"
	token := "gt_test_token"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "yomi",
			AdapterType:              gamedomain.AdapterYomiV4,
			CallbackSecretCiphertext: secret,
			CallbackIPWhitelist:      []string{"3.1.174.194"},
			AdapterConfigJSON:        `{"uid_mode":"user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_test",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "yomi",
			GameID:          "yomi_fish_001",
			ProviderGameID:  "33",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 880}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	rawBody := encryptYomiTestBody(t, secret, `{"requestId":9001,"platUserId":"42","platToken":"gt_test_token","gold":120,"gameUid":33,"gameLevelUid":0,"reasonType":1,"gameRoundId":88,"timestamp":1700000000,"platPayload":"","platRoomId":"room_1"}`)
	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-yomi-change", AppCode: "lalu"},
		PlatformCode: "yomi",
		Operation:    "change_balance",
		RawBody:      rawBody,
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":200`) || !strings.Contains(string(raw), `"balance":880`) {
		t.Fatalf("yomi change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetCommandId() != "game:yomi:9001" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 {
		t.Fatalf("wallet command mismatch: %+v", wallet.lastApply)
	}
	if repo.order.ProviderOrderID != "9001" || repo.order.ProviderRoundID != "88" || repo.order.GameID != "yomi_fish_001" {
		t.Fatalf("game order mismatch: %+v", repo.order)
	}
}

func TestHandleYomiRepairOrderOnlyLogs(t *testing.T) {
	secret := "12345678901234567890123456789012"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "yomi",
			AdapterType:              gamedomain.AdapterYomiV4,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{}`,
		},
	}
	wallet := &fakeWallet{balanceAfter: 880}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	rawBody := encryptYomiTestBody(t, secret, `{"requestId":9002,"platUserId":"42","gold":50,"gameUid":33,"gameRoundId":89,"rewardType":2,"winId":"","platRoomId":"room_1"}`)
	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-yomi-repair", AppCode: "lalu"},
		PlatformCode: "yomi",
		Operation:    "repair_order",
		RawBody:      rawBody,
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":200`) {
		t.Fatalf("repair response mismatch: %s", raw)
	}
	if repo.repair.ProviderOrderID != "9002" || repo.repair.Status != gamedomain.RepairStatusLogged {
		t.Fatalf("repair order mismatch: %+v", repo.repair)
	}
	if wallet.lastApply != nil {
		t.Fatalf("repair order must not apply wallet change: %+v", wallet.lastApply)
	}
}

func TestHandleZeeOneUserInfoChangeBalanceAndUpdateSession(t *testing.T) {
	secret := "zeeone-test-secret"
	token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700003600)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "zeeone",
			AdapterType:              gamedomain.AdapterZeeOneV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	userBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","game_id":1021,"extra":"{\"vip\":1}"}`
	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zeeone-user", AppCode: "lalu"},
		PlatformCode: "zeeone",
		Operation:    "get_user_info",
		RawBody:      zeeoneTestEnvelope(t, secret, userBody, 1700000000000),
	})
	if err != nil {
		t.Fatalf("HandleCallback userinfo failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"balance":1880`) || !strings.Contains(string(raw), `"sessionId"`) {
		t.Fatalf("zeeone userinfo response mismatch: %s", raw)
	}
	if user.lastGet.GetUserId() != 42 {
		t.Fatalf("zeeone userinfo must query token user, got %+v", user.lastGet)
	}

	changeBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","currency_diff":-120,"diff_msg":"bet","game_id":1021,"room_id":"01","change_time_at":1700000000,"order_id":"zee_order_1","extend":"round_1"}`
	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zeeone-change", AppCode: "lalu"},
		PlatformCode: "zeeone",
		Operation:    "change_balance",
		RawBody:      zeeoneTestEnvelope(t, secret, changeBody, 1700000000000),
	})
	if err != nil {
		t.Fatalf("HandleCallback change failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"currency_balance":1880`) {
		t.Fatalf("zeeone change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetUserId() != 42 || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "1021" {
		t.Fatalf("zeeone wallet command mismatch: %+v", wallet.lastApply)
	}

	updateBody := `{"appId":"M307715_P2032","userId":"420001","sessionId":"` + token + `","game_id":1021}`
	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zeeone-update-session", AppCode: "lalu"},
		PlatformCode: "zeeone",
		Operation:    "update_session",
		RawBody:      zeeoneTestEnvelope(t, secret, updateBody, 1700000000000),
	})
	if err != nil {
		t.Fatalf("HandleCallback update_session failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expireDate":1700003600000`) {
		t.Fatalf("zeeone update_session response mismatch: %s", raw)
	}
}

func TestHandleZeeOneAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
	secret := "zeeone-test-secret"
	token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "zeeone",
			AdapterType:              gamedomain.AdapterZeeOneV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
		},
		// 同一个 App access token 启动过其他 ZeeOne 游戏时，旧启动会话会留下同 hash 但不同 provider_game_id。
		// 回调必须以 JWT 用户身份为准，不能被旧会话的 game_id 拦截成 sessionId invalid。
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "old_launch_session",
			UserID:          123456,
			DisplayUserID:   "123456",
			ProviderGameID:  "1021",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700003600000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 900}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	changeBody := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","currency_diff":-100,"diff_msg":"bet","game_id":1032,"room_id":"6","change_time_at":1700000000,"order_id":"zee_order_stale_session"}`
	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zeeone-stale-session", AppCode: "lalu"},
		PlatformCode: "zeeone",
		Operation:    "change_balance",
		RawBody:      zeeoneTestEnvelope(t, secret, changeBody, 1700000000000),
	})
	if err != nil {
		t.Fatalf("HandleCallback change failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || strings.Contains(string(raw), "sessionId invalid") {
		t.Fatalf("zeeone access token should ignore stale launch session game id: %s", raw)
	}
	if wallet.lastApply.GetUserId() != 123456 || wallet.lastApply.GetGameId() != "1032" {
		t.Fatalf("zeeone wallet command must use JWT user and callback game id, got %+v", wallet.lastApply)
	}
}

func TestHandleZeeOneExpiredAccessTokenUsesScopedLaunchSession(t *testing.T) {
	secret := "zeeone-test-secret"
	userID := int64(328141720820654080)
	token := leaderccTestAccessToken(t, "lalu", userID, "1010", 1700000300)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "zeeone",
			AdapterType:              gamedomain.AdapterZeeOneV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
		},
		sessions: []gamedomain.LaunchSession{
			{
				AppCode:         "lalu",
				SessionID:       "launch_session_1035",
				UserID:          userID,
				DisplayUserID:   "1010",
				PlatformCode:    "zeeone",
				GameID:          "zeeone_1035",
				ProviderGameID:  "1035",
				LaunchTokenHash: stableHash(token),
				Status:          gamedomain.SessionActive,
				ExpiresAtMS:     1700086800000,
				CreatedAtMS:     1700000000000,
			},
			{
				AppCode:         "lalu",
				SessionID:       "newer_wrong_game_session",
				UserID:          userID,
				DisplayUserID:   "1010",
				PlatformCode:    "zeeone",
				GameID:          "zeeone_1021",
				ProviderGameID:  "1021",
				LaunchTokenHash: stableHash(token),
				Status:          gamedomain.SessionActive,
				ExpiresAtMS:     1700086800000,
				CreatedAtMS:     1700000100000,
			},
		},
	}
	wallet := &fakeWallet{balanceAfter: 777}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000400000) }

	changeBody := `{"appId":"M307715_P2032","userId":"1010","sessionId":"` + token + `","currency_diff":-50000,"diff_msg":"bet","game_id":1035,"room_id":"3255","change_time_at":1700000400,"order_id":"zee_order_expired_token_scoped"}`
	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zeeone-expired-scoped", AppCode: "lalu"},
		PlatformCode: "zeeone",
		Operation:    "change_balance",
		RawBody:      zeeoneTestEnvelope(t, secret, changeBody, 1700000400000),
	})
	if err != nil {
		t.Fatalf("HandleCallback change failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || strings.Contains(string(raw), "sessionId invalid") {
		t.Fatalf("zeeone expired JWT must fall back to scoped launch session: %s", raw)
	}
	if wallet.lastApply.GetUserId() != userID || wallet.lastApply.GetGameId() != "zeeone_1035" || wallet.lastApply.GetCoinAmount() != 50000 {
		t.Fatalf("zeeone scoped launch session must drive wallet command, got %+v", wallet.lastApply)
	}
}

func TestHandleZeeOneAllowsDebitAndCreditWithSameProviderOrderID(t *testing.T) {
	secret := "zeeone-test-secret"
	token := leaderccTestAccessToken(t, "lalu", 123456, "123456", 1700003600)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "zeeone",
			AdapterType:              gamedomain.AdapterZeeOneV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"merchant_id":307715,"platform_id":2032,"uid_mode":"display_user_id"}`,
		},
	}
	wallet := &fakeWallet{balanceAfter: 900}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	base := `{"appId":"M307715_P2032","userId":"123456","sessionId":"` + token + `","diff_msg":"settle","game_id":1021,"room_id":"6","change_time_at":1700000000,"order_id":"zee_round_order_1"`
	for _, item := range []struct {
		name      string
		diff      string
		wantOp    string
		wantID    string
		wantCoins int64
	}{
		{name: "debit", diff: "-500", wantOp: "debit", wantID: "game:zeeone:zee_round_order_1:debit", wantCoins: 500},
		{name: "credit", diff: "750", wantOp: "credit", wantID: "game:zeeone:zee_round_order_1:credit", wantCoins: 750},
	} {
		raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
			Meta:         &gamev1.RequestMeta{RequestId: "req-zeeone-" + item.name, AppCode: "lalu"},
			PlatformCode: "zeeone",
			Operation:    "change_balance",
			RawBody:      zeeoneTestEnvelope(t, secret, base+`,"currency_diff":`+item.diff+`}`, 1700000000000),
		})
		if err != nil {
			t.Fatalf("HandleCallback %s failed: %v", item.name, err)
		}
		if !strings.Contains(string(raw), `"code":0`) {
			t.Fatalf("zeeone %s response mismatch: %s", item.name, raw)
		}
		if wallet.lastApply.GetCommandId() != item.wantID || wallet.lastApply.GetOpType() != item.wantOp || wallet.lastApply.GetCoinAmount() != item.wantCoins {
			t.Fatalf("zeeone %s wallet command mismatch: %+v", item.name, wallet.lastApply)
		}
	}
	if wallet.applyCount != 2 {
		t.Fatalf("debit and credit should both reach wallet, got %d applies", wallet.applyCount)
	}
}

func TestHandleBaishunSSTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
	secret := "baishun-test-app-key"
	token := "app_access_token_baishun"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "baishun",
			AdapterType:              gamedomain.AdapterBaishunV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"app_id":21397507,"app_channel":"mesh","uid_mode":"display_user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_baishun",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "baishun",
			GameID:          "baishun_greedy",
			ProviderGameID:  "1006",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-baishun-sstoken", AppCode: "lalu"},
		PlatformCode: "baishun",
		Operation:    "get_sstoken",
		RawBody:      baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","code":"app_access_token_baishun"}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback get_sstoken failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"ss_token":"app_access_token_baishun"`) || !strings.Contains(string(raw), `"user_info"`) || !strings.Contains(string(raw), `"balance":1880`) {
		t.Fatalf("baishun get_sstoken response mismatch: %s", raw)
	}
	if user.lastGet.GetUserId() != 42 {
		t.Fatalf("baishun get_sstoken must query token user, got %+v", user.lastGet)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-baishun-userinfo", AppCode: "lalu"},
		PlatformCode: "baishun",
		Operation:    "get_user_info",
		RawBody:      baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","ss_token":"app_access_token_baishun","client_ip":"110.86.1.130","game_id":1006}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback get_user_info failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"user_id":"420001"`) || !strings.Contains(string(raw), `"user_avatar":"https://cdn.example/avatar.png"`) {
		t.Fatalf("baishun userinfo response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-baishun-update-sstoken", AppCode: "lalu"},
		PlatformCode: "baishun",
		Operation:    "update_sstoken",
		RawBody:      baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","ss_token":"app_access_token_baishun","game_id":1006}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback update_sstoken failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expire_date":1700086400000`) {
		t.Fatalf("baishun update_sstoken response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-baishun-change", AppCode: "lalu"},
		PlatformCode: "baishun",
		Operation:    "change_balance",
		RawBody:      baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","ss_token":"app_access_token_baishun","currency_diff":-120,"diff_msg":"bet","game_id":1006,"room_id":"room_1","game_round_id":"round_1","change_time_at":1700000000,"order_id":"bs_order_1","extend":"{\"source\":\"test\"}"}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback change_balance failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"currency_balance":1880`) || !strings.Contains(string(raw), `"extend":"{\"source\":\"test\"}"`) {
		t.Fatalf("baishun change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetCommandId() != "game:baishun:bs_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "baishun_greedy" {
		t.Fatalf("baishun wallet command mismatch: %+v", wallet.lastApply)
	}
}

func TestHandleVivaGamesTokenUserInfoUpdateAndChangeBalance(t *testing.T) {
	secret := "vivagames-test-app-key"
	token := "app_access_token_viva"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "vivagames",
			AdapterType:              gamedomain.AdapterVivaGamesV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"app_id":1012127974,"uid_mode":"display_user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_viva",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "vivagames",
			GameID:          "vivagames_2",
			ProviderGameID:  "2",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-viva-token", AppCode: "lalu"},
		PlatformCode: "vivagames",
		Operation:    "get-token",
		RawBody:      vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","code":"app_access_token_viva"}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback get-token failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"token":"app_access_token_viva"`) || !strings.Contains(string(raw), `"expire_at":1700086400000`) {
		t.Fatalf("vivagames get-token response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-viva-userinfo", AppCode: "lalu"},
		PlatformCode: "vivagames",
		Operation:    "get-user-info",
		RawBody:      vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva","client_ip":"110.86.1.130","game_id":2}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback get-user-info failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"user_id":"420001"`) || !strings.Contains(string(raw), `"avatar_url":"https://cdn.example/avatar.png"`) || !strings.Contains(string(raw), `"balance":1880`) {
		t.Fatalf("vivagames userinfo response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-viva-update", AppCode: "lalu"},
		PlatformCode: "vivagames",
		Operation:    "update-token",
		RawBody:      vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva"}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback update-token failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"expire_at":1700086400000`) {
		t.Fatalf("vivagames update-token response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-viva-change", AppCode: "lalu"},
		PlatformCode: "vivagames",
		Operation:    "change-balance",
		RawBody:      vivaGamesTestBody(t, secret, `{"app_id":1012127974,"user_id":"420001","token":"app_access_token_viva","order_id":"viva_order_1","game_round_id":"round_1","game_id":2,"total_bet":120,"change_amount":-120,"change_reason":"bet","change_time_at":1700000000,"metadata":"{\"room_id\":\"room_1\"}"}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback change-balance failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || !strings.Contains(string(raw), `"balance":1880`) {
		t.Fatalf("vivagames change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetCommandId() != "game:vivagames:viva_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "vivagames_2" {
		t.Fatalf("vivagames wallet command mismatch: %+v", wallet.lastApply)
	}
}

func TestHandleReyouUserInfoAndUpdateBalance(t *testing.T) {
	secret := "reyou-test-key"
	token := "app_access_token_reyou"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "reyou",
			AdapterType:              gamedomain.AdapterReyouV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"uid_mode":"display_user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_reyou",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "reyou",
			GameID:          "reyou_101",
			ProviderGameID:  "101",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	userInfoSign := reyouTestSign(secret, "101", "420001", token)
	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-reyou-userinfo", AppCode: "lalu"},
		PlatformCode: "reyou",
		Operation:    "getUserInfo",
		RawBody:      []byte(`{"gameId":"101","uid":"420001","token":"` + token + `","sign":"` + userInfoSign + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback getUserInfo failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"uid":"420001"`) || !strings.Contains(string(raw), `"coin":1880`) || !strings.Contains(string(raw), `"vipLevel":0`) {
		t.Fatalf("reyou userinfo response mismatch: %s", raw)
	}
	if user.lastGet.GetUserId() != 42 {
		t.Fatalf("user request mismatch: %+v", user.lastGet)
	}

	changeSign := reyouTestSign(secret, "reyou_order_1", "101", "round_1", "420001", "120", "1", token)
	changeBody := `{"orderId":"reyou_order_1","gameId":"101","roundId":"round_1","uid":"420001","coin":120,"type":1,"token":"` + token + `","sign":"` + changeSign + `"}`
	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-reyou-change", AppCode: "lalu"},
		PlatformCode: "reyou",
		Operation:    "updateBalance",
		RawBody:      []byte(changeBody),
	})
	if err != nil {
		t.Fatalf("HandleCallback updateBalance failed: %v", err)
	}
	if !strings.Contains(string(raw), `"errorCode":0`) || !strings.Contains(string(raw), `"coin":1880`) || !strings.Contains(string(raw), `"responseId":"reyou_`) {
		t.Fatalf("reyou change response mismatch: %s", raw)
	}
	if wallet.lastApply.GetCommandId() != "game:reyou:reyou_order_1" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "reyou_101" {
		t.Fatalf("reyou wallet command mismatch: %+v", wallet.lastApply)
	}
	if wallet.applyCount != 1 {
		t.Fatalf("wallet apply count = %d, want 1", wallet.applyCount)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-reyou-change-replay", AppCode: "lalu"},
		PlatformCode: "reyou",
		Operation:    "updateBalance",
		RawBody:      []byte(changeBody),
	})
	if err != nil {
		t.Fatalf("duplicate updateBalance failed: %v", err)
	}
	if !strings.Contains(string(raw), `"errorCode":3001`) || !strings.Contains(string(raw), `"coin":1880`) {
		t.Fatalf("reyou duplicate response mismatch: %s", raw)
	}
	if wallet.applyCount != 1 {
		t.Fatalf("duplicate order must not apply wallet again, count=%d", wallet.applyCount)
	}
}

func TestHandleCallbackZGameAdapter(t *testing.T) {
	secret := "zgame-test-secret"
	token := leaderccTestAccessToken(t, "lalu", 42, "420001", 1700007200)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             gamedomain.PlatformCodeZGame,
			AdapterType:              gamedomain.AdapterZGameV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"uid_mode":"display_user_id","level":10,"gender":1}`,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zgame-login", AppCode: "lalu"},
		PlatformCode: gamedomain.PlatformCodeZGame,
		Operation:    "server/login",
		RawBody:      []byte(`{"open_id":"420001","js_code":"` + token + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback login failed: %v", err)
	}
	var loginResp map[string]any
	if contentType != "application/json" || json.Unmarshal(raw, &loginResp) != nil {
		t.Fatalf("zgame login response must be json: content_type=%s raw=%s", contentType, raw)
	}
	loginData := loginResp["data"].(map[string]any)
	if loginResp["code"].(float64) != 0 || loginData["session"] != token || loginData["openId"] != "420001" || loginData["userName"] != "tester" || int64(loginData["balance"].(float64)) != 1880 || int64(loginData["level"].(float64)) != 10 {
		t.Fatalf("zgame login response mismatch: %s", raw)
	}
	if int64(loginData["session_expired"].(float64)) != 7200000 {
		t.Fatalf("zgame login session ttl mismatch: %s", raw)
	}

	signature := zgameTestSignature("420001", token, secret)
	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zgame-renew", AppCode: "lalu"},
		PlatformCode: gamedomain.PlatformCodeZGame,
		Operation:    "api/server/session_renew",
		RawBody:      []byte(`{"open_id":"420001","session":"` + token + `","signature":"` + signature + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback session_renew failed: %v", err)
	}
	var renewResp map[string]any
	if json.Unmarshal(raw, &renewResp) != nil || renewResp["code"].(float64) != 0 || int64(renewResp["data"].(map[string]any)["session_expired"].(float64)) != 7200000 {
		t.Fatalf("zgame session_renew response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zgame-users", AppCode: "lalu"},
		PlatformCode: gamedomain.PlatformCodeZGame,
		Operation:    "server/user_info_batch",
		RawBody:      []byte(`{"open_id":"420001","open_id_list":["420001"],"session":"` + token + `","signature":"` + signature + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback user_info_batch failed: %v", err)
	}
	var usersResp map[string]any
	if json.Unmarshal(raw, &usersResp) != nil {
		t.Fatalf("decode zgame user_info_batch response failed: %s", raw)
	}
	userInfos := usersResp["data"].(map[string]any)["user_infos"].(map[string]any)
	userInfo := userInfos["420001"].(map[string]any)
	if usersResp["code"].(float64) != 0 || userInfo["nickname"] != "tester" || int64(userInfo["balance"].(float64)) != 1880 {
		t.Fatalf("zgame user_info_batch response mismatch: %s", raw)
	}

	raw, _, err = svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-zgame-consume", AppCode: "lalu"},
		PlatformCode: gamedomain.PlatformCodeZGame,
		Operation:    "server/consume",
		RawBody:      []byte(`{"open_id":"420001","session":"` + token + `","amount":120,"order_id":"zord_1","game_id":"1001","round_id":"zr_1","type":0,"signature":"` + signature + `"}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback consume failed: %v", err)
	}
	var consumeResp map[string]any
	if json.Unmarshal(raw, &consumeResp) != nil || consumeResp["code"].(float64) != 0 || int64(consumeResp["balance"].(float64)) != 1880 {
		t.Fatalf("zgame consume response mismatch: %s", raw)
	}
	if wallet.lastApply.GetCommandId() != "game:zgame:zord_1:debit" || wallet.lastApply.GetOpType() != "debit" || wallet.lastApply.GetCoinAmount() != 120 || wallet.lastApply.GetGameId() != "1001" {
		t.Fatalf("zgame wallet command mismatch: %+v", wallet.lastApply)
	}
}

func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
	secret := "baishun-test-app-key"
	userID := int64(312900923573673984)
	token := leaderccTestAccessToken(t, "lalu", userID, "163004", 1700003600)
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "baishun",
			AdapterType:              gamedomain.AdapterBaishunV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"app_id":7482496867,"app_channel":"bobi","uid_mode":"display_user_id"}`,
		},
		// 同一个 App access token 会被百顺 H5 作为 ss_token 复用；旧启动会话只说明曾经打开过别的游戏。
		// 回调验权必须以 JWT 里的当前用户身份为准，否则 user_info 会被旧 provider_game_id 拦成 token invalid。
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "old_baishun_launch_session",
			UserID:          userID,
			DisplayUserID:   "163004",
			PlatformCode:    "baishun",
			ProviderGameID:  "1006",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	wallet := &fakeWallet{balanceAfter: 1880}
	user := &fakeUser{}
	svc := New(Config{}, repo, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, _, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-baishun-stale-session", AppCode: "lalu"},
		PlatformCode: "baishun",
		Operation:    "get_user_info",
		RawBody:      baishunTestBody(t, secret, `{"app_id":7482496867,"user_id":"163004","ss_token":"`+token+`","provider_name":"bobi","client_ip":"104.28.244.150","game_id":1043,"currency_type":0}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback get_user_info failed: %v", err)
	}
	if !strings.Contains(string(raw), `"code":0`) || strings.Contains(string(raw), "token invalid") || !strings.Contains(string(raw), `"user_id":"163004"`) {
		t.Fatalf("baishun access token should ignore stale launch session game id: %s", raw)
	}
	if user.lastGet.GetUserId() != userID {
		t.Fatalf("baishun userinfo must query JWT user, got %+v", user.lastGet)
	}
}

func TestHandleBaishunGetSSTokenReturnsTokenInvalidWhenUserMissing(t *testing.T) {
	secret := "baishun-test-app-key"
	token := "app_access_token_baishun"
	repo := &fakeRepository{
		platform: gamedomain.Platform{
			PlatformCode:             "baishun",
			AdapterType:              gamedomain.AdapterBaishunV1,
			CallbackSecretCiphertext: secret,
			AdapterConfigJSON:        `{"app_id":21397507,"app_channel":"mesh","uid_mode":"display_user_id"}`,
		},
		session: gamedomain.LaunchSession{
			AppCode:         "lalu",
			SessionID:       "game_sess_baishun_missing_user",
			UserID:          42,
			DisplayUserID:   "420001",
			RoomID:          "room_1",
			PlatformCode:    "baishun",
			GameID:          "baishun_greedy",
			ProviderGameID:  "1006",
			LaunchTokenHash: stableHash(token),
			Status:          gamedomain.SessionActive,
			ExpiresAtMS:     1700086400000,
		},
	}
	svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{err: xerr.New(xerr.NotFound, "user not found")})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-baishun-missing-user", AppCode: "lalu"},
		PlatformCode: "baishun",
		Operation:    "get_sstoken",
		RawBody:      baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","code":"app_access_token_baishun"}`, 1700000000),
		RemoteAddr:   "3.1.174.194:443",
	})
	if err != nil {
		t.Fatalf("HandleCallback get_sstoken failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"code":1001`) || !strings.Contains(string(raw), `"message":"token invalid"`) {
		t.Fatalf("baishun missing user response mismatch: %s", raw)
	}
	if strings.Contains(string(raw), `"code":1002`) || strings.Contains(string(raw), "read data failed") {
		t.Fatalf("baishun missing user must not look like read data failure: %s", raw)
	}
}

func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
	repo := &fakeRepository{
		launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
	}
	wallet := &fakeWallet{balanceAfter: 880}
	svc := New(Config{}, repo, wallet, &fakeUser{})
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	req := &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-callback", AppCode: "lalu"},
		PlatformCode: "demo",
		Operation:    "change_coin",
		RawBody:      []byte(`{"user_id":42,"game_id":"demo_rocket_001","provider_order_id":"porder_1","op_type":"debit","coin_amount":120,"room_id":"room_1"}`),
	}
	raw, contentType, err := svc.HandleCallback(context.Background(), req)
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"balance_after":880`) {
		t.Fatalf("callback response mismatch: contentType=%s raw=%s", contentType, raw)
	}
	if wallet.lastApply.GetCommandId() != "game:demo:porder_1" || wallet.lastApply.GetCoinAmount() != 120 {
		t.Fatalf("wallet command mismatch: %+v", wallet.lastApply)
	}
	if repo.order.Status != gamedomain.OrderStatusSucceeded || repo.order.WalletTransactionID != "wtx-game-1" {
		t.Fatalf("order state mismatch: %+v", repo.order)
	}
	if len(repo.levelEvents) != 1 || repo.levelEvents[0].CoinAmount != 120 {
		t.Fatalf("level outbox event mismatch: %+v", repo.levelEvents)
	}

	raw, _, err = svc.HandleCallback(context.Background(), req)
	if err != nil {
		t.Fatalf("idempotent callback failed: %v", err)
	}
	if !strings.Contains(string(raw), `"idempotent_replay":true`) {
		t.Fatalf("idempotent response mismatch: %s", raw)
	}
	if len(repo.levelEvents) != 1 {
		t.Fatalf("idempotent replay should not duplicate level outbox events: %+v", repo.levelEvents)
	}
}

func TestHandleCallbackGetUserInfoUsesUserAndWallet(t *testing.T) {
	wallet := &fakeWallet{balanceAfter: 1680}
	user := &fakeUser{}
	svc := New(Config{}, &fakeRepository{}, wallet, user)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
		Meta:         &gamev1.RequestMeta{RequestId: "req-user-info", AppCode: "lalu"},
		PlatformCode: "demo",
		Operation:    "get_user_info",
		RawBody:      []byte(`{"user_id":42}`),
	})
	if err != nil {
		t.Fatalf("HandleCallback failed: %v", err)
	}
	if contentType != "application/json" || !strings.Contains(string(raw), `"display_user_id":"420001"`) || !strings.Contains(string(raw), `"balance":1680`) {
		t.Fatalf("user info response mismatch: %s", raw)
	}
	if user.lastGet.GetUserId() != 42 {
		t.Fatalf("user request mismatch: %+v", user.lastGet)
	}
}

func TestProcessLevelEventOutboxBatchRelaysGameSpend(t *testing.T) {
	repo := &fakeRepository{levelEvents: []gamedomain.LevelEventOutbox{{
		AppCode:             "lalu",
		EventID:             "game_level:order_1",
		OrderID:             "order_1",
		UserID:              42,
		GameID:              "demo_rocket_001",
		ProviderOrderID:     "porder_1",
		ProviderRoundID:     "round_1",
		CoinAmount:          120,
		WalletTransactionID: "wtx-game-1",
		PayloadJSON:         `{"game_id":"demo_rocket_001"}`,
		OccurredAtMS:        1700000000000,
	}}}
	activity := &fakeActivity{status: "consumed"}
	svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{}, activity)
	svc.now = func() time.Time { return time.UnixMilli(1700000001000) }

	claimed, processed, success, failure, hasMore, err := svc.ProcessLevelEventOutboxBatch(context.Background(), "run_1", "worker_1", 100, 30*time.Second)
	if err != nil {
		t.Fatalf("ProcessLevelEventOutboxBatch failed: %v", err)
	}
	if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore {
		t.Fatalf("batch result mismatch: claimed=%d processed=%d success=%d failure=%d hasMore=%v", claimed, processed, success, failure, hasMore)
	}
	if activity.last.GetTrack() != "game" || activity.last.GetMetricType() != "game_spend_coin" || activity.last.GetValueDelta() != 120 {
		t.Fatalf("activity relay request mismatch: %+v", activity.last)
	}
	if activity.lastTask.GetMetricType() != "game_spend_coin" || activity.lastTask.GetValue() != 120 || activity.lastTask.GetEventId() != "game_level:order_1:task" {
		t.Fatalf("task relay request mismatch: %+v", activity.lastTask)
	}
	if len(repo.deliveredEvents) != 1 || repo.deliveredEvents[0] != "game_level:order_1" {
		t.Fatalf("delivered state mismatch: %+v", repo.deliveredEvents)
	}
}

func TestProcessLevelEventOutboxBatchRelaysSelfGamePlayCount(t *testing.T) {
	repo := &fakeRepository{levelEvents: []gamedomain.LevelEventOutbox{{
		AppCode:             "lalu",
		EventID:             "game_level:dice_order_1",
		OrderID:             "dice_order_1",
		UserID:              42,
		GameID:              "dice",
		ProviderOrderID:     "dice_match_1:42:debit",
		ProviderRoundID:     "dice_match_1",
		CoinAmount:          100,
		WalletTransactionID: "wtx-dice-1",
		PayloadJSON:         `{"game_id":"dice","match_id":"dice_match_1"}`,
		OccurredAtMS:        1700000000000,
	}}}
	activity := &fakeActivity{status: "consumed"}
	svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{}, activity)
	svc.now = func() time.Time { return time.UnixMilli(1700000001000) }

	_, _, success, failure, _, err := svc.ProcessLevelEventOutboxBatch(context.Background(), "run_1", "worker_1", 100, 30*time.Second)
	if err != nil {
		t.Fatalf("ProcessLevelEventOutboxBatch failed: %v", err)
	}
	if success != 1 || failure != 0 {
		t.Fatalf("batch result mismatch: success=%d failure=%d", success, failure)
	}
	if len(activity.taskRequests) != 2 {
		t.Fatalf("task request count mismatch: %d", len(activity.taskRequests))
	}
	if activity.taskRequests[0].GetMetricType() != "game_spend_coin" || activity.taskRequests[0].GetValue() != 100 {
		t.Fatalf("game spend task mismatch: %+v", activity.taskRequests[0])
	}
	if activity.taskRequests[1].GetMetricType() != "game_dice_play_count" || activity.taskRequests[1].GetValue() != 1 || activity.taskRequests[1].GetEventId() != "game_level:dice_order_1:task:play_count" {
		t.Fatalf("play count task mismatch: %+v", activity.taskRequests[1])
	}
}

type fakeRepository struct {
	launchable               gamedomain.LaunchableGame
	platform                 gamedomain.Platform
	session                  gamedomain.LaunchSession
	sessions                 []gamedomain.LaunchSession
	order                    gamedomain.GameOrder
	orders                   []gamedomain.GameOrder
	repair                   gamedomain.RepairOrder
	levelEvents              []gamedomain.LevelEventOutbox
	recentGames              []gamedomain.AppGame
	lastRecentQuery          ListRecentGamesQuery
	platforms                []gamedomain.Platform
	lastListPlatformsAppCode string
	lastListPlatformsStatus  string
	deliveredEvents          []string
	failedEvents             []string
	accessAllowed            *bool
}

func (f *fakeRepository) Ping(context.Context) error { return nil }
func (f *fakeRepository) ListGames(context.Context, ListGamesQuery) ([]gamedomain.AppGame, error) {
	return []gamedomain.AppGame{{GameID: "demo_rocket_001"}}, nil
}
func (f *fakeRepository) ListRecentGames(_ context.Context, query ListRecentGamesQuery) ([]gamedomain.AppGame, error) {
	f.lastRecentQuery = query
	if f.recentGames != nil {
		return f.recentGames, nil
	}
	return []gamedomain.AppGame{{GameID: "demo_recent_001"}}, nil
}
func (f *fakeRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
	return f.launchable, nil
}
func (f *fakeRepository) GetPlatform(context.Context, string, string) (gamedomain.Platform, error) {
	return f.platform, nil
}
func (f *fakeRepository) CreateLaunchSession(_ context.Context, session gamedomain.LaunchSession) error {
	f.session = session
	f.sessions = append(f.sessions, session)
	return nil
}
func (f *fakeRepository) GetLaunchSessionByToken(_ context.Context, _ string, token string) (gamedomain.LaunchSession, error) {
	for index := len(f.sessions) - 1; index >= 0; index-- {
		if f.sessions[index].LaunchTokenHash == stableHash(token) {
			return f.sessions[index], nil
		}
	}
	if f.session.LaunchTokenHash == stableHash(token) {
		return f.session, nil
	}
	return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid")
}
func (f *fakeRepository) GetLaunchSessionByTokenScope(_ context.Context, _ string, token string, platformCode string, providerGameID string) (gamedomain.LaunchSession, error) {
	tokenHash := stableHash(token)
	for index := len(f.sessions) - 1; index >= 0; index-- {
		session := f.sessions[index]
		if session.LaunchTokenHash != tokenHash || session.PlatformCode != strings.TrimSpace(platformCode) {
			continue
		}
		if strings.TrimSpace(providerGameID) != "" && session.ProviderGameID != strings.TrimSpace(providerGameID) {
			continue
		}
		return session, nil
	}
	if f.session.LaunchTokenHash == tokenHash && f.session.PlatformCode == strings.TrimSpace(platformCode) {
		if strings.TrimSpace(providerGameID) == "" || f.session.ProviderGameID == strings.TrimSpace(providerGameID) {
			return f.session, nil
		}
	}
	return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "session token invalid")
}
func (f *fakeRepository) InsertCallbackLog(context.Context, gamedomain.CallbackLog) error { return nil }
func (f *fakeRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
	for _, existing := range f.orders {
		if existing.OrderID == order.OrderID {
			f.order = existing
			return existing, true, nil
		}
	}
	if f.order.OrderID != "" && len(f.orders) == 0 {
		if f.order.OrderID == order.OrderID {
			return f.order, true, nil
		}
		f.orders = append(f.orders, f.order)
	}
	f.order = order
	f.orders = append(f.orders, order)
	return order, false, nil
}
func (f *fakeRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
	f.order.OrderID = orderID
	f.order.Status = gamedomain.OrderStatusSucceeded
	f.order.WalletTransactionID = walletTransactionID
	f.order.WalletBalanceAfter = balanceAfter
	f.order.UpdatedAtMS = nowMs
	for index := range f.orders {
		if f.orders[index].OrderID == orderID {
			f.orders[index] = f.order
			break
		}
	}
	if strings.EqualFold(f.order.OpType, "debit") && f.order.CoinAmount > 0 && len(f.levelEvents) == 0 {
		f.levelEvents = append(f.levelEvents, gamedomain.LevelEventOutbox{
			AppCode:             f.order.AppCode,
			EventID:             "game_level:" + f.order.OrderID,
			OrderID:             f.order.OrderID,
			UserID:              f.order.UserID,
			GameID:              f.order.GameID,
			ProviderOrderID:     f.order.ProviderOrderID,
			ProviderRoundID:     f.order.ProviderRoundID,
			CoinAmount:          f.order.CoinAmount,
			WalletTransactionID: walletTransactionID,
			OccurredAtMS:        nowMs,
			CreatedAtMS:         nowMs,
			UpdatedAtMS:         nowMs,
		})
	}
	return nil
}
func (f *fakeRepository) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMs int64) error {
	f.order.OrderID = orderID
	f.order.Status = status
	f.order.FailureCode = code
	f.order.FailureMessage = message
	f.order.UpdatedAtMS = nowMs
	for index := range f.orders {
		if f.orders[index].OrderID == orderID {
			f.orders[index] = f.order
			break
		}
	}
	return nil
}
func (f *fakeRepository) CreateRepairOrder(_ context.Context, order gamedomain.RepairOrder) (gamedomain.RepairOrder, bool, error) {
	if f.repair.RepairID != "" {
		return f.repair, true, nil
	}
	f.repair = order
	return order, false, nil
}
func (f *fakeRepository) ClaimPendingLevelEvents(context.Context, string, int64, time.Duration, int) ([]gamedomain.LevelEventOutbox, error) {
	events := append([]gamedomain.LevelEventOutbox(nil), f.levelEvents...)
	f.levelEvents = nil
	return events, nil
}
func (f *fakeRepository) MarkLevelEventDelivered(_ context.Context, eventID string, _ int64) error {
	f.deliveredEvents = append(f.deliveredEvents, eventID)
	return nil
}
func (f *fakeRepository) MarkLevelEventFailed(_ context.Context, eventID string, _ string, _ int64, _ int64) error {
	f.failedEvents = append(f.failedEvents, eventID)
	return nil
}
func (f *fakeRepository) ListPlatforms(_ context.Context, appCode string, status string) ([]gamedomain.Platform, error) {
	f.lastListPlatformsAppCode = appCode
	f.lastListPlatformsStatus = status
	if f.platforms != nil {
		return f.platforms, nil
	}
	return nil, nil
}
func (f *fakeRepository) UpsertPlatform(context.Context, gamedomain.Platform) (gamedomain.Platform, error) {
	return gamedomain.Platform{}, nil
}
func (f *fakeRepository) ListCatalog(context.Context, ListCatalogQuery) ([]gamedomain.CatalogItem, string, error) {
	return nil, "", nil
}
func (f *fakeRepository) UpsertCatalog(context.Context, gamedomain.CatalogItem) (gamedomain.CatalogItem, error) {
	return gamedomain.CatalogItem{}, nil
}
func (f *fakeRepository) SetGameStatus(context.Context, string, string, string, int64) (gamedomain.CatalogItem, error) {
	return gamedomain.CatalogItem{}, nil
}
func (f *fakeRepository) DeleteCatalog(context.Context, string, string) error {
	return nil
}
func (f *fakeRepository) CanUserAccessGame(context.Context, string, string, int64) (bool, error) {
	if f.accessAllowed != nil {
		return *f.accessAllowed, nil
	}
	return true, nil
}
func (f *fakeRepository) SetGameWhitelistEnabled(context.Context, string, string, bool, int64) (gamedomain.CatalogItem, error) {
	return gamedomain.CatalogItem{}, nil
}
func (f *fakeRepository) ListGameWhitelistUsers(context.Context, string, string, int32) ([]gamedomain.WhitelistUser, error) {
	return nil, nil
}
func (f *fakeRepository) AddGameWhitelistUser(_ context.Context, item gamedomain.WhitelistUser) (gamedomain.WhitelistUser, error) {
	return item, nil
}
func (f *fakeRepository) DeleteGameWhitelistUser(context.Context, string, string, int64) error {
	return nil
}

type fakeWallet struct {
	lastApply    *walletv1.ApplyGameCoinChangeRequest
	applyCount   int
	balanceAfter int64
}

func (f *fakeWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
	f.lastApply = req
	f.applyCount++
	return &walletv1.ApplyGameCoinChangeResponse{WalletTransactionId: "wtx-game-1", BalanceAfter: f.balanceAfter}, nil
}

func (f *fakeWallet) GetBalances(context.Context, *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: f.balanceAfter}}}, nil
}

type fakeActivity struct {
	status       string
	taskStatus   string
	last         *activityv1.ConsumeLevelEventRequest
	lastTask     *activityv1.ConsumeTaskEventRequest
	taskRequests []*activityv1.ConsumeTaskEventRequest
}

func (f *fakeActivity) ConsumeLevelEvent(_ context.Context, req *activityv1.ConsumeLevelEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeLevelEventResponse, error) {
	f.last = req
	status := f.status
	if status == "" {
		status = "consumed"
	}
	return &activityv1.ConsumeLevelEventResponse{EventId: req.GetEventId(), Status: status}, nil
}

func (f *fakeActivity) ConsumeTaskEvent(_ context.Context, req *activityv1.ConsumeTaskEventRequest, _ ...grpc.CallOption) (*activityv1.ConsumeTaskEventResponse, error) {
	f.lastTask = req
	f.taskRequests = append(f.taskRequests, req)
	status := f.taskStatus
	if status == "" {
		status = "consumed"
	}
	return &activityv1.ConsumeTaskEventResponse{EventId: req.GetEventId(), Status: status, MatchedTaskCount: 1}, nil
}

type fakeUser struct {
	lastGet *userv1.GetUserRequest
	err     error
}

func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
	f.lastGet = req
	if f.err != nil {
		return nil, f.err
	}
	return &userv1.GetUserResponse{User: &userv1.User{
		UserId:        req.GetUserId(),
		DisplayUserId: "420001",
		Username:      "tester",
		Avatar:        "https://cdn.example/avatar.png",
		Country:       "AE",
		RegionId:      100,
	}}, nil
}

func encryptYomiTestBody(t *testing.T, secret string, plain string) []byte {
	t.Helper()
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		t.Fatalf("aes cipher failed: %v", err)
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		t.Fatalf("gcm failed: %v", err)
	}
	nonce := []byte("123456789012")
	payload := append([]byte{}, nonce...)
	payload = append(payload, gcm.Seal(nil, nonce, []byte(plain), nil)...)
	return []byte(base64.URLEncoding.EncodeToString(payload))
}

func leaderccTestSign(key string, parts ...string) string {
	builder := strings.Builder{}
	for _, part := range parts {
		builder.WriteString(strings.TrimSpace(part))
	}
	builder.WriteString(strings.TrimSpace(key))
	sum := md5.Sum([]byte(builder.String()))
	return hex.EncodeToString(sum[:])
}

func reyouTestSign(key string, parts ...string) string {
	builder := strings.Builder{}
	for _, part := range parts {
		builder.WriteString(strings.TrimSpace(part))
	}
	builder.WriteString(strings.TrimSpace(key))
	sum := md5.Sum([]byte(builder.String()))
	return strings.ToUpper(hex.EncodeToString(sum[:]))
}

func zgameTestSignature(openID string, session string, secret string) string {
	sum := sha256.Sum256([]byte(strings.TrimSpace(openID) + strings.TrimSpace(session) + strings.TrimSpace(secret)))
	return hex.EncodeToString(sum[:])
}

func leaderccTestAccessToken(t *testing.T, app string, userID int64, displayUserID string, expiresAtSec int64) string {
	t.Helper()
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"iss":                     "hyapp",
		"app_code":                app,
		"sub":                     strconv.FormatInt(userID, 10),
		"user_id":                 strconv.FormatInt(userID, 10),
		"display_user_id":         displayUserID,
		"default_display_user_id": displayUserID,
		"sid":                     "sess_test_app_token",
		"typ":                     "access",
		"iat":                     int64(1700000000),
		"exp":                     expiresAtSec,
	})
	signed, err := token.SignedString([]byte("test-secret"))
	if err != nil {
		t.Fatalf("sign test access token: %v", err)
	}
	return signed
}

func zeeoneTestEnvelope(t *testing.T, secret string, body string, timestamp int64) []byte {
	t.Helper()
	nonce := "5f0eb04d7603a9d8"
	raw := nonce + body + strconv.FormatInt(timestamp, 10) + secret
	sum := md5.Sum([]byte(raw))
	signature := hex.EncodeToString(sum[:])
	encodedBody, err := json.Marshal(body)
	if err != nil {
		t.Fatalf("marshal reqbody: %v", err)
	}
	return []byte(`{"reqbody":` + string(encodedBody) + `,"signature":"` + signature + `","signature_nonce":"` + nonce + `","timestamp":` + strconv.FormatInt(timestamp, 10) + `}`)
}

func baishunTestBody(t *testing.T, secret string, body string, timestamp int64) []byte {
	t.Helper()
	nonceSum := md5.Sum([]byte(body))
	nonce := hex.EncodeToString(nonceSum[:8])
	raw := nonce + strings.TrimSpace(secret) + strconv.FormatInt(timestamp, 10)
	sum := md5.Sum([]byte(raw))
	var payload map[string]any
	decoder := json.NewDecoder(strings.NewReader(body))
	decoder.UseNumber()
	if err := decoder.Decode(&payload); err != nil {
		t.Fatalf("decode baishun test body: %v", err)
	}
	payload["signature_nonce"] = nonce
	payload["timestamp"] = timestamp
	payload["signature"] = hex.EncodeToString(sum[:])
	encoded, err := json.Marshal(payload)
	if err != nil {
		t.Fatalf("marshal baishun test body: %v", err)
	}
	return encoded
}

func vivaGamesTestBody(t *testing.T, secret string, body string, timestamp int64) []byte {
	t.Helper()
	var payload map[string]any
	decoder := json.NewDecoder(strings.NewReader(body))
	decoder.UseNumber()
	if err := decoder.Decode(&payload); err != nil {
		t.Fatalf("decode vivagames test body: %v", err)
	}
	payload["timestamp"] = json.Number(strconv.FormatInt(timestamp, 10))
	payload["sign"] = vivaGamesSign(payload, secret)
	encoded, err := json.Marshal(payload)
	if err != nil {
		t.Fatalf("marshal vivagames test body: %v", err)
	}
	return encoded
}
