package dice

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

	walletv1 "hyapp.local/api/proto/wallet/v1"
	"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"
)

func TestRollMatchDebitsRollsAndPaysWinner(t *testing.T) {
	repo := newFakeDiceRepository()
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
	svc := New(Config{FeeBPS: 500}, repo, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
	rolls := []int{5, 0}
	svc.randomInt = func(max int) (int, error) {
		if len(rolls) == 0 {
			t.Fatal("randomInt called too many times")
		}
		value := rolls[0]
		rolls = rolls[1:]
		return value, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-create",
		UserID:     101,
		GameID:     "dice",
		RoomID:     "room_1",
		RegionID:   100,
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 3,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join", UserID: 202, MatchID: created.MatchID}); err != nil {
		t.Fatalf("JoinMatch failed: %v", err)
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll", UserID: 101, MatchID: created.MatchID})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin {
		t.Fatalf("match not settled as win: %+v", settled)
	}
	if len(settled.Participants) != 2 {
		t.Fatalf("participants len = %d", len(settled.Participants))
	}
	winner := participantByUser(settled.Participants, 101)
	loser := participantByUser(settled.Participants, 202)
	if len(winner.DicePoints) != 1 || winner.DicePoints[0] != 6 {
		t.Fatalf("winner should have one dice point, got %+v", winner.DicePoints)
	}
	if len(loser.DicePoints) != 1 || loser.DicePoints[0] != 1 {
		t.Fatalf("loser should have one dice point, got %+v", loser.DicePoints)
	}
	if winner.Result != dicedomain.ParticipantResultWin || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 {
		t.Fatalf("winner settlement mismatch: %+v", winner)
	}
	if loser.Result != dicedomain.ParticipantResultLose || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 {
		t.Fatalf("loser settlement mismatch: %+v", loser)
	}
	if wallet.calls != 3 {
		t.Fatalf("wallet calls = %d, want 3", wallet.calls)
	}
	if repo.levelDebitOrders != 2 {
		t.Fatalf("debit orders should still feed game order success path, got %d", repo.levelDebitOrders)
	}
	if len(repo.exploreWinners) != 1 {
		t.Fatalf("explore winner records = %d, want 1", len(repo.exploreWinners))
	}
	exploreWinner := repo.exploreWinners[0]
	if exploreWinner.GameCode != dicedomain.ExploreGameCodeDice || exploreWinner.GameID != "dice" || exploreWinner.UserID != 101 || exploreWinner.CoinAmount != 188 {
		t.Fatalf("explore winner mismatch: %+v", exploreWinner)
	}
	if exploreWinner.WinID == "" || exploreWinner.WonAtMS != 1700000000000 {
		t.Fatalf("explore winner identity/time mismatch: %+v", exploreWinner)
	}
}

func TestRollMatchRequiresMinimumParticipantsBeforeWalletDebit(t *testing.T) {
	repo := newFakeDiceRepository()
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 3,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	_, _, err = svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll", UserID: 101, MatchID: created.MatchID})
	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("RollMatch should reject not enough participants, got %v", err)
	}
	if wallet.calls != 0 {
		t.Fatalf("wallet should not be called, got %d", wallet.calls)
	}
}

