package mysql

import (
	"context"
	"fmt"
	"strings"
	"testing"
	"time"

	gamev1 "hyapp.local/api/proto/game/v1"
	"hyapp/internal/testutil/mysqlschema"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	dicedomain "hyapp/services/game-service/internal/domain/dice"
	gamedomain "hyapp/services/game-service/internal/domain/game"
	selfgame "hyapp/services/game-service/internal/domain/selfgame"
	gameservice "hyapp/services/game-service/internal/service/game"
)

func TestRoomRPSConfigRepositoryRoundTrip(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_rps_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	if _, err := repo.GetRoomRPSConfig(ctx, "lalu", "room_rps"); !xerr.IsCode(err, xerr.NotFound) {
		t.Fatalf("empty config should return not found, got %v", err)
	}

	saved, err := repo.UpsertRoomRPSConfig(ctx, &gamev1.RoomRPSConfig{
		AppCode:            "lalu",
		GameId:             "room_rps",
		Status:             "active",
		ChallengeTimeoutMs: 600000,
		RevealCountdownMs:  3000,
		StakeGifts: []*gamev1.RoomRPSStakeGift{
			{GiftIdText: "Gifi-3", GiftName: "Tiger", GiftIconUrl: "https://cdn.example.test/tiger.png", GiftAnimationUrl: "https://cdn.example.test/tiger.mp4", GiftPriceCoin: 100, Enabled: true, SortOrder: 1},
			{GiftIdText: "Gifi-2", GiftName: "Cowboy", GiftIconUrl: "https://cdn.example.test/cowboy.png", GiftPriceCoin: 200, Enabled: true, SortOrder: 2},
			{GiftIdText: "Gifi-1", GiftName: "Warrior", GiftIconUrl: "https://cdn.example.test/warrior.png", GiftPriceCoin: 300, Enabled: true, SortOrder: 3},
			{GiftIdText: "lw1", GiftName: "Wheel", GiftIconUrl: "https://cdn.example.test/wheel.png", GiftPriceCoin: 400, Enabled: false, SortOrder: 4},
		},
		CreatedAtMs: 1700000000000,
		UpdatedAtMs: 1700000000000,
	})
	if err != nil {
		t.Fatalf("upsert room rps config failed: %v", err)
	}
	if saved.GetCreatedAtMs() != 1700000000000 || saved.GetStakeGifts()[0].GetGiftAnimationUrl() == "" {
		t.Fatalf("saved config mismatch: %+v", saved)
	}

	updated, err := repo.UpsertRoomRPSConfig(ctx, &gamev1.RoomRPSConfig{
		AppCode:            "lalu",
		GameId:             "room_rps",
		Status:             "disabled",
		ChallengeTimeoutMs: 300000,
		RevealCountdownMs:  5000,
		StakeGifts:         saved.GetStakeGifts(),
		CreatedAtMs:        1800000000000,
		UpdatedAtMs:        1800000000000,
	})
	if err != nil {
		t.Fatalf("update room rps config failed: %v", err)
	}
	if updated.GetStatus() != "disabled" || updated.GetCreatedAtMs() != 1700000000000 || updated.GetUpdatedAtMs() != 1800000000000 {
		t.Fatalf("updated config should preserve created_at and update payload: %+v", updated)
	}
}