func TestRollMatchDrawRerollsUntilWinner(t *testing.T) {
	repo := newFakeDiceRepository()
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
	svc := New(Config{FeeBPS: 500}, repo, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
	rolls := []int{2, 2, 5, 0}
	svc.randomInt = func(max int) (int, error) {
		if len(rolls) == 0 {
			t.Fatal("randomInt called too many times")
		}
		value := rolls[0]
		rolls = rolls[1:]
		return value, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join", UserID: 202, MatchID: created.MatchID}); err != nil {
		t.Fatalf("JoinMatch failed: %v", err)
	}

	draw, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-1", UserID: 101, MatchID: created.MatchID})
	if err != nil {
		t.Fatalf("first RollMatch failed: %v", err)
	}
	if draw.Status != dicedomain.MatchStatusReady || draw.Result != dicedomain.ParticipantResultDraw || draw.RoundNo != 2 {
		t.Fatalf("draw should keep match ready for reroll: %+v", draw)
	}
	if wallet.calls != 2 {
		t.Fatalf("draw should only debit both users, wallet calls = %d", wallet.calls)
	}
	for _, participant := range draw.Participants {
		if participant.Result != dicedomain.ParticipantResultDraw || participant.PayoutCoin != 0 || len(participant.DicePoints) != 1 || participant.DicePoints[0] != 3 {
			t.Fatalf("draw participant mismatch: %+v", participant)
		}
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-2", UserID: 101, MatchID: created.MatchID})
	if err != nil {
		t.Fatalf("second RollMatch failed: %v", err)
	}
	if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin {
		t.Fatalf("reroll should settle with winner: %+v", settled)
	}
	if wallet.calls != 3 {
		t.Fatalf("reroll should not debit again and should only pay winner, wallet calls = %d", wallet.calls)
	}
	winner := participantByUser(settled.Participants, 101)
	loser := participantByUser(settled.Participants, 202)
	if winner.DicePoints[0] != 6 || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 {
		t.Fatalf("winner after reroll mismatch: %+v", winner)
	}
	if loser.DicePoints[0] != 1 || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 {
		t.Fatalf("loser after reroll mismatch: %+v", loser)
	}
}

func TestRPSRollUsesPreselectedGesturesWithoutRandomRoll(t *testing.T) {
	repo := newFakeDiceRepository()
	configureFakeRock(repo)
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
	svc := New(Config{FeeBPS: 500}, repo, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
	svc.randomInt = func(max int) (int, error) {
		t.Fatalf("rock settlement must not call randomInt, max=%d", max)
		return 0, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-create-rps",
		UserID:     101,
		GameID:     "rock",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
		RPSGesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join-rps", UserID: 202, MatchID: created.MatchID, GameID: "rock", RPSGesture: "scissors"}); err != nil {
		t.Fatalf("JoinMatch failed: %v", err)
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-rps", UserID: 101, MatchID: created.MatchID, GameID: "rock", RPSGesture: "rock"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultWin {
		t.Fatalf("rock match should settle with winner: %+v", settled)
	}
	winner := participantByUser(settled.Participants, 101)
	loser := participantByUser(settled.Participants, 202)
	if winner.RPSGesture != "rock" || len(winner.DicePoints) != 1 || winner.DicePoints[0] != 1 || winner.Result != dicedomain.ParticipantResultWin || winner.PayoutCoin != 188 || winner.BalanceAfter != 1088 {
		t.Fatalf("winner rps settlement mismatch: %+v", winner)
	}
	if loser.RPSGesture != "scissors" || len(loser.DicePoints) != 1 || loser.DicePoints[0] != 3 || loser.Result != dicedomain.ParticipantResultLose || loser.PayoutCoin != 0 || loser.BalanceAfter != 900 {
		t.Fatalf("loser rps settlement mismatch: %+v", loser)
	}
	if wallet.calls != 3 {
		t.Fatalf("rock win should debit both and pay winner, wallet calls=%d", wallet.calls)
	}
	if len(repo.exploreWinners) != 1 || repo.exploreWinners[0].GameCode != dicedomain.ExploreGameCodeRock || repo.exploreWinners[0].UserID != 101 {
		t.Fatalf("rock explore winner mismatch: %+v", repo.exploreWinners)
	}
}

func TestRPSDrawRefundsBothAndSettles(t *testing.T) {
	repo := newFakeDiceRepository()
	configureFakeRock(repo)
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
	svc := New(Config{FeeBPS: 500}, repo, wallet)
	svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
	svc.randomInt = func(max int) (int, error) {
		t.Fatalf("rock draw settlement must not call randomInt, max=%d", max)
		return 0, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-create-rps-draw",
		UserID:     101,
		GameID:     "rock",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
		RPSGesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	if _, _, err := svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", RequestID: "req-join-rps-draw", UserID: 202, MatchID: created.MatchID, GameID: "rock", RPSGesture: "rock"}); err != nil {
		t.Fatalf("JoinMatch failed: %v", err)
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-roll-rps-draw", UserID: 101, MatchID: created.MatchID, GameID: "rock"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	if settled.Status != dicedomain.MatchStatusSettled || settled.Result != dicedomain.ParticipantResultDraw || settled.RoundNo != 1 {
		t.Fatalf("rock draw should settle without reroll: %+v", settled)
	}
	for _, participant := range settled.Participants {
		if participant.Result != dicedomain.ParticipantResultDraw || participant.PayoutCoin != participant.StakeCoin || participant.BalanceAfter != 1000 || participant.RPSGesture != "rock" {
			t.Fatalf("draw participant should be refunded: %+v", participant)
		}
	}
	if wallet.calls != 4 {
		t.Fatalf("rock draw should debit and refund both users, wallet calls=%d", wallet.calls)
	}
	if len(repo.exploreWinners) != 0 {
		t.Fatalf("draw must not record explore winners: %+v", repo.exploreWinners)
	}
}

func TestRPSRequiresGestureBeforeMatchmaking(t *testing.T) {
	tests := []struct {
		name string
		call func(*Service) (dicedomain.Match, int64, error)
	}{
		{
			name: "create",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.CreateMatch(context.Background(), CreateMatchCommand{AppCode: "lalu", UserID: 101, GameID: "rock", StakeCoin: 100, MinPlayers: 2, MaxPlayers: 2})
			},
		},
		{
			name: "match",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.Match(context.Background(), MatchCommand{AppCode: "lalu", UserID: 101, GameID: "rock", StakeCoin: 100})
			},
		},
		{
			name: "join",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", UserID: 202, MatchID: "match-rock-1", GameID: "rock"})
			},
		},
	}
	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			repo := newFakeDiceRepository()
			configureFakeRock(repo)
			repo.match = dicedomain.Match{
				AppCode:        "lalu",
				MatchID:        "match-rock-1",
				GameID:         "rock",
				PlatformCode:   "dice",
				ProviderGameID: "rock",
				MinPlayers:     2,
				MaxPlayers:     2,
				CurrentPlayers: 1,
				StakeCoin:      100,
				Status:         dicedomain.MatchStatusCreated,
				Participants: []dicedomain.Participant{{
					AppCode:    "lalu",
					MatchID:    "match-rock-1",
					UserID:     101,
					SeatNo:     1,
					Status:     dicedomain.ParticipantStatusJoined,
					StakeCoin:  100,
					RPSGesture: "paper",
				}},
			}
			wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
			svc := New(Config{}, repo, wallet)

			_, _, err := tt.call(svc)

			if !xerr.IsCode(err, xerr.InvalidArgument) {
				t.Fatalf("%s should reject missing rps gesture, got %v", tt.name, err)
			}
			if wallet.calls != 0 {
				t.Fatalf("%s should reject before wallet mutation, calls=%d", tt.name, wallet.calls)
			}
		})
	}
}

func TestRPSRollRejectsGestureMismatchBeforeMutation(t *testing.T) {
	repo := newFakeDiceRepository()
	configureFakeRock(repo)
	repo.match = dicedomain.Match{
		AppCode:        "lalu",
		MatchID:        "match-rock-1",
		GameID:         "rock",
		PlatformCode:   "dice",
		ProviderGameID: "rock",
		MinPlayers:     2,
		MaxPlayers:     2,
		CurrentPlayers: 2,
		StakeCoin:      100,
		Status:         dicedomain.MatchStatusReady,
		Participants: []dicedomain.Participant{
			{AppCode: "lalu", MatchID: "match-rock-1", UserID: 101, SeatNo: 1, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "rock"},
			{AppCode: "lalu", MatchID: "match-rock-1", UserID: 202, SeatNo: 2, Status: dicedomain.ParticipantStatusJoined, StakeCoin: 100, RPSGesture: "paper"},
		},
	}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}}
	svc := New(Config{}, repo, wallet)

	_, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "rock", RPSGesture: "scissors"})

	if !xerr.IsCode(err, xerr.Conflict) {
		t.Fatalf("roll should reject changed rps gesture, got %v", err)
	}
	if repo.match.Status != dicedomain.MatchStatusReady || wallet.calls != 0 {
		t.Fatalf("gesture mismatch must not claim roll or call wallet: match=%+v wallet=%d", repo.match, wallet.calls)
	}
}

func TestNewUserRobotDiceMatchUsesProtectionStrategyWhenPoolIsGreen(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9001, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	rolls := []int{0, 0, 5, 4}
	svc.randomInt = func(max int) (int, error) {
		if len(rolls) == 0 {
			t.Fatal("randomInt called too many times")
		}
		value := rolls[0]
		rolls = rolls[1:]
		return value, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-first-dice-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerWin {
		t.Fatalf("new user protection should force player win when strategy hits, got %+v", ready)
	}
	if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "NEW_USER_POSITIVE_FEEDBACK" {
		t.Fatalf("strategy log mismatch: %+v", repo.strategyLogs)
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-first-dice-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	human := participantByUser(settled.Participants, 101)
	robot := participantByUser(settled.Participants, 9001)
	if human.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || human.PayoutCoin != 188 || human.BalanceAfter != 1088 {
		t.Fatalf("first dice human should win from forced points: %+v", human)
	}
	if robot.Result != dicedomain.ParticipantResultLose || len(robot.DicePoints) != 1 {
		t.Fatalf("first dice robot should lose from forced points: %+v", robot)
	}
	if human.DicePoints[0] <= robot.DicePoints[0] || (human.DicePoints[0] == 6 && robot.DicePoints[0] == 1) {
		t.Fatalf("forced win should use a random winning dice pair instead of fixed 6:1, human=%+v robot=%+v", human, robot)
	}
	if repo.poolBalance != 112 || repo.stakePool.BalanceCoin != 112 {
		t.Fatalf("pool should pay only human net win coin, legacy=%d stake=%d", repo.poolBalance, repo.stakePool.BalanceCoin)
	}
	if len(repo.protectionEvents) != 1 || repo.protectionEvents[0].LifetimeSubsidyCoinDelta != 88 {
		t.Fatalf("new user protection should consume one effective subsidized robot game, events=%+v", repo.protectionEvents)
	}
	if wallet.calls != 2 {
		t.Fatalf("robot match should debit and pay only human wallet, calls=%d", wallet.calls)
	}
}

func TestForcedHumanLoseUsesRandomLosingDicePair(t *testing.T) {
	svc := New(Config{}, newFakeDiceRepository(), &fakeDiceWallet{balances: map[int64]int64{}})
	svc.randomInt = func(max int) (int, error) {
		if max != 14 {
			t.Fatalf("forced lose should random from non-fixed losing pairs, max=%d", max)
		}
		return 11, nil
	}
	participants := []dicedomain.Participant{
		{AppCode: "lalu", MatchID: "match-forced-lose", UserID: 101, ParticipantType: dicedomain.ParticipantTypeUser, StakeCoin: 100},
		{AppCode: "lalu", MatchID: "match-forced-lose", UserID: 9001, ParticipantType: dicedomain.ParticipantTypeRobot, StakeCoin: 100},
	}

	if err := svc.forceHumanLose(participants); err != nil {
		t.Fatalf("forceHumanLose failed: %v", err)
	}
	human := participantByUser(participants, 101)
	robot := participantByUser(participants, 9001)
	if len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] >= robot.DicePoints[0] || (human.DicePoints[0] == 1 && robot.DicePoints[0] == 6) {
		t.Fatalf("forced lose should use a random losing dice pair instead of fixed 1:6, human=%+v robot=%+v", human, robot)
	}
}

func TestNewUserLoseStreakProtectionForcesRobotMatchWin(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.userStats = selfgame.UserStats{EffectiveBotGameCount: 3, LoseStreak: 2}
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9011, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	svc.randomInt = func(max int) (int, error) {
		t.Fatalf("lose streak protection should bypass probability random, max=%d", max)
		return 0, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-new-losing-streak-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerWin {
		t.Fatalf("new user lose streak protection should force player win, got %+v", ready)
	}
	if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "NEW_USER_LOSE_STREAK_PROTECTION" {
		t.Fatalf("strategy log mismatch: %+v", repo.strategyLogs)
	}
}

func TestStakePoolBlackSkipsRobotAttachment(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 87
	repo.poolBalance = 87
	repo.stakePool.BalanceCoin = 87
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-short-pool-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.MatchMode == dicedomain.MatchModeRobot || ready.CurrentPlayers != 1 {
		t.Fatalf("black stake pool should not attach robot, got %+v", ready)
	}
	if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "POOL_BLACK" {
		t.Fatalf("black pool should write pool guard strategy log, logs=%+v", repo.strategyLogs)
	}
}

func TestStakePoolBlackCanForceRobotWinWhenEnabled(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 87
	repo.poolBalance = 87
	repo.stakePool.BalanceCoin = 87
	repo.newUserPolicy.BlackPoolRobotForceWinEnabled = true
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9004, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-black-force-robot-win-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.MatchMode != dicedomain.MatchModeRobot || ready.ForcedResult != dicedomain.ForcedResultPlayerLose {
		t.Fatalf("black pool force switch should attach robot and force player lose, got %+v", ready)
	}
	if len(repo.strategyLogs) != 1 ||
		repo.strategyLogs[0].ReasonCode != "POOL_BLACK_ROBOT_FORCE_WIN" ||
		repo.strategyLogs[0].ForceResult != dicedomain.ForcedResultPlayerLose {
		t.Fatalf("black pool force switch should write force-lose strategy log, logs=%+v", repo.strategyLogs)
	}

	rolls := []struct {
		max   int
		value int
	}{
		{max: 6, value: 0},
		{max: 6, value: 5},
		{max: 14, value: 11},
	}
	svc.randomInt = func(max int) (int, error) {
		if len(rolls) == 0 {
			t.Fatal("randomInt called too many times")
		}
		next := rolls[0]
		rolls = rolls[1:]
		if max != next.max {
			t.Fatalf("randomInt max = %d, want %d", max, next.max)
		}
		return next.value, nil
	}
	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-black-force-robot-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	human := participantByUser(settled.Participants, 101)
	robot := participantByUser(settled.Participants, 9004)
	if human.Result != dicedomain.ParticipantResultLose || robot.Result != dicedomain.ParticipantResultWin || len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] >= robot.DicePoints[0] {
		t.Fatalf("black pool force switch should settle as robot win with random losing pair, human=%+v robot=%+v", human, robot)
	}
	if repo.poolBalance != 182 || repo.stakePool.BalanceCoin != 182 {
		t.Fatalf("black pool force robot win should put user stake net of fee into pool, legacy=%d stake=%d", repo.poolBalance, repo.stakePool.BalanceCoin)
	}
}

func TestNewUserRobotRockChoosesLosingGestureWhenProtectionHits(t *testing.T) {
	repo := newFakeDiceRepository()
	configureFakeRock(repo)
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "rock", UserID: 9002, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	svc.randomInt = func(max int) (int, error) {
		if max != 100 {
			t.Fatalf("new user strategy should only roll protection probability before forced rock gesture, max=%d", max)
		}
		return 0, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-first-rock-create",
		UserID:     101,
		GameID:     "rock",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
		RPSGesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "rock"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.ForcedResult != dicedomain.ForcedResultPlayerWin || repo.joinRobotRPSGesture != dicedomain.RPSGestureScissors {
		t.Fatalf("protected rock robot should choose losing gesture, forced=%q gesture=%q", ready.ForcedResult, repo.joinRobotRPSGesture)
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-first-rock-roll", UserID: 101, MatchID: created.MatchID, GameID: "rock"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	human := participantByUser(settled.Participants, 101)
	robot := participantByUser(settled.Participants, 9002)
	if human.Result != dicedomain.ParticipantResultWin || human.RPSGesture != dicedomain.RPSGestureRock || human.PayoutCoin != 188 || human.BalanceAfter != 1088 {
		t.Fatalf("first rock human should keep selected gesture and win: %+v", human)
	}
	if robot.Result != dicedomain.ParticipantResultLose || robot.RPSGesture != dicedomain.RPSGestureScissors {
		t.Fatalf("first rock robot should lose with losing gesture: %+v", robot)
	}
	if len(repo.protectionEvents) != 1 {
		t.Fatalf("protected non-draw rock robot game should consume protection, events=%+v", repo.protectionEvents)
	}
}

func TestOldUserRobotMatchKeepsNormalRandom(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9003, Status: dicedomain.RobotStatusActive}
	repo.userStats = selfgame.UserStats{EffectiveBotGameCount: 31, WinStreak: 9, TodayNetWinCoin: 900000, SevenDayNetWinCoin: 1800000}
	repo.newUserPolicy.NormalPhaseTargetWinRatePercent = 50
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	rolls := []int{5, 5}
	svc.randomInt = func(max int) (int, error) {
		if len(rolls) == 0 {
			t.Fatal("randomInt called too many times")
		}
		value := rolls[0]
		rolls = rolls[1:]
		return value, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-normal-after-win-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.ForcedResult != "" {
		t.Fatalf("old user robot match must not force result, got forced_result=%q", ready.ForcedResult)
	}
	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-normal-after-win-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	human := participantByUser(settled.Participants, 101)
	if human.Result != dicedomain.ParticipantResultDraw || len(human.DicePoints) != 1 || human.DicePoints[0] != 6 || human.PayoutCoin != 0 {
		t.Fatalf("old user normal random dice should settle from natural dice points, got %+v", human)
	}
	if len(repo.protectionEvents) != 0 {
		t.Fatalf("old user normal random must not consume new user protection, events=%+v", repo.protectionEvents)
	}
}

func TestNormalPhaseTargetWinRateForcesWinWithoutProtectionConsume(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9005, Status: dicedomain.RobotStatusActive}
	repo.userStats = selfgame.UserStats{EffectiveBotGameCount: 31}
	repo.protectionState.UsedRounds = 30
	repo.newUserPolicy.NormalPhaseTargetWinRatePercent = 53
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	rolls := []int{0, 0, 5, 8}
	svc.randomInt = func(max int) (int, error) {
		if len(rolls) == 0 {
			t.Fatal("randomInt called too many times")
		}
		value := rolls[0]
		rolls = rolls[1:]
		return value, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-normal-target-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.ForcedResult != dicedomain.ForcedResultPlayerWinWithoutProtection {
		t.Fatalf("normal phase target should force player win without protection consume, forced=%q", ready.ForcedResult)
	}
	if len(repo.strategyLogs) != 1 || repo.strategyLogs[0].ReasonCode != "NORMAL_PHASE_TARGET_HIT" {
		t.Fatalf("strategy log mismatch: %+v", repo.strategyLogs)
	}

	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-normal-target-roll", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	human := participantByUser(settled.Participants, 101)
	robot := participantByUser(settled.Participants, 9005)
	if human.Result != dicedomain.ParticipantResultWin || human.PayoutCoin != 188 || human.BalanceAfter != 1088 {
		t.Fatalf("normal phase target should settle as human win, human=%+v", human)
	}
	if len(human.DicePoints) != 1 || len(robot.DicePoints) != 1 || human.DicePoints[0] <= robot.DicePoints[0] || (human.DicePoints[0] == 6 && robot.DicePoints[0] == 1) {
		t.Fatalf("normal phase target forced win should use a random winning dice pair, human=%+v robot=%+v", human, robot)
	}
	if len(repo.protectionEvents) != 0 {
		t.Fatalf("normal phase target must not consume new user protection, events=%+v", repo.protectionEvents)
	}
}

func TestUpdateSelfGameNewUserPolicyRejectsInvalidNormalPhaseTarget(t *testing.T) {
	repo := newFakeDiceRepository()
	svc := New(Config{}, repo, &fakeDiceWallet{balances: map[int64]int64{}})

	for _, percent := range []int32{49, 101} {
		_, _, err := svc.UpdateSelfGameNewUserPolicy(context.Background(), selfgame.NewUserPolicy{
			AppCode:                         "lalu",
			GameID:                          "dice",
			Enabled:                         true,
			NormalPhaseTargetWinRatePercent: percent,
		})
		if !xerr.IsCode(err, xerr.InvalidArgument) {
			t.Fatalf("normal phase target %d should be rejected, got %v", percent, err)
		}
	}
}

func TestNewUserQuotaShortFallsBackToNormalRandom(t *testing.T) {
	repo := newFakeDiceRepository()
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.protectionState = selfgame.ProtectionState{Status: "active", LifetimeSubsidyCoin: 29950}
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "dice", UserID: 9101, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	svc.randomInt = func(max int) (int, error) {
		t.Fatalf("quota short should fall back without protection probability roll, max=%d", max)
		return 0, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-first-win-over-rate-create",
		UserID:     101,
		GameID:     "dice",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "dice"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.ForcedResult != "" {
		t.Fatalf("quota short should keep normal random forced_result, got %q", ready.ForcedResult)
	}
}

func TestRockDrawDoesNotConsumeProtectionQuota(t *testing.T) {
	repo := newFakeDiceRepository()
	configureFakeRock(repo)
	repo.config.PoolBalanceCoin = 200
	repo.poolBalance = 200
	repo.stakePool.BalanceCoin = 200
	repo.protectionState = selfgame.ProtectionState{Status: "active", LifetimeSubsidyCoin: 29950}
	repo.robot = dicedomain.Robot{AppCode: "lalu", GameID: "rock", UserID: 9102, Status: dicedomain.RobotStatusActive}
	wallet := &fakeDiceWallet{balances: map[int64]int64{101: 1000}}
	svc := New(Config{}, repo, wallet)
	nowMS := int64(1700000000000)
	svc.now = func() time.Time { return time.UnixMilli(nowMS) }
	svc.randomInt = func(max int) (int, error) {
		if max != 3 {
			t.Fatalf("quota short rock should only randomize robot gesture, max=%d", max)
		}
		return 0, nil
	}

	created, _, err := svc.CreateMatch(context.Background(), CreateMatchCommand{
		AppCode:    "lalu",
		RequestID:  "req-rock-draw-create",
		UserID:     101,
		GameID:     "rock",
		StakeCoin:  100,
		MinPlayers: 2,
		MaxPlayers: 2,
		RPSGesture: "rock",
	})
	if err != nil {
		t.Fatalf("CreateMatch failed: %v", err)
	}
	nowMS += dicedomain.DefaultRobotWaitMS
	ready, _, err := svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: created.MatchID, GameID: "rock"})
	if err != nil {
		t.Fatalf("GetMatch attach robot failed: %v", err)
	}
	if ready.ForcedResult != "" || repo.joinRobotRPSGesture != dicedomain.RPSGestureRock {
		t.Fatalf("quota short rock should be normal random draw setup, forced=%q gesture=%q", ready.ForcedResult, repo.joinRobotRPSGesture)
	}
	settled, _, err := svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", RequestID: "req-rock-draw-roll", UserID: 101, MatchID: created.MatchID, GameID: "rock"})
	if err != nil {
		t.Fatalf("RollMatch failed: %v", err)
	}
	if settled.Result != dicedomain.ParticipantResultDraw {
		t.Fatalf("rock draw expected, got result=%q", settled.Result)
	}
	if len(repo.protectionEvents) != 0 {
		t.Fatalf("rock draw must not consume protection quota, events=%+v", repo.protectionEvents)
	}
}

func TestExploreGameCodeForMatchUsesSelfGameIDs(t *testing.T) {
	tests := []struct {
		gameID string
		want   string
	}{
		{gameID: "dice", want: dicedomain.ExploreGameCodeDice},
		{gameID: "rock", want: dicedomain.ExploreGameCodeRock},
		{gameID: "room_rps", want: ""},
		{gameID: "ocean_hunt", want: ""},
	}
	for _, tt := range tests {
		if got := exploreGameCodeForMatch(tt.gameID); got != tt.want {
			t.Fatalf("exploreGameCodeForMatch(%q) = %q, want %q", tt.gameID, got, tt.want)
		}
	}
}

func TestSelfGameOperationsRejectWrongGameIDBeforeMutation(t *testing.T) {
	tests := []struct {
		name string
		call func(*Service) (dicedomain.Match, int64, error)
	}{
		{
			name: "join",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.JoinMatch(context.Background(), JoinMatchCommand{AppCode: "lalu", UserID: 202, MatchID: "match-rock-1", GameID: "dice"})
			},
		},
		{
			name: "get",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.GetMatch(context.Background(), GetMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"})
			},
		},
		{
			name: "roll",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.RollMatch(context.Background(), RollMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"})
			},
		},
		{
			name: "cancel",
			call: func(svc *Service) (dicedomain.Match, int64, error) {
				return svc.CancelMatch(context.Background(), CancelMatchCommand{AppCode: "lalu", UserID: 101, MatchID: "match-rock-1", GameID: "dice"})
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			repo := newFakeDiceRepository()
			repo.match = dicedomain.Match{
				AppCode:        "lalu",
				MatchID:        "match-rock-1",
				GameID:         "rock",
				PlatformCode:   "dice",
				ProviderGameID: "rock",
				MinPlayers:     2,
				MaxPlayers:     2,
				CurrentPlayers: 1,
				StakeCoin:      100,
				Status:         dicedomain.MatchStatusCreated,
				Participants: []dicedomain.Participant{{
					AppCode:   "lalu",
					MatchID:   "match-rock-1",
					UserID:    101,
					SeatNo:    1,
					Status:    dicedomain.ParticipantStatusJoined,
					StakeCoin: 100,
				}},
			}
			svc := New(Config{}, repo, &fakeDiceWallet{balances: map[int64]int64{101: 1000, 202: 1000}})

			_, _, err := tt.call(svc)

			if !xerr.IsCode(err, xerr.NotFound) {
				t.Fatalf("%s should hide cross-game match as not found, got %v", tt.name, err)
			}
			if repo.match.Status != dicedomain.MatchStatusCreated || len(repo.match.Participants) != 1 {
				t.Fatalf("%s must not mutate mismatched game match: %+v", tt.name, repo.match)
			}
		})
	}
}

type fakeDiceRepository struct {
	game                gamedomain.LaunchableGame
	match               dicedomain.Match
	config              dicedomain.Config
	robot               dicedomain.Robot
	poolBalance         int64
	orders              map[string]gamedomain.GameOrder
	userWins            map[string]bool
	joinRobotRPSGesture string
	stakePool           selfgame.StakePool
	newUserPolicy       selfgame.NewUserPolicy
	protectionState     selfgame.ProtectionState
	riskResult          selfgame.RiskResult
	userStats           selfgame.UserStats
	strategyLogs        []selfgame.StrategyLog
	protectionEvents    []selfgame.ProtectionEvent
	levelDebitOrders    int
	exploreWinners      []dicedomain.ExploreWinner
}

func newFakeDiceRepository() *fakeDiceRepository {
	return &fakeDiceRepository{
		game: gamedomain.LaunchableGame{
			CatalogItem: gamedomain.CatalogItem{
				AppCode:        "lalu",
				GameID:         "dice",
				PlatformCode:   "dice",
				ProviderGameID: "dice",
				GameName:       "Dice",
				Status:         gamedomain.StatusActive,
			},
			PlatformStatus: gamedomain.StatusActive,
		},
		config: dicedomain.Config{
			AppCode:          "lalu",
			GameID:           "dice",
			Status:           dicedomain.ConfigStatusActive,
			StakeOptions:     dicedomain.NormalizeStakeOptions(nil),
			FeeBPS:           int32(dicedomain.DefaultFeeBPS),
			PoolBPS:          int32(dicedomain.DefaultPoolBPS),
			MinPlayers:       dicedomain.DefaultMinPlayers,
			MaxPlayers:       dicedomain.DefaultMaxPlayers,
			RobotEnabled:     true,
			RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS,
		},
		stakePool:     selfgame.StakePool{AppCode: "lalu", GameID: "dice", StakeCoin: 100, Status: "active"},
		newUserPolicy: selfgame.DefaultNewUserPolicy("lalu", "dice", 1700000000000),
		riskResult:    selfgame.RiskResult{},
		orders:        map[string]gamedomain.GameOrder{},
	}
}

func configureFakeRock(repo *fakeDiceRepository) {
	repo.game.CatalogItem.GameID = "rock"
	repo.game.CatalogItem.ProviderGameID = "rock"
	repo.game.CatalogItem.GameName = "Rock Paper Scissors"
	repo.config.GameID = "rock"
	repo.stakePool.GameID = "rock"
	repo.newUserPolicy.GameID = "rock"
}

func (r *fakeDiceRepository) GetLaunchableGame(context.Context, string, string) (gamedomain.LaunchableGame, error) {
	return r.game, nil
}

func (r *fakeDiceRepository) CreateGameOrder(_ context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error) {
	if existing, ok := r.orders[order.OrderID]; ok {
		if existing.RequestHash != order.RequestHash {
			return gamedomain.GameOrder{}, true, xerr.New(xerr.IdempotencyConflict, "provider order payload conflict")
		}
		return existing, true, nil
	}
	r.orders[order.OrderID] = order
	return order, false, nil
}

func (r *fakeDiceRepository) MarkOrderSucceeded(_ context.Context, _ string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error {
	order := r.orders[orderID]
	order.Status = gamedomain.OrderStatusSucceeded
	order.WalletTransactionID = walletTransactionID
	order.WalletBalanceAfter = balanceAfter
	order.UpdatedAtMS = nowMs
	r.orders[orderID] = order
	if order.OpType == "debit" {
		r.levelDebitOrders++
	}
	return nil
}

func (r *fakeDiceRepository) MarkOrderFailed(_ context.Context, _ string, orderID string, status string, code string, message string, nowMs int64) error {
	order := r.orders[orderID]
	order.Status = status
	order.FailureCode = code
	order.FailureMessage = message
	order.UpdatedAtMS = nowMs
	r.orders[orderID] = order
	return nil
}

func (r *fakeDiceRepository) CreateDiceMatch(_ context.Context, match dicedomain.Match) (dicedomain.Match, error) {
	r.match = cloneDiceMatch(match)
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) JoinDiceMatch(_ context.Context, _ string, _ string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
	for _, participant := range r.match.Participants {
		if participant.UserID == userID {
			return cloneDiceMatch(r.match), nil
		}
	}
	seatNo := int32(len(r.match.Participants) + 1)
	r.match.Participants = append(r.match.Participants, dicedomain.Participant{
		AppCode:     r.match.AppCode,
		MatchID:     r.match.MatchID,
		UserID:      userID,
		SeatNo:      seatNo,
		Status:      dicedomain.ParticipantStatusJoined,
		StakeCoin:   r.match.StakeCoin,
		RPSGesture:  strings.TrimSpace(rpsGesture),
		JoinedAtMS:  nowMs,
		UpdatedAtMS: nowMs,
	})
	r.match.CurrentPlayers = int32(len(r.match.Participants))
	r.match.Status = dicedomain.MatchStatusJoining
	r.match.UpdatedAtMS = nowMs
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) GetDiceMatch(context.Context, string, string) (dicedomain.Match, error) {
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) ClaimDiceMatchForRoll(_ context.Context, _ string, _ string, userID int64, nowMs int64) (dicedomain.Match, error) {
	if !hasFakeDiceParticipant(r.match.Participants, userID) {
		return dicedomain.Match{}, xerr.New(xerr.PermissionDenied, "dice participant is required")
	}
	if len(r.match.Participants) < int(r.match.MinPlayers) {
		return dicedomain.Match{}, xerr.New(xerr.Conflict, "dice match does not have enough participants")
	}
	if r.match.Result == dicedomain.ParticipantResultDraw {
		r.match.Result = ""
		for index := range r.match.Participants {
			r.match.Participants[index].DicePoints = nil
			r.match.Participants[index].Result = ""
			r.match.Participants[index].PayoutCoin = 0
			r.match.Participants[index].UpdatedAtMS = nowMs
		}
	}
	r.match.Status = dicedomain.MatchStatusSettling
	r.match.UpdatedAtMS = nowMs
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) SaveDiceRolls(_ context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
	for _, source := range match.Participants {
		for index := range r.match.Participants {
			if r.match.Participants[index].UserID == source.UserID {
				r.match.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...)
				r.match.Participants[index].RPSGesture = source.RPSGesture
				r.match.Participants[index].Result = source.Result
				r.match.Participants[index].PayoutCoin = source.PayoutCoin
				r.match.Participants[index].UpdatedAtMS = nowMs
			}
		}
	}
	r.match.Status = dicedomain.MatchStatusPayoutApplying
	r.match.Result = match.Result
	r.match.UpdatedAtMS = nowMs
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) SaveDiceDrawForReroll(_ context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error) {
	for _, source := range match.Participants {
		for index := range r.match.Participants {
			if r.match.Participants[index].UserID == source.UserID {
				r.match.Participants[index].DicePoints = append([]int32(nil), source.DicePoints...)
				r.match.Participants[index].RPSGesture = source.RPSGesture
				r.match.Participants[index].Result = dicedomain.ParticipantResultDraw
				r.match.Participants[index].PayoutCoin = 0
				r.match.Participants[index].UpdatedAtMS = nowMs
			}
		}
	}
	r.match.Status = dicedomain.MatchStatusReady
	r.match.Result = dicedomain.ParticipantResultDraw
	r.match.RoundNo++
	r.match.UpdatedAtMS = nowMs
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) MarkDiceParticipantDebitSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
	return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
		participant.Status = dicedomain.ParticipantStatusDebitSucceeded
		participant.DebitOrderID = orderID
		participant.BalanceAfter = balanceAfter
		participant.UpdatedAtMS = nowMs
	})
}