func TestSelfGameStakePoolStrategyLogAndProtectionState(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	nowMS := int64(1700000000000)
	_, err = repo.UpsertDiceConfig(ctx, dicedomain.Config{
		AppCode: "lalu",
		GameID:  dicedomain.DefaultGameID,
		Status:  dicedomain.ConfigStatusActive,
		StakeOptions: []dicedomain.StakeOption{
			{StakeCoin: 100, Enabled: true, SortOrder: 1},
			{StakeCoin: 500, Enabled: true, SortOrder: 2},
		},
		FeeBPS:           int32(dicedomain.DefaultFeeBPS),
		PoolBPS:          int32(dicedomain.DefaultPoolBPS),
		MinPlayers:       dicedomain.DefaultMinPlayers,
		MaxPlayers:       dicedomain.DefaultMaxPlayers,
		RobotEnabled:     true,
		RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS,
	}, nowMS)
	if err != nil {
		t.Fatalf("upsert dice config failed: %v", err)
	}
	pool, err := repo.GetSelfGameStakePool(ctx, "lalu", dicedomain.DefaultGameID, 100, nowMS)
	if err != nil {
		t.Fatalf("get stake pool failed: %v", err)
	}
	if pool.StakeCoin != 100 || pool.BalanceCoin != 0 || pool.SafeBalanceCoin == 0 {
		t.Fatalf("stake pool should be initialized with thresholds, got %+v", pool)
	}
	savedPool, err := repo.UpsertSelfGameStakePoolConfig(ctx, selfgame.StakePool{
		AppCode:            "lalu",
		GameID:             dicedomain.DefaultGameID,
		StakeCoin:          100,
		TargetBalanceCoin:  3000,
		SafeBalanceCoin:    2000,
		WarningBalanceCoin: 1000,
		DangerBalanceCoin:  500,
		Status:             "disabled",
	}, nowMS+1)
	if err != nil {
		t.Fatalf("upsert stake pool config failed: %v", err)
	}
	if savedPool.TargetBalanceCoin != 3000 || savedPool.Status != "disabled" || savedPool.BalanceCoin != 0 {
		t.Fatalf("saved stake pool config mismatch: %+v", savedPool)
	}
	stakePools, err := repo.ListSelfGameStakePools(ctx, "lalu", dicedomain.DefaultGameID, nowMS+1)
	if err != nil {
		t.Fatalf("list stake pools failed: %v", err)
	}
	if len(stakePools) != 2 || stakePools[0].StakeCoin != 100 || stakePools[0].Status != "disabled" {
		t.Fatalf("stake pool list mismatch: %+v", stakePools)
	}
	if _, err := repo.AdjustSelfGameStakePool(ctx, dicedomain.PoolAdjustment{
		AdjustmentID: "adj_legacy_rejected",
		AppCode:      "lalu",
		GameID:       dicedomain.DefaultGameID,
		Direction:    dicedomain.PoolDirectionIn,
		AmountCoin:   1000,
		Reason:       "legacy_should_not_work",
		CreatedAtMS:  nowMS + 1,
	}); !xerr.IsCode(err, xerr.InvalidArgument) {
		t.Fatalf("legacy aggregate pool adjustment should be rejected, got %v", err)
	}
	if _, err := repo.AdjustSelfGameStakePool(ctx, dicedomain.PoolAdjustment{
		AdjustmentID: "adj_stake_1",
		AppCode:      "lalu",
		GameID:       dicedomain.DefaultGameID,
		StakeCoin:    100,
		Direction:    dicedomain.PoolDirectionIn,
		AmountCoin:   1000,
		Reason:       "test",
		CreatedAtMS:  nowMS + 1,
	}); err != nil {
		t.Fatalf("adjust stake pool failed: %v", err)
	}
	pool, err = repo.GetSelfGameStakePool(ctx, "lalu", dicedomain.DefaultGameID, 100, nowMS+2)
	if err != nil {
		t.Fatalf("get adjusted stake pool failed: %v", err)
	}
	if pool.BalanceCoin != 1000 {
		t.Fatalf("stake pool balance = %d, want 1000", pool.BalanceCoin)
	}
	if _, err := repo.db.ExecContext(ctx,
		`INSERT INTO game_self_game_pools (app_code, game_id, balance_coin, created_at_ms, updated_at_ms)
			 VALUES (?, ?, 0, ?, ?)
			 ON DUPLICATE KEY UPDATE balance_coin = 0, updated_at_ms = VALUES(updated_at_ms)`,
		"lalu", dicedomain.DefaultGameID, nowMS+2, nowMS+2,
	); err != nil {
		t.Fatalf("seed legacy pool failed: %v", err)
	}
	if _, err := repo.AdjustSelfGameStakePool(ctx, dicedomain.PoolAdjustment{
		AdjustmentID: "adj_stake_2",
		AppCode:      "lalu",
		GameID:       dicedomain.DefaultGameID,
		StakeCoin:    100,
		Direction:    dicedomain.PoolDirectionOut,
		AmountCoin:   88,
		Reason:       "test_out",
		CreatedAtMS:  nowMS + 2,
	}); err != nil {
		t.Fatalf("stake pool out should not depend on legacy aggregate balance: %v", err)
	}
	pool, err = repo.GetSelfGameStakePool(ctx, "lalu", dicedomain.DefaultGameID, 100, nowMS+3)
	if err != nil {
		t.Fatalf("get out adjusted stake pool failed: %v", err)
	}
	if pool.BalanceCoin != 912 {
		t.Fatalf("stake pool balance after out = %d, want 912", pool.BalanceCoin)
	}
	var legacyBalance int64
	if err := repo.db.QueryRowContext(ctx,
		`SELECT balance_coin FROM game_self_game_pools WHERE app_code = ? AND game_id = ?`,
		"lalu", dicedomain.DefaultGameID,
	).Scan(&legacyBalance); err != nil {
		t.Fatalf("query legacy pool failed: %v", err)
	}
	if legacyBalance != 0 {
		t.Fatalf("legacy aggregate pool should not be touched by stake pool adjustment, got %d", legacyBalance)
	}

	savedPolicy, err := repo.UpsertSelfGameNewUserPolicy(ctx, selfgame.NewUserPolicy{
		AppCode:                         "lalu",
		GameID:                          dicedomain.DefaultGameID,
		Enabled:                         true,
		ProtectionRounds:                30,
		ProtectionHours:                 168,
		MaxStakeCoin:                    5000,
		LifetimeSubsidyQuotaCoin:        30000,
		Day1SubsidyQuotaCoin:            15000,
		SingleRoundSubsidyCap:           5000,
		StrategyLevel:                   selfgame.NewUserStrategySoftBoost,
		LoseStreakProtectionEnabled:     true,
		LoseStreakTrigger:               2,
		NormalPhaseTargetWinRatePercent: 57,
		BlackPoolRobotForceWinEnabled:   true,
	}, nowMS+4)
	if err != nil {
		t.Fatalf("upsert new user policy failed: %v", err)
	}
	if savedPolicy.NormalPhaseTargetWinRatePercent != 57 {
		t.Fatalf("normal phase target win rate was not persisted: %+v", savedPolicy)
	}
	if !savedPolicy.BlackPoolRobotForceWinEnabled {
		t.Fatalf("black pool robot force-win switch was not persisted: %+v", savedPolicy)
	}
	readPolicy, err := repo.GetSelfGameNewUserPolicy(ctx, "lalu", dicedomain.DefaultGameID, nowMS+5)
	if err != nil {
		t.Fatalf("get new user policy failed: %v", err)
	}
	if readPolicy.NormalPhaseTargetWinRatePercent != 57 {
		t.Fatalf("normal phase target win rate readback mismatch: %+v", readPolicy)
	}
	if !readPolicy.BlackPoolRobotForceWinEnabled {
		t.Fatalf("black pool robot force-win switch readback mismatch: %+v", readPolicy)
	}

	if err := repo.RecordSelfGameStrategyLog(ctx, selfgame.StrategyLog{
		AppCode:         "lalu",
		LogID:           "log_strategy_1",
		GameID:          dicedomain.DefaultGameID,
		StakeCoin:       100,
		MatchID:         "match_strategy_1",
		UserID:          101,
		StrategyCode:    selfgame.StrategyCodeNewUserProtection,
		Decision:        selfgame.DecisionForceHumanWin,
		ForceResult:     selfgame.ForceResultHumanWin,
		ReasonCode:      "NEW_USER_POSITIVE_FEEDBACK",
		ReasonJSON:      `{"source":"test"}`,
		PoolBalanceCoin: 1000,
		PoolLevel:       selfgame.PoolLevelGreen,
		CreatedAtMS:     nowMS + 2,
	}); err != nil {
		t.Fatalf("record strategy log failed: %v", err)
	}
	var logCount int
	if err := repo.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM game_self_game_strategy_logs WHERE app_code = ? AND match_id = ? AND user_id = ?`,
		"lalu", "match_strategy_1", int64(101),
	).Scan(&logCount); err != nil {
		t.Fatalf("query strategy log failed: %v", err)
	}
	if logCount != 1 {
		t.Fatalf("strategy log count = %d, want 1", logCount)
	}

	event := selfgame.ProtectionEvent{
		AppCode:                  "lalu",
		GameID:                   dicedomain.DefaultGameID,
		MatchID:                  "match_strategy_1",
		UserID:                   101,
		UsedRoundDelta:           1,
		LifetimeSubsidyCoinDelta: 88,
		Day1SubsidyCoinDelta:     88,
		CreatedAtMS:              nowMS + 3,
	}
	state, consumed, err := repo.ConsumeSelfGameProtection(ctx, event)
	if err != nil {
		t.Fatalf("consume protection failed: %v", err)
	}
	if !consumed || state.UsedRounds != 1 || state.LifetimeSubsidyCoin != 88 {
		t.Fatalf("protection state mismatch after first consume: consumed=%v state=%+v", consumed, state)
	}
	state, consumed, err = repo.ConsumeSelfGameProtection(ctx, event)
	if err != nil {
		t.Fatalf("consume duplicate protection failed: %v", err)
	}
	if consumed || state.UsedRounds != 1 || state.LifetimeSubsidyCoin != 88 {
		t.Fatalf("duplicate protection event should be idempotent: consumed=%v state=%+v", consumed, state)
	}
}

func TestAppGameListsExcludeBuiltInSelfGames(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	nowMS := int64(1781200000000)
	for _, platform := range []gamedomain.Platform{
		{AppCode: "lalu", PlatformCode: "dice", PlatformName: "Dice Internal", Status: gamedomain.StatusActive, AdapterType: "dice_internal", CreatedAtMS: nowMS, UpdatedAtMS: nowMS},
		{AppCode: "lalu", PlatformCode: "demo", PlatformName: "Demo", Status: gamedomain.StatusActive, AdapterType: gamedomain.AdapterDemo, CreatedAtMS: nowMS, UpdatedAtMS: nowMS},
	} {
		if _, err := repo.UpsertPlatform(ctx, platform); err != nil {
			t.Fatalf("upsert platform %s failed: %v", platform.PlatformCode, err)
		}
	}
	for _, item := range []gamedomain.CatalogItem{
		{AppCode: "lalu", GameID: dicedomain.DefaultGameID, PlatformCode: "dice", ProviderGameID: "dice", GameName: "Dice", Category: "self", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 1},
		{AppCode: "lalu", GameID: dicedomain.SelfGameIDRock, PlatformCode: "dice", ProviderGameID: "rock", GameName: "Rock Paper Scissors", Category: "self", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 2},
		{AppCode: "lalu", GameID: "demo_slot_001", PlatformCode: "demo", ProviderGameID: "slot_001", GameName: "Demo Slot", Category: "slot", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive, SortOrder: 3},
	} {
		if _, err := repo.UpsertCatalog(ctx, item); err != nil {
			t.Fatalf("upsert catalog %s failed: %v", item.GameID, err)
		}
	}
	for _, session := range []gamedomain.LaunchSession{
		{AppCode: "lalu", SessionID: "sess_dice", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "dice", GameID: dicedomain.DefaultGameID, ProviderGameID: "dice", LaunchTokenHash: "hash_dice", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 10, UpdatedAtMS: nowMS + 10},
		{AppCode: "lalu", SessionID: "sess_rock", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "dice", GameID: dicedomain.SelfGameIDRock, ProviderGameID: "rock", LaunchTokenHash: "hash_rock", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 20, UpdatedAtMS: nowMS + 20},
		{AppCode: "lalu", SessionID: "sess_demo", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "demo", GameID: "demo_slot_001", ProviderGameID: "slot_001", LaunchTokenHash: "hash_demo", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS + 30, UpdatedAtMS: nowMS + 30},
	} {
		if err := repo.CreateLaunchSession(ctx, session); err != nil {
			t.Fatalf("create launch session %s failed: %v", session.SessionID, err)
		}
	}

	query := gameservice.ListGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom}
	games, err := repo.ListGames(ctx, query)
	if err != nil {
		t.Fatalf("list games failed: %v", err)
	}
	if len(games) != 1 || games[0].GameID != "demo_slot_001" {
		t.Fatalf("app game list should hide built-in self games, got %+v", games)
	}
	recent, err := repo.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PageSize: 10})
	if err != nil {
		t.Fatalf("list recent games failed: %v", err)
	}
	if len(recent) != 1 || recent[0].GameID != "demo_slot_001" {
		t.Fatalf("recent game list should hide built-in self games, got %+v", recent)
	}
	if _, err := repo.GetLaunchableGame(ctx, "lalu", dicedomain.DefaultGameID); err != nil {
		t.Fatalf("dice catalog must remain launchable for dedicated endpoints: %v", err)
	}
	if _, err := repo.GetLaunchableGame(ctx, "lalu", dicedomain.SelfGameIDRock); err != nil {
		t.Fatalf("rock catalog must remain launchable for dedicated endpoints: %v", err)
	}
}

func TestGameWhitelistFiltersCatalogAndRecentListsAndControlsLaunchAccess(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})
	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	nowMS := int64(1781200000000)
	if _, err := repo.UpsertPlatform(ctx, gamedomain.Platform{AppCode: "lalu", PlatformCode: "demo", PlatformName: "Demo", Status: gamedomain.StatusActive, AdapterType: gamedomain.AdapterDemo, CreatedAtMS: nowMS, UpdatedAtMS: nowMS}); err != nil {
		t.Fatalf("upsert platform failed: %v", err)
	}
	item := gamedomain.CatalogItem{AppCode: "lalu", GameID: "private_slot", PlatformCode: "demo", ProviderGameID: "private_1", GameName: "Private Slot", LaunchMode: gamedomain.LaunchModeH5Popup, Orientation: "portrait", Status: gamedomain.StatusActive}
	if _, err := repo.UpsertCatalog(ctx, item); err != nil {
		t.Fatalf("upsert catalog failed: %v", err)
	}
	if err := repo.CreateLaunchSession(ctx, gamedomain.LaunchSession{AppCode: "lalu", SessionID: "private_session", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PlatformCode: "demo", GameID: item.GameID, ProviderGameID: item.ProviderGameID, LaunchTokenHash: "private_hash", Status: gamedomain.SessionActive, ExpiresAtMS: nowMS + 60000, CreatedAtMS: nowMS, UpdatedAtMS: nowMS}); err != nil {
		t.Fatalf("create launch session failed: %v", err)
	}
	if _, err := repo.SetGameWhitelistEnabled(ctx, "lalu", item.GameID, true, nowMS); err != nil {
		t.Fatalf("enable whitelist failed: %v", err)
	}

	listQuery := gameservice.ListGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom}
	if games, err := repo.ListGames(ctx, listQuery); err != nil || len(games) != 0 {
		t.Fatalf("non-member catalog list must hide game, games=%+v err=%v", games, err)
	}
	if recent, err := repo.ListRecentGames(ctx, gameservice.ListRecentGamesQuery{AppCode: "lalu", UserID: 42, Scene: gamedomain.SceneVoiceRoom, PageSize: 10}); err != nil || len(recent) != 0 {
		t.Fatalf("non-member recent list must hide game, games=%+v err=%v", recent, err)
	}
	if allowed, err := repo.CanUserAccessGame(ctx, "lalu", item.GameID, 42); err != nil || allowed {
		t.Fatalf("non-member launch access mismatch: allowed=%v err=%v", allowed, err)
	}
	member, err := repo.AddGameWhitelistUser(ctx, gamedomain.WhitelistUser{AppCode: "lalu", GameID: item.GameID, UserID: 42, CreatedByAdminID: 7, CreatedAtMS: nowMS + 1})
	if err != nil || member.UserID != 42 {
		t.Fatalf("add whitelist member failed: member=%+v err=%v", member, err)
	}
	if games, err := repo.ListGames(ctx, listQuery); err != nil || len(games) != 1 || games[0].GameID != item.GameID {
		t.Fatalf("member catalog list mismatch: games=%+v err=%v", games, err)
	}
	if allowed, err := repo.CanUserAccessGame(ctx, "lalu", item.GameID, 42); err != nil || !allowed {
		t.Fatalf("member launch access mismatch: allowed=%v err=%v", allowed, err)
	}
	members, err := repo.ListGameWhitelistUsers(ctx, "lalu", item.GameID, 200)
	if err != nil || len(members) != 1 || members[0].CreatedByAdminID != 7 {
		t.Fatalf("whitelist member list mismatch: members=%+v err=%v", members, err)
	}
	if err := repo.DeleteGameWhitelistUser(ctx, "lalu", item.GameID, 42); err != nil {
		t.Fatalf("delete whitelist member failed: %v", err)
	}
	if allowed, err := repo.CanUserAccessGame(ctx, "lalu", item.GameID, 42); err != nil || allowed {
		t.Fatalf("removed member must lose launch access: allowed=%v err=%v", allowed, err)
	}
}

func TestMarkOrderSucceededCreatesClaimableLevelOutboxEvent(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	order := gamedomain.GameOrder{
		AppCode:         "lalu",
		OrderID:         "order_1",
		PlatformCode:    "demo",
		ProviderOrderID: "provider_order_1",
		GameID:          "demo_rocket_001",
		ProviderGameID:  "rocket_001",
		UserID:          42,
		RoomID:          "room_1",
		OpType:          "debit",
		CoinAmount:      120,
		Status:          gamedomain.OrderStatusWalletApplying,
		RequestHash:     "hash_1",
		CreatedAtMS:     1700000000000,
		UpdatedAtMS:     1700000000000,
	}
	if _, _, err := repo.CreateGameOrder(ctx, order); err != nil {
		t.Fatalf("create order failed: %v", err)
	}
	if err := repo.MarkOrderSucceeded(ctx, "lalu", "order_1", "wtx-game-1", 880, 1700000001000); err != nil {
		t.Fatalf("mark order succeeded failed: %v", err)
	}

	events, err := repo.ClaimPendingLevelEvents(ctx, "worker_1", 1700000002000, 30*time.Second, 10)
	if err != nil {
		t.Fatalf("claim level events failed: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("expected one level event, got %+v", events)
	}
	event := events[0]
	if event.EventID != "game_level:order_1" || event.UserID != 42 || event.CoinAmount != 120 {
		t.Fatalf("level event mismatch: %+v", event)
	}
	if event.PayloadJSON == "" || event.WalletTransactionID != "wtx-game-1" {
		t.Fatalf("level event payload mismatch: %+v", event)
	}
}

func TestClaimPendingLevelEventsPreservesCreatedOrderAcrossStatuses(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	insertLevelEvent := func(eventID string, orderID string, status string, nextRetryAtMS int64, createdAtMS int64, payload string) {
		t.Helper()
		if _, err := repo.db.ExecContext(ctx, `
			INSERT INTO game_level_event_outbox (
				app_code, event_id, order_id, user_id, game_id, provider_order_id,
				provider_round_id, coin_amount, wallet_transaction_id, payload_json,
				status, attempt_count, next_retry_at_ms, locked_by, locked_until_ms,
				failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
			) VALUES (?, ?, ?, 42, 'game_1', ?, '', 100, 'wallet_tx_1', ?,
			          ?, 0, ?, '', 0, '', ?, ?, ?)`,
			"lalu", eventID, orderID, "provider_"+orderID, payload, status, nextRetryAtMS, createdAtMS, createdAtMS, createdAtMS,
		); err != nil {
			t.Fatalf("insert level event %s failed: %v", eventID, err)
		}
	}
	insertLevelEvent("event_failed_old", "order_failed_old", "failed", 1000, 1000, `{"source":"failed"}`)
	insertLevelEvent("event_failed_future", "order_failed_future", "failed", 999999, 1500, `{"source":"future"}`)
	insertLevelEvent("event_pending_new", "order_pending_new", "pending", 0, 2000, `{"source":"pending"}`)

	events, err := repo.ClaimPendingLevelEvents(ctx, "worker_1", 3000, 30*time.Second, 2)
	if err != nil {
		t.Fatalf("claim level events failed: %v", err)
	}
	if len(events) != 2 {
		t.Fatalf("expected two claimed events, got %+v", events)
	}
	if events[0].EventID != "event_failed_old" || events[0].Status != "failed" || !strings.Contains(events[0].PayloadJSON, `"source": "failed"`) {
		t.Fatalf("first claimed event should be the older failed payload, got %+v", events[0])
	}
	if events[1].EventID != "event_pending_new" || events[1].Status != "pending" || !strings.Contains(events[1].PayloadJSON, `"source": "pending"`) {
		t.Fatalf("second claimed event should be the pending payload, got %+v", events[1])
	}

	var runningCount int
	if err := repo.db.QueryRowContext(ctx, `
		SELECT COUNT(*) FROM game_level_event_outbox
		WHERE app_code = 'lalu' AND status = 'running' AND locked_by = 'worker_1' AND locked_until_ms = 33000`,
	).Scan(&runningCount); err != nil {
		t.Fatalf("query running events failed: %v", err)
	}
	if runningCount != 2 {
		t.Fatalf("expected two running claimed events, got %d", runningCount)
	}
	var futureStatus string
	if err := repo.db.QueryRowContext(ctx, `
		SELECT status FROM game_level_event_outbox WHERE app_code = 'lalu' AND event_id = 'event_failed_future'`,
	).Scan(&futureStatus); err != nil {
		t.Fatalf("query future retry event failed: %v", err)
	}
	if futureStatus != "failed" {
		t.Fatalf("future retry event should stay failed, got %s", futureStatus)
	}
}

func TestClaimPendingLevelEventsReclaimsLegacyAndExpiredRunningLocks(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	insertRunning := func(eventID string, lockedUntilMS int64, createdAtMS int64) {
		t.Helper()
		if _, err := repo.db.ExecContext(ctx, `
			INSERT INTO game_level_event_outbox (
				app_code, event_id, order_id, user_id, game_id, provider_order_id,
				provider_round_id, coin_amount, wallet_transaction_id, payload_json,
				status, attempt_count, next_retry_at_ms, locked_by, locked_until_ms,
				failure_reason, occurred_at_ms, created_at_ms, updated_at_ms
			) VALUES ('lalu', ?, ?, 42, 'game_1', ?, '', 100, 'wallet_tx_1', '{}',
			          'running', 1, 0, 'old-worker', ?, '', ?, ?, ?)`,
			eventID, "order_"+eventID, "provider_"+eventID, lockedUntilMS, createdAtMS, createdAtMS, createdAtMS,
		); err != nil {
			t.Fatalf("insert running level event %s failed: %v", eventID, err)
		}
	}
	insertRunning("legacy_zero_lock", 0, 1000)
	insertRunning("expired_lock", 2500, 1100)
	insertRunning("future_lock", 4000, 1200)

	events, err := repo.ClaimPendingLevelEvents(ctx, "replacement-worker", 3000, 30*time.Second, 10)
	if err != nil {
		t.Fatalf("claim expired running events failed: %v", err)
	}
	if len(events) != 2 || events[0].EventID != "legacy_zero_lock" || events[1].EventID != "expired_lock" {
		t.Fatalf("reclaimed running events mismatch: %+v", events)
	}
	for _, event := range events {
		if event.Status != "running" {
			t.Fatalf("reclaimed event must expose its pre-claim running state: %+v", event)
		}
	}

	var lockedBy string
	var lockedUntilMS int64
	if err := repo.db.QueryRowContext(ctx, `
		SELECT locked_by, locked_until_ms FROM game_level_event_outbox
		WHERE app_code = 'lalu' AND event_id = 'future_lock'`,
	).Scan(&lockedBy, &lockedUntilMS); err != nil {
		t.Fatalf("query future running lock failed: %v", err)
	}
	if lockedBy != "old-worker" || lockedUntilMS != 4000 {
		t.Fatalf("future lock was stolen: locked_by=%q locked_until_ms=%d", lockedBy, lockedUntilMS)
	}
}

func TestLevelEventClaimKeyQueryUsesOrderIndexWithoutFilesort(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	var plan string
	if err := repo.db.QueryRowContext(ctx, `
		EXPLAIN FORMAT=JSON
		SELECT event_id, created_at_ms
		FROM game_level_event_outbox FORCE INDEX (idx_game_level_event_claim_order)
		WHERE app_code = ? AND status = ? AND locked_until_ms = 0 AND next_retry_at_ms <= ?
		ORDER BY created_at_ms ASC, event_id ASC
		LIMIT 100`,
		"lalu", "pending", int64(1780542672724),
	).Scan(&plan); err != nil {
		t.Fatalf("explain level event claim key query failed: %v", err)
	}
	if !strings.Contains(plan, `"key": "idx_game_level_event_claim_order"`) {
		t.Fatalf("claim key query did not use idx_game_level_event_claim_order:\n%s", plan)
	}
	if strings.Contains(strings.ToLower(plan), `"using_filesort": true`) {
		t.Fatalf("claim key query should avoid filesort:\n%s", plan)
	}
}

func TestPickActiveDiceRobotRandomlySamplesWholeActivePool(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	nowMS := int64(1780542672000)
	userIDs := []int64{101, 102}
	for index, userID := range userIDs {
		usedCount := int64(0)
		if userID == 101 {
			usedCount = 999
		}
		if _, err := repo.db.ExecContext(ctx,
			`INSERT INTO game_self_game_robots (
			   app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms,
			   last_used_at_ms, used_count
			 ) VALUES (?, ?, ?, ?, 7, ?, ?, 0, ?)`,
			"lalu", dicedomain.DefaultGameID, userID, dicedomain.RobotStatusActive, nowMS+int64(index), nowMS+int64(index), usedCount,
		); err != nil {
			t.Fatalf("insert robot %d failed: %v", userID, err)
		}
	}

	picked := map[int64]bool{}
	for attempt := 0; attempt < 48; attempt++ {
		robot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, fmt.Sprintf("match_random_%d", attempt), nowMS+100+int64(attempt))
		if err != nil {
			t.Fatalf("pick active dice robot failed: %v", err)
		}
		if !containsInt64(userIDs, robot.UserID) {
			t.Fatalf("random pick %d returned unknown robot: %+v", attempt, robot)
		}
		picked[robot.UserID] = true
		if robot.LastUsedAtMS != nowMS+100+int64(attempt) {
			t.Fatalf("picked robot should return updated usage time, got %+v", robot)
		}
	}
	if len(picked) != len(userIDs) {
		t.Fatalf("full random must keep high used_count robots in the candidate pool, got picked=%+v", picked)
	}

	if _, err := repo.db.ExecContext(ctx,
		`INSERT INTO game_dice_participants (
		   app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, joined_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, 2, ?, 100, ?, ?)`,
		"lalu", "match_random_robot", int64(101), dicedomain.ParticipantTypeRobot, dicedomain.ParticipantStatusJoined, nowMS, nowMS,
	); err != nil {
		t.Fatalf("insert existing robot participant failed: %v", err)
	}

	excluded, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, "match_random_robot", nowMS+300)
	if err != nil {
		t.Fatalf("pick active dice robot with existing participant failed: %v", err)
	}
	if excluded.UserID == 101 {
		t.Fatalf("picked robot already in this match: %+v", excluded)
	}

	rockRobot, err := repo.PickActiveDiceRobot(ctx, "lalu", dicedomain.SelfGameIDRock, "match_random_robot_rock", nowMS+400)
	if err != nil {
		t.Fatalf("rock should reuse the app-wide robot pool: %v", err)
	}
	if rockRobot.GameID != dicedomain.SelfGameIDRock || !containsInt64(userIDs, rockRobot.UserID) {
		t.Fatalf("rock robot should come from the shared app robot pool, got %+v", rockRobot)
	}
}

func TestUniversalDiceRobotManagementIgnoresGameID(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	nowMS := int64(1780542672000)
	rows := []struct {
		gameID string
		userID int64
		status string
	}{
		{gameID: dicedomain.DefaultGameID, userID: 201, status: dicedomain.RobotStatusActive},
		{gameID: dicedomain.SelfGameIDRock, userID: 201, status: dicedomain.RobotStatusDisabled},
		{gameID: dicedomain.SelfGameIDRock, userID: 202, status: dicedomain.RobotStatusDisabled},
	}
	for index, row := range rows {
		if _, err := repo.db.ExecContext(ctx,
			`INSERT INTO game_self_game_robots (
			   app_code, game_id, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms
			 ) VALUES (?, ?, ?, ?, 7, ?, ?)`,
			"lalu", row.gameID, row.userID, row.status, nowMS+int64(index), nowMS+int64(index),
		); err != nil {
			t.Fatalf("insert robot row %+v failed: %v", row, err)
		}
	}

	active, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusActive, 20, "")
	if err != nil {
		t.Fatalf("list active robots failed: %v", err)
	}
	if len(active) != 1 || active[0].UserID != 201 || active[0].Status != dicedomain.RobotStatusActive {
		t.Fatalf("active list should aggregate user 201 once, got %+v", active)
	}
	disabled, _, err := repo.ListDiceRobots(ctx, "lalu", "", dicedomain.RobotStatusDisabled, 20, "")
	if err != nil {
		t.Fatalf("list disabled robots failed: %v", err)
	}
	if len(disabled) != 1 || disabled[0].UserID != 202 || disabled[0].Status != dicedomain.RobotStatusDisabled {
		t.Fatalf("disabled list should only include fully disabled users, got %+v", disabled)
	}

	updated, err := repo.SetDiceRobotStatus(ctx, "lalu", dicedomain.SelfGameIDRock, 201, dicedomain.RobotStatusDisabled, nowMS+100)
	if err != nil {
		t.Fatalf("set universal robot status failed: %v", err)
	}
	if updated.GameID != dicedomain.SelfGameIDRock || updated.Status != dicedomain.RobotStatusDisabled {
		t.Fatalf("status response should keep requested game context while updating shared rows, got %+v", updated)
	}
	var activeRows int
	if err := repo.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ? AND status = ?`,
		"lalu", int64(201), dicedomain.RobotStatusActive,
	).Scan(&activeRows); err != nil {
		t.Fatalf("query active rows failed: %v", err)
	}
	if activeRows != 0 {
		t.Fatalf("status update should affect all game_id rows for user 201, active rows=%d", activeRows)
	}

	if err := repo.DeleteDiceRobot(ctx, "lalu", dicedomain.DefaultGameID, 201); err != nil {
		t.Fatalf("delete universal robot failed: %v", err)
	}
	var remainingRows int
	if err := repo.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`,
		"lalu", int64(201),
	).Scan(&remainingRows); err != nil {
		t.Fatalf("query remaining rows failed: %v", err)
	}
	if remainingRows != 0 {
		t.Fatalf("delete should remove every game_id row for user 201, rows=%d", remainingRows)
	}

	registered, err := repo.RegisterDiceRobots(ctx, "lalu", dicedomain.SelfGameIDRock, []int64{203}, 7, nowMS+200)
	if err != nil {
		t.Fatalf("register universal robot failed: %v", err)
	}
	if !robotListContains(registered, 203, dicedomain.RobotStatusActive) {
		t.Fatalf("registered robot should be returned from shared list, got %+v", registered)
	}
	var registeredGameID string
	if err := repo.db.QueryRowContext(ctx,
		`SELECT game_id FROM game_self_game_robots WHERE app_code = ? AND user_id = ?`,
		"lalu", int64(203),
	).Scan(&registeredGameID); err != nil {
		t.Fatalf("query registered robot failed: %v", err)
	}
	if registeredGameID != dicedomain.DefaultGameID {
		t.Fatalf("new universal robots should use default compatibility game_id, got %s", registeredGameID)
	}
}

func TestSelfGameUserStatsCountsHumanAndRobotLossStreak(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	nowMS := int64(1780542672000)
	rows := []struct {
		matchID   string
		matchMode string
		result    string
		payout    int64
		settledAt int64
	}{
		// 平局不形成用户收益，也不应该打断或消耗新手连输保护判断。
		{matchID: "stats_draw_latest", matchMode: dicedomain.MatchModeHuman, result: dicedomain.ParticipantResultDraw, payout: 100, settledAt: nowMS},
		// 最近两局非平局分别来自机器人局和真人局；新手连输保护必须把这两类自研游戏结果放在同一个体验口径里。
		{matchID: "stats_robot_loss", matchMode: dicedomain.MatchModeRobot, result: dicedomain.ParticipantResultLose, payout: 0, settledAt: nowMS - 1000},
		{matchID: "stats_human_loss", matchMode: dicedomain.MatchModeHuman, result: dicedomain.ParticipantResultLose, payout: 0, settledAt: nowMS - 2000},
		// 更早的机器人胜局只用于验证连输会在结果变化时停止，同时机器人有效局数仍然只看机器人局。
		{matchID: "stats_robot_win", matchMode: dicedomain.MatchModeRobot, result: dicedomain.ParticipantResultWin, payout: 188, settledAt: nowMS - 3000},
	}
	for _, row := range rows {
		if _, err := repo.db.ExecContext(ctx,
			`INSERT INTO game_dice_matches (
			   app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id,
			   min_players, max_players, current_players, stake_coin, round_no, status, result,
			   join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
			   fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
			 ) VALUES (?, ?, ?, 'dice', ?, '', 0, 2, 2, 2, 100, 1, ?, ?, 0, ?, ?, ?, ?, 0, 500, 100, ?, '', 0)`,
			"lalu", row.matchID, dicedomain.DefaultGameID, dicedomain.DefaultGameID,
			dicedomain.MatchStatusSettled, row.result,
			row.settledAt, row.settledAt, row.settledAt, row.settledAt, row.matchMode,
		); err != nil {
			t.Fatalf("insert match %+v failed: %v", row, err)
		}
		if _, err := repo.db.ExecContext(ctx,
			`INSERT INTO game_dice_participants (
			   app_code, match_id, user_id, participant_type, seat_no, status, stake_coin, dice_points_json,
			   rps_gesture, result, payout_coin, joined_at_ms, updated_at_ms
			 ) VALUES (?, ?, 401, ?, 1, ?, 100, CAST('[6]' AS JSON), '', ?, ?, ?, ?)`,
			"lalu", row.matchID, dicedomain.ParticipantTypeUser, dicedomain.ParticipantStatusSettled,
			row.result, row.payout, row.settledAt, row.settledAt,
		); err != nil {
			t.Fatalf("insert participant %+v failed: %v", row, err)
		}
	}

	stats, err := repo.GetSelfGameUserStats(ctx, "lalu", dicedomain.DefaultGameID, 401, nowMS)
	if err != nil {
		t.Fatalf("query self game stats failed: %v", err)
	}
	if stats.EffectiveBotGameCount != 2 || stats.TodayRobotGameCount != 2 {
		t.Fatalf("robot counters should only count non-draw robot games, got %+v", stats)
	}
	if stats.LoseStreak != 2 || stats.WinStreak != 0 {
		t.Fatalf("loss streak should include human and robot non-draw losses, got %+v", stats)
	}
	if stats.TodayNetWinCoin != -112 || stats.SevenDayNetWinCoin != -112 {
		t.Fatalf("net win should include human and robot non-draw games, got %+v", stats)
	}
}

func containsInt64(values []int64, target int64) bool {
	for _, value := range values {
		if value == target {
			return true
		}
	}
	return false
}

func robotListContains(robots []dicedomain.Robot, userID int64, status string) bool {
	for _, robot := range robots {
		if robot.UserID == userID && robot.Status == status {
			return true
		}
	}
	return false
}

func TestUpsertCatalogCreatesDefaultVoiceRoomDisplayRule(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	item := gamedomain.CatalogItem{
		AppCode:        "lalu",
		GameID:         "sync_game_001",
		PlatformCode:   "demo",
		ProviderGameID: "provider_sync_001",
		GameName:       "Sync Game",
		Category:       "casino",
		LaunchMode:     gamedomain.LaunchModeH5Popup,
		Orientation:    "portrait",
		Status:         gamedomain.StatusActive,
		SortOrder:      70,
		Tags:           []string{"sync"},
	}
	if _, err := repo.UpsertCatalog(ctx, item); err != nil {
		t.Fatalf("upsert catalog failed: %v", err)
	}

	var scene string
	var visible, enabled bool
	var sortOrder int32
	if err := repo.db.QueryRowContext(ctx,
		`SELECT scene, visible, enabled, sort_order
		 FROM game_display_rules WHERE app_code = ? AND rule_id = ? AND game_id = ?`,
		"lalu", defaultVoiceRoomRuleID(item.GameID), item.GameID,
	).Scan(&scene, &visible, &enabled, &sortOrder); err != nil {
		t.Fatalf("query display rule failed: %v", err)
	}
	if scene != gamedomain.SceneVoiceRoom || !visible || !enabled || sortOrder != item.SortOrder {
		t.Fatalf("display rule mismatch: scene=%s visible=%v enabled=%v sort=%d", scene, visible, enabled, sortOrder)
	}
}

func TestDeleteCatalogRemovesGameAndDisplayRule(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	item := gamedomain.CatalogItem{
		AppCode:        "lalu",
		GameID:         "delete_game_001",
		PlatformCode:   "demo",
		ProviderGameID: "provider_delete_001",
		GameName:       "Delete Game",
		Category:       "casino",
		LaunchMode:     gamedomain.LaunchModeH5Popup,
		Orientation:    "portrait",
		Status:         gamedomain.StatusActive,
		SortOrder:      80,
	}
	if _, err := repo.UpsertCatalog(ctx, item); err != nil {
		t.Fatalf("upsert catalog failed: %v", err)
	}
	if err := repo.DeleteCatalog(ctx, "lalu", item.GameID); err != nil {
		t.Fatalf("delete catalog failed: %v", err)
	}

	var gameCount int
	if err := repo.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM game_catalog WHERE app_code = ? AND game_id = ?`,
		"lalu", item.GameID,
	).Scan(&gameCount); err != nil {
		t.Fatalf("query catalog count failed: %v", err)
	}
	if gameCount != 0 {
		t.Fatalf("game_catalog count = %d, want 0", gameCount)
	}

	var ruleCount int
	if err := repo.db.QueryRowContext(ctx,
		`SELECT COUNT(*) FROM game_display_rules WHERE app_code = ? AND game_id = ?`,
		"lalu", item.GameID,
	).Scan(&ruleCount); err != nil {
		t.Fatalf("query display rule count failed: %v", err)
	}
	if ruleCount != 0 {
		t.Fatalf("game_display_rules count = %d, want 0", ruleCount)
	}
}

func TestDeleteCatalogRejectsBuiltInSelfGames(t *testing.T) {
	caller := mysqlschema.CallerFile(t, 1)
	schema := mysqlschema.New(t, mysqlschema.Config{
		InitDBPath:     mysqlschema.InitDBPath(t, caller, "../../../deploy/mysql/initdb/001_game_service.sql"),
		DatabasePrefix: "hy_game_test",
	})

	ctx := appcode.WithContext(context.Background(), "lalu")
	repo, err := Open(ctx, schema.DSN)
	if err != nil {
		t.Fatalf("open repository failed: %v", err)
	}
	t.Cleanup(func() { _ = repo.Close() })

	for _, gameID := range []string{dicedomain.DefaultGameID, dicedomain.SelfGameIDRock} {
		err := repo.DeleteCatalog(ctx, "lalu", gameID)
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("delete built-in self game %s should be rejected, got %v", gameID, err)
		}
	}
}