func (r *fakeDiceRepository) MarkDiceParticipantDebitFailed(_ context.Context, _ string, _ string, userID int64, orderID string, nowMs int64) error {
	return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
		participant.Status = dicedomain.ParticipantStatusDebitFailed
		participant.DebitOrderID = orderID
		participant.UpdatedAtMS = nowMs
	})
}

func (r *fakeDiceRepository) MarkDiceParticipantPayoutSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
	return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
		participant.Status = dicedomain.ParticipantStatusPayoutSucceeded
		participant.PayoutOrderID = orderID
		participant.BalanceAfter = balanceAfter
		participant.UpdatedAtMS = nowMs
	})
}

func (r *fakeDiceRepository) MarkDiceParticipantRefundSucceeded(_ context.Context, _ string, _ string, userID int64, orderID string, balanceAfter int64, nowMs int64) error {
	return r.updateParticipant(userID, func(participant *dicedomain.Participant) {
		participant.Status = dicedomain.ParticipantStatusRefundSucceeded
		participant.RefundOrderID = orderID
		participant.BalanceAfter = balanceAfter
		participant.UpdatedAtMS = nowMs
	})
}

func (r *fakeDiceRepository) MarkDiceMatchSettled(_ context.Context, _ string, _ string, result string, nowMs int64) error {
	r.match.Status = dicedomain.MatchStatusSettled
	r.match.Result = result
	r.match.UpdatedAtMS = nowMs
	r.match.SettledAtMS = nowMs
	for index := range r.match.Participants {
		r.match.Participants[index].Status = dicedomain.ParticipantStatusSettled
		r.match.Participants[index].UpdatedAtMS = nowMs
	}
	return nil
}

func (r *fakeDiceRepository) MarkDiceMatchFailed(_ context.Context, _ string, _ string, result string, nowMs int64) error {
	r.match.Status = dicedomain.MatchStatusFailed
	r.match.Result = result
	r.match.UpdatedAtMS = nowMs
	return nil
}

func (r *fakeDiceRepository) GetDiceConfig(context.Context, string, string) (dicedomain.Config, error) {
	return r.config, nil
}

func (r *fakeDiceRepository) ListSelfGameConfigs(context.Context, string) ([]dicedomain.Config, error) {
	return []dicedomain.Config{r.config}, nil
}

func (r *fakeDiceRepository) UpsertDiceConfig(_ context.Context, config dicedomain.Config, _ int64) (dicedomain.Config, error) {
	r.config = config
	return config, nil
}

func (r *fakeDiceRepository) FindAndJoinWaitingDiceMatch(context.Context, string, string, int64, int64, string, int64) (dicedomain.Match, error) {
	return dicedomain.Match{}, xerr.New(xerr.NotFound, "waiting dice match not found")
}

func (r *fakeDiceRepository) JoinDiceRobot(_ context.Context, _ string, _ string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error) {
	if robotUserID <= 0 {
		return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice robot not found")
	}
	for _, participant := range r.match.Participants {
		if participant.UserID == robotUserID {
			return cloneDiceMatch(r.match), nil
		}
	}
	seatNo := int32(len(r.match.Participants) + 1)
	r.match.Participants = append(r.match.Participants, dicedomain.Participant{
		AppCode:         r.match.AppCode,
		MatchID:         r.match.MatchID,
		UserID:          robotUserID,
		ParticipantType: dicedomain.ParticipantTypeRobot,
		SeatNo:          seatNo,
		Status:          dicedomain.ParticipantStatusJoined,
		StakeCoin:       r.match.StakeCoin,
		RPSGesture:      strings.TrimSpace(rpsGesture),
		JoinedAtMS:      nowMs,
		UpdatedAtMS:     nowMs,
	})
	r.match.CurrentPlayers = int32(len(r.match.Participants))
	r.match.MatchMode = dicedomain.MatchModeRobot
	r.match.ForcedResult = strings.TrimSpace(forcedResult)
	r.match.Status = dicedomain.MatchStatusReady
	r.match.ReadyAtMS = nowMs
	r.match.UpdatedAtMS = nowMs
	r.joinRobotRPSGesture = strings.TrimSpace(rpsGesture)
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) CancelDiceMatch(context.Context, string, string, int64, int64) (dicedomain.Match, error) {
	r.match.Status = dicedomain.MatchStatusCanceled
	return cloneDiceMatch(r.match), nil
}

func (r *fakeDiceRepository) PickActiveDiceRobot(context.Context, string, string, string, int64) (dicedomain.Robot, error) {
	if r.robot.UserID > 0 {
		return r.robot, nil
	}
	return dicedomain.Robot{}, xerr.New(xerr.NotFound, "dice robot not found")
}

func (r *fakeDiceRepository) AdjustSelfGameStakePool(_ context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error) {
	if adjustment.StakeCoin <= 0 {
		return dicedomain.PoolAdjustment{}, xerr.New(xerr.InvalidArgument, "self game stake coin is required")
	}
	if adjustment.Direction == dicedomain.PoolDirectionOut {
		r.poolBalance -= adjustment.AmountCoin
		r.stakePool.BalanceCoin -= adjustment.AmountCoin
	} else {
		r.poolBalance += adjustment.AmountCoin
		r.stakePool.BalanceCoin += adjustment.AmountCoin
	}
	adjustment.BalanceAfter = r.poolBalance
	return adjustment, nil
}

func (r *fakeDiceRepository) GetSelfGameStakePool(context.Context, string, string, int64, int64) (selfgame.StakePool, error) {
	pool := r.stakePool
	if pool.Status == "" {
		pool.Status = "active"
	}
	if pool.BalanceCoin == 0 && r.poolBalance > 0 {
		pool.BalanceCoin = r.poolBalance
	}
	if pool.StakeCoin == 0 {
		pool.StakeCoin = 100
	}
	return selfgame.NormalizeStakePool(pool), nil
}

func (r *fakeDiceRepository) ListSelfGameStakePools(ctx context.Context, appCode string, gameID string, nowMs int64) ([]selfgame.StakePool, error) {
	pool, err := r.GetSelfGameStakePool(ctx, appCode, gameID, r.stakePool.StakeCoin, nowMs)
	if err != nil {
		return nil, err
	}
	return []selfgame.StakePool{pool}, nil
}

func (r *fakeDiceRepository) UpsertSelfGameStakePoolConfig(_ context.Context, pool selfgame.StakePool, nowMs int64) (selfgame.StakePool, error) {
	current := r.stakePool
	current.AppCode = pool.AppCode
	current.GameID = pool.GameID
	current.StakeCoin = pool.StakeCoin
	current.TargetBalanceCoin = pool.TargetBalanceCoin
	current.SafeBalanceCoin = pool.SafeBalanceCoin
	current.WarningBalanceCoin = pool.WarningBalanceCoin
	current.DangerBalanceCoin = pool.DangerBalanceCoin
	current.Status = pool.Status
	current.UpdatedAtMS = nowMs
	if current.CreatedAtMS == 0 {
		current.CreatedAtMS = nowMs
	}
	r.stakePool = selfgame.NormalizeStakePool(current)
	return r.stakePool, nil
}

func (r *fakeDiceRepository) GetSelfGameNewUserPolicy(context.Context, string, string, int64) (selfgame.NewUserPolicy, error) {
	if strings.TrimSpace(r.newUserPolicy.GameID) == "" {
		return selfgame.DefaultNewUserPolicy("lalu", "dice", 1700000000000), nil
	}
	return selfgame.NormalizeNewUserPolicy(r.newUserPolicy), nil
}

func (r *fakeDiceRepository) UpsertSelfGameNewUserPolicy(_ context.Context, policy selfgame.NewUserPolicy, nowMs int64) (selfgame.NewUserPolicy, error) {
	policy.UpdatedAtMS = nowMs
	if policy.CreatedAtMS == 0 {
		policy.CreatedAtMS = nowMs
	}
	r.newUserPolicy = selfgame.NormalizeNewUserPolicy(policy)
	return r.newUserPolicy, nil
}

func (r *fakeDiceRepository) GetSelfGameProtectionState(context.Context, string, string, int64, int64) (selfgame.ProtectionState, error) {
	if strings.TrimSpace(r.protectionState.Status) == "" {
		r.protectionState.Status = "active"
	}
	return selfgame.NormalizeProtectionState(r.protectionState), nil
}

func (r *fakeDiceRepository) GetSelfGameRiskResult(context.Context, string, string, int64, int64) (selfgame.RiskResult, error) {
	return selfgame.NormalizeRiskResult(r.riskResult), nil
}

func (r *fakeDiceRepository) GetSelfGameUserStats(context.Context, string, string, int64, int64) (selfgame.UserStats, error) {
	return r.userStats, nil
}

func (r *fakeDiceRepository) RecordSelfGameStrategyLog(_ context.Context, log selfgame.StrategyLog) error {
	r.strategyLogs = append(r.strategyLogs, log)
	return nil
}

func (r *fakeDiceRepository) ConsumeSelfGameProtection(_ context.Context, event selfgame.ProtectionEvent) (selfgame.ProtectionState, bool, error) {
	r.protectionEvents = append(r.protectionEvents, event)
	r.protectionState.UsedRounds += event.UsedRoundDelta
	r.protectionState.LifetimeSubsidyCoin += event.LifetimeSubsidyCoinDelta
	r.protectionState.Day1SubsidyCoin += event.Day1SubsidyCoinDelta
	return selfgame.NormalizeProtectionState(r.protectionState), true, nil
}

func (r *fakeDiceRepository) ListDiceRobots(context.Context, string, string, string, int32, string) ([]dicedomain.Robot, string, error) {
	return nil, "", nil
}

func (r *fakeDiceRepository) RegisterDiceRobots(context.Context, string, string, []int64, int64, int64) ([]dicedomain.Robot, error) {
	return nil, nil
}

func (r *fakeDiceRepository) SetDiceRobotStatus(context.Context, string, string, int64, string, int64) (dicedomain.Robot, error) {
	return dicedomain.Robot{}, nil
}

func (r *fakeDiceRepository) DeleteDiceRobot(context.Context, string, string, int64) error {
	return nil
}

func (r *fakeDiceRepository) RecordExploreWinner(_ context.Context, winner dicedomain.ExploreWinner) error {
	r.exploreWinners = append(r.exploreWinners, winner)
	return nil
}

func (r *fakeDiceRepository) ListExploreWinners(context.Context, string, int32) ([]dicedomain.ExploreWinner, error) {
	return nil, nil
}

func (r *fakeDiceRepository) updateParticipant(userID int64, apply func(*dicedomain.Participant)) error {
	for index := range r.match.Participants {
		if r.match.Participants[index].UserID == userID {
			apply(&r.match.Participants[index])
			return nil
		}
	}
	return xerr.New(xerr.NotFound, "dice participant not found")
}

type fakeDiceWallet struct {
	balances map[int64]int64
	calls    int
}

func (w *fakeDiceWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) {
	w.calls++
	if req.GetCoinAmount() <= 0 {
		return nil, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
	}
	switch req.GetOpType() {
	case "debit":
		if w.balances[req.GetUserId()] < req.GetCoinAmount() {
			return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance")
		}
		w.balances[req.GetUserId()] -= req.GetCoinAmount()
	case "credit", "refund":
		w.balances[req.GetUserId()] += req.GetCoinAmount()
	default:
		return nil, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
	}
	return &walletv1.ApplyGameCoinChangeResponse{
		WalletTransactionId: "wtx_" + strings.ReplaceAll(req.GetProviderOrderId(), ":", "_"),
		BalanceAfter:        w.balances[req.GetUserId()],
	}, nil
}

func (w *fakeDiceWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) {
	return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{
		AssetType:       "COIN",
		AvailableAmount: w.balances[req.GetUserId()],
	}}}, nil
}

func participantByUser(participants []dicedomain.Participant, userID int64) dicedomain.Participant {
	for _, participant := range participants {
		if participant.UserID == userID {
			return participant
		}
	}
	return dicedomain.Participant{}
}

func hasFakeDiceParticipant(participants []dicedomain.Participant, userID int64) bool {
	return participantByUser(participants, userID).UserID == userID
}

func cloneDiceMatch(match dicedomain.Match) dicedomain.Match {
	cloned := match
	cloned.Participants = append([]dicedomain.Participant(nil), match.Participants...)
	for index := range cloned.Participants {
		cloned.Participants[index].DicePoints = append([]int32(nil), match.Participants[index].DicePoints...)
	}
	return cloned
}
