# vim: set ft=python ts=4 sw=4 expandtab:
# ruff: noqa: T201

import random
from unittest.mock import MagicMock, patch

import pytest
from apologies.game import GameMode, PlayerColor
from apologies.rules import Rules
from apologies.source import NoOpInputSource, RewardV1InputSource
from arrow import utcnow as arrow_utcnow
from ordered_set import OrderedSet

from apologiesserver.interface import (
    ActivityState,
    AdvertiseGameContext,
    CancelledReason,
    ConnectionState,
    GamePlayer,
    GamePlayerTurnContext,
    GameState,
    PlayerState,
    PlayerType,
    ProcessingError,
    Visibility,
)
from apologiesserver.manager import (
    _MANAGER,
    _NAMES,
    CurrentTurn,
    StateManager,
    TrackedEngine,
    TrackedGame,
    TrackedPlayer,
    TrackedWebsocket,
    manager,
)
from tests.conftest import random_string, to_date


def create_test_player(player_id="id", handle="handle"):
    return TrackedPlayer(player_id=player_id, websocket=MagicMock(), handle=handle)


def create_test_game():
    engine = MagicMock()
    return TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PRIVATE, [], engine=engine)


def check_fully_joined(players: int, game_players: int) -> bool:
    game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PRIVATE, [])
    game.players = players
    game.game_players = {random_string(): MagicMock() for _ in range(game_players)}
    return game.is_fully_joined()


def check_is_in_progress(advertised: bool, playing: bool) -> bool:
    game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PRIVATE, ["bender", "fry"])
    game.is_advertised = MagicMock()  # type: ignore
    game.is_playing = MagicMock()  # type: ignore
    game.is_advertised.return_value = advertised
    game.is_playing.return_value = playing
    return game.is_in_progress()


def check_is_viable(advertised: bool, available: int) -> bool:
    game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PRIVATE, [])
    game.is_advertised = MagicMock()  # type: ignore
    game.get_available_player_count = MagicMock()  # type: ignore
    game.is_advertised.return_value = advertised
    game.get_available_player_count.return_value = available
    return game.is_viable()


class TestFunctions:
    """
    Test Python functions.
    """

    def test_manager(self):
        assert _MANAGER is not None
        assert manager() is _MANAGER


class TestTrackedWebsocket:
    """
    Test the TrackedWebsocket class.
    """

    # noinspection PyTypeChecker
    def test_mark_active(self):
        now = arrow_utcnow()
        tracked = TrackedWebsocket(MagicMock())
        tracked.last_active_date = None
        tracked.activity_state = None
        tracked.mark_active()
        assert tracked.last_active_date >= now
        assert tracked.activity_state == ActivityState.ACTIVE

    def test_mark_idle(self):
        tracked = TrackedWebsocket(MagicMock())
        tracked.activity_state = None
        tracked.mark_idle()
        assert tracked.activity_state == ActivityState.IDLE

    def test_mark_inactive(self):
        tracked = TrackedWebsocket(MagicMock())
        tracked.activity_state = None
        tracked.mark_inactive()
        assert tracked.activity_state == ActivityState.INACTIVE


class TestTrackedPlayer:
    """
    Test the TrackedPlayer class.
    """

    def test_for_context(self):
        now = arrow_utcnow()
        websocket = MagicMock()
        player = TrackedPlayer.for_context("id", websocket, "handle")
        assert player.player_id == "id"
        assert player.handle == "handle"
        assert player.websocket is websocket
        assert player.registration_date >= now
        assert player.last_active_date >= now
        assert player.activity_state == ActivityState.ACTIVE
        assert player.connection_state == ConnectionState.CONNECTED
        assert player.player_state == PlayerState.WAITING
        assert player.game_id is None

    def test_to_registered_player(self):
        websocket = MagicMock()
        player = TrackedPlayer(player_id="id", websocket=websocket, handle="handle", game_id="game_id")
        registered = player.to_registered_player()
        assert registered.handle == "handle"
        assert registered.registration_date == player.registration_date
        assert registered.last_active_date == player.last_active_date
        assert registered.connection_state == player.connection_state
        assert registered.activity_state == player.activity_state
        assert registered.player_state == player.player_state
        assert registered.game_id == player.game_id

    # noinspection PyTypeChecker
    def test_mark_active(self):
        now = arrow_utcnow()
        player = create_test_player()
        player.last_active_date = None
        player.activity_state = None
        player.connection_state = None
        player.mark_active()
        assert player.last_active_date >= now
        assert player.activity_state == ActivityState.ACTIVE
        assert player.connection_state == ConnectionState.CONNECTED

    def test_mark_idle(self):
        player = create_test_player()
        player.activity_state = None
        player.mark_idle()
        assert player.activity_state == ActivityState.IDLE

    def test_mark_inactive(self):
        player = create_test_player()
        player.activity_state = None
        player.mark_inactive()
        assert player.activity_state == ActivityState.INACTIVE

    def test_mark_joined(self):
        game = MagicMock(game_id="game_id")
        player = create_test_player()
        player.game_id = None
        player.player_state = None
        player.mark_joined(game)
        assert player.game_id == "game_id"
        assert player.player_state == PlayerState.JOINED

    def test_mark_playing(self):
        player = create_test_player()
        player.player_state = None
        player.mark_playing()
        assert player.player_state == PlayerState.PLAYING

    def test_mark_quit(self):
        player = create_test_player()
        player.game_id = "game_id"
        player.player_state = None
        player.mark_quit()
        assert player.game_id is None
        assert player.player_state == PlayerState.WAITING  # they move through QUIT and back to WAITING

    def test_mark_disconnected(self):
        player = create_test_player()
        player.game_id = "game_id"
        player.activity_state = None
        player.connection_state = None
        player.player_state = None
        player.mark_disconnected()
        assert player.game_id is None
        assert player.websocket is None
        assert player.activity_state == ActivityState.IDLE
        assert player.connection_state == ConnectionState.DISCONNECTED
        assert player.player_state == PlayerState.WAITING  # they move through QUIT and back to WAITING


class TestCurrentTurn:
    """
    Test the CurrentTurn class.
    """

    def test_draw_again(self):
        current = CurrentTurn(handle="handle", color=PlayerColor.RED, view=None, movelist=None, movedict=None)
        move = MagicMock(id="move_id")
        view = MagicMock()
        engine = MagicMock()
        engine.game.create_player_view.return_value = view
        engine.construct_legal_moves.return_value = (None, [move])
        turn = current.draw_again(engine)
        engine.game.create_player_view.assert_called_once_with(PlayerColor.RED)
        engine.construct_legal_moves.assert_called_once_with(view)
        assert turn.handle == "handle"
        assert turn.color == PlayerColor.RED
        assert turn.view is view
        assert turn.movelist == [move]
        assert turn.movedict == {"move_id": move}

    def test_next_player(self):
        move = MagicMock(id="move_id")
        character = MagicMock()
        character.configure_mock(name="handle")  # the "name" attribute means something special in the constructor
        view = MagicMock()
        engine = MagicMock()
        engine.next_turn.return_value = (PlayerColor.RED, character)
        engine.game.create_player_view.return_value = view
        engine.construct_legal_moves.return_value = (None, [move])
        turn = CurrentTurn.next_player(engine)
        engine.next_turn.assert_called_once()
        engine.game.create_player_view.assert_called_once_with(PlayerColor.RED)
        engine.construct_legal_moves.assert_called_once_with(view)
        assert turn.handle == "handle"
        assert turn.color == PlayerColor.RED
        assert turn.view is view
        assert turn.movelist == [move]
        assert turn.movedict == {"move_id": move}


class TestTrackedEngine:
    """
    Test the TrackedEngine class.
    """

    # noinspection PyTypeChecker
    # noinspection PyUnresolvedReferences
    @patch("apologiesserver.manager.CurrentTurn")
    def test_start_game(self, current_turn):
        replacement = MagicMock()
        current_turn.next_player.return_value = replacement
        mode = GameMode.STANDARD
        handles = ["leela", "bender"]
        engine = TrackedEngine()
        engine._engine = MagicMock()
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.start_game(mode, handles=handles)  # there's already an engine
        engine._engine = None
        engine._colors = {}
        engine._current = None
        colors = engine.start_game(mode, handles)
        assert colors == engine._colors and colors is not engine._colors  # it's a copy
        assert engine._engine.mode == mode
        assert len(engine._engine.characters) == 2
        assert engine._engine.characters[0].name == "leela"
        assert isinstance(engine._engine.characters[0].source, NoOpInputSource)
        assert engine._engine.characters[1].name == "bender"
        assert isinstance(engine._engine.characters[1].source, NoOpInputSource)
        assert engine._engine.started is True
        assert "leela" in engine._colors and "bender" in engine._colors and engine._colors["leela"] != engine._colors["bender"]
        assert engine._current is replacement
        current_turn.next_player.assert_called_with(engine._engine)

    def test_stop_game(self):
        engine = TrackedEngine()
        engine._engine = MagicMock()
        engine._colors = {"a": "b"}
        engine._current = MagicMock()
        engine.stop_game()
        assert engine._engine is None
        assert engine._colors == {}
        assert engine._current is None

    def test_get_next_turn(self):
        engine = TrackedEngine()
        engine._current = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_next_turn()
        engine._current = MagicMock(handle="handle")
        assert engine.get_next_turn() == "handle"

    def test_get_legal_moves(self):
        move = MagicMock()
        engine = TrackedEngine()
        engine._current = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_legal_moves("handle")  # no current turn
        engine._current = MagicMock(handle="handle")
        engine._current.movelist = [move]
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_legal_moves("bogus")  # unknown handle
        assert engine.get_legal_moves("handle") == [move]

    def test_get_player_view(self):
        view = MagicMock()
        engine = TrackedEngine()
        engine._engine = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_player_view("handle")  # no engine
        engine._engine = MagicMock()
        engine._engine.game.create_player_view.return_value = view
        engine._colors = {"handle": PlayerColor.RED}
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_player_view("bogus")  # unknown handle
        assert engine.get_player_view("handle") is view
        engine._engine.game.create_player_view.assert_called_once_with(PlayerColor.RED)

    def test_get_recent_history(self):
        engine = TrackedEngine()
        engine._engine = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_recent_history(10)  # no engine
        engine._engine = MagicMock()
        engine._engine.game = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.get_recent_history(10)  # no game
        engine._engine.game = MagicMock(history=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
        assert engine.get_recent_history(10) == [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]

    def test_is_move_pending(self):
        engine = TrackedEngine()
        engine._current = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.is_move_pending("handle")  # no current turn
        engine._current = MagicMock(handle="handle")
        assert engine.is_move_pending("bogus") is False
        assert engine.is_move_pending("handle") is True

    def test_is_legal_move(self):
        move = MagicMock()
        engine = TrackedEngine()
        engine._current = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.is_legal_move("handle", "move_id")  # no current turn
        engine._current = MagicMock(handle="handle")
        engine._current.movedict = {"move_id": move}
        assert engine.is_legal_move("bogus", "move_id") is False  # handle doesn't match current turn
        assert engine.is_legal_move("handle", "bogus") is False  # move isn't in the current turn's list
        assert engine.is_legal_move("handle", "move_id") is True

    def test_execute_move_validations(self):
        move = MagicMock()
        engine = TrackedEngine()
        engine._engine = None
        engine._current = None
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.execute_move("handle", "move_id")  # no engine
        engine._engine = MagicMock()
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.execute_move("handle", "move_id")  # no current turn
        engine._current = MagicMock(handle="handle")
        engine._current.movedict = {"move_id": move}
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.execute_move("bogus", "move_id")  # handle doesn't match current turn
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.execute_move("handle", "move_id")  # can't find color mapping for handle
        engine._colors = {"handle": PlayerColor.RED}
        with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
            engine.execute_move("handle", "bogus")  # unknown move

    def test_execute_move_completed(self):
        character = MagicMock()
        character.configure_mock(name="handle")  # the "name" attribute means something special in the constructor
        player = MagicMock(color=PlayerColor.YELLOW, turns=42)
        move = MagicMock()
        current = MagicMock(handle="handle")
        engine = TrackedEngine()
        engine._engine = MagicMock(completed=True)
        engine._current = current
        engine._current.movedict = {"move_id": move}
        engine._colors = {"handle": PlayerColor.RED}
        engine._engine.winner.return_value = (character, player)  # only set if the game is completed
        engine._engine.execute_move.return_value = True  # turn is always true if the game is completed
        assert engine.execute_move("handle", "move_id") == (True, "handle", "Player handle won after 42 turns")
        assert engine._current is None
        engine._engine.execute_move.assert_called_once_with(PlayerColor.RED, move)

    def test_execute_move_not_done(self):
        character = MagicMock()
        character.configure_mock(name="handle")  # the "name" attribute means something special in the constructor
        move = MagicMock()
        current = MagicMock(handle="handle")
        again = MagicMock()
        engine = TrackedEngine()
        engine._engine = MagicMock(completed=False)
        engine._current = current
        engine._current.draw_again.return_value = again
        engine._current.movedict = {"move_id": move}
        engine._colors = {"handle": PlayerColor.RED}
        engine._engine.winner.return_value = None  # only set if the game is completed
        engine._engine.execute_move.return_value = False  # player's turn is not done yet, so current does not change
        assert engine.execute_move("handle", "move_id") == (False, None, None)
        assert engine._current is again
        current.draw_again.assert_called_once_with(engine._engine)
        engine._engine.execute_move.assert_called_once_with(PlayerColor.RED, move)

    @patch("apologiesserver.manager.CurrentTurn")
    def test_execute_move_done(self, current_turn):
        character = MagicMock()
        character.configure_mock(name="handle")  # the "name" attribute means something special in the constructor
        move = MagicMock()
        current = MagicMock(handle="handle")
        replacement = MagicMock()
        current_turn.next_player.return_value = replacement
        engine = TrackedEngine()
        engine._engine = MagicMock(completed=False)
        engine._current = current
        engine._current.movedict = {"move_id": move}
        engine._colors = {"handle": PlayerColor.RED}
        engine._engine.winner.return_value = None  # only set if the game is completed
        engine._engine.execute_move.return_value = True  # player's turn is now done, so current will change
        assert engine.execute_move("handle", "move_id") == (False, None, None)
        assert engine._current is replacement
        engine._engine.execute_move.assert_called_once_with(PlayerColor.RED, move)


class TestTrackedGame:
    """
    Test the TrackedGame class.
    """

    def test_for_context(self):
        now = arrow_utcnow()
        context = AdvertiseGameContext("name", GameMode.STANDARD, 3, Visibility.PUBLIC, ["a", "b"])
        game = TrackedGame.for_context("handle", "game_id", context)
        assert game.game_id == "game_id"
        assert game.advertiser_handle == "handle"
        assert game.name == "name"
        assert game.mode == GameMode.STANDARD
        assert game.players == 3
        assert game.visibility == Visibility.PUBLIC
        assert game.invited_handles == ["a", "b"]
        assert game.advertised_date >= now
        assert game.last_active_date >= now
        assert game.started_date is None
        assert game.completed_date is None
        assert game.game_state == GameState.ADVERTISED
        assert game.activity_state == ActivityState.ACTIVE
        assert game.cancelled_reason is None
        assert game.completed_comment is None
        assert game.game_players == {}

    def test_to_advertised_game(self):
        game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PUBLIC, ["a", "b"])
        advertised = game.to_advertised_game()
        assert advertised.game_id == "game_id"
        assert advertised.name == "name"
        assert advertised.mode == GameMode.STANDARD
        assert advertised.advertiser_handle == "handle"
        assert advertised.players == 3
        assert advertised.available == 3
        assert advertised.visibility == Visibility.PUBLIC
        assert advertised.invited_handles == ["a", "b"]

    def test_get_game_players(self):
        game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PUBLIC, ["a", "b"])
        gp1 = MagicMock()
        gp2 = MagicMock()
        game.game_players = {"gp1": gp1, "gp2": gp2}
        assert game.get_game_players() == [gp1, gp2]

    def test_get_available_players(self):
        game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PUBLIC, ["a", "b"])
        gp1 = MagicMock(player_state=PlayerState.WAITING)
        gp2 = MagicMock(player_state=PlayerState.JOINED)
        gp3 = MagicMock(player_state=PlayerState.PLAYING)
        gp4 = MagicMock(player_state=PlayerState.FINISHED)
        gp5 = MagicMock(player_state=PlayerState.QUIT)
        gp6 = MagicMock(player_state=PlayerState.DISCONNECTED)
        game.game_players = {
            "gp1": gp1,
            "gp2": gp2,
            "gp3": gp3,
            "gp4": gp4,
            "gp5": gp5,
            "gp6": gp6,
        }  # obviously not realistic
        assert game.get_available_players() == [
            gp1,
            gp2,
            gp3,
            gp4,
        ]
        assert game.get_available_player_count() == 4

    def test_get_next_turn(self):
        player = MagicMock(player_type=PlayerType.PROGRAMMATIC)
        game = create_test_game()
        game.game_players["handle"] = player
        game._engine.get_next_turn.return_value = "handle"
        assert game.get_next_turn() == ("handle", PlayerType.PROGRAMMATIC)

    def test_get_legal_moves(self):
        move = MagicMock()
        game = create_test_game()
        game._engine.get_legal_moves.return_value = [move]
        assert game.get_legal_moves("handle") == [move]
        # noinspection PyUnresolvedReferences
        game._engine.get_legal_moves.assert_called_once_with("handle")

    def test_get_player_view(self):
        view = MagicMock()
        game = create_test_game()
        game._engine.get_player_view.return_value = view
        assert game.get_player_view("handle") == view
        # noinspection PyUnresolvedReferences
        game._engine.get_player_view.assert_called_once_with("handle")

    def test_get_recent_history(self):
        history = MagicMock()
        game = create_test_game()
        game._engine.get_recent_history.return_value = [history]
        assert game.get_recent_history(10) == [history]
        # noinspection PyUnresolvedReferences
        game._engine.get_recent_history.assert_called_once_with(10)

    def test_is_available_public(self):
        game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PUBLIC, ["bender", "fry"])

        # If a public game is advertised, then it is available anyone (regardless of whether they are invited)
        game.game_state = GameState.ADVERTISED
        assert game.is_available("leela") is True
        assert game.is_available("bender") is True
        assert game.is_available("fry") is True

        # If any game is not advertised, then it is not available to anyone
        for state in [state for state in GameState if state != GameState.ADVERTISED]:
            game.game_state = state
            assert game.is_available("leela") is False
            assert game.is_available("bender") is False
            assert game.is_available("fry") is False

    def test_is_available_private(self):
        game = TrackedGame("game_id", "handle", "name", GameMode.STANDARD, 3, Visibility.PRIVATE, ["bender", "fry"])

        # If a private game is advertised, then it is available only for an invited handle
        game.game_state = GameState.ADVERTISED
        assert game.is_available("leela") is False
        assert game.is_available("bender") is True
        assert game.is_available("fry") is True

        # If any game is not advertised, then it is not available to anyone
        for state in [state for state in GameState if state != GameState.ADVERTISED]:
            game.game_state = state
            assert game.is_available("leela") is False
            assert game.is_available("bender") is False
            assert game.is_available("fry") is False

    def test_is_advertised(self):
        game = create_test_game()

        game.game_state = GameState.ADVERTISED
        assert game.is_advertised() is True

        for state in [state for state in GameState if state != GameState.ADVERTISED]:
            game.game_state = state
            assert game.is_advertised() is False

    def test_is_playing(self):
        game = create_test_game()

        game.game_state = GameState.PLAYING
        assert game.is_playing() is True

        for state in [state for state in GameState if state != GameState.PLAYING]:
            game.game_state = state
            assert game.is_playing() is False

    def test_is_in_progress(self):
        assert check_is_in_progress(True, True) is True
        assert check_is_in_progress(True, False) is True
        assert check_is_in_progress(False, True) is True
        assert check_is_in_progress(False, False) is False

    def test_is_fully_joined(self):
        assert check_fully_joined(2, 0) is False
        assert check_fully_joined(2, 1) is False
        assert check_fully_joined(2, 2) is True
        assert check_fully_joined(3, 0) is False
        assert check_fully_joined(3, 1) is False
        assert check_fully_joined(3, 2) is False
        assert check_fully_joined(3, 3) is True
        assert check_fully_joined(3, 0) is False
        assert check_fully_joined(4, 1) is False
        assert check_fully_joined(4, 2) is False
        assert check_fully_joined(4, 3) is False
        assert check_fully_joined(4, 4) is True

    def test_is_viable(self):
        for available in [0, 1, 2, 3, 4]:
            assert check_is_viable(True, available) is True
        for available in [0, 1]:
            assert check_is_viable(False, available) is False
        for available in [2, 3, 4]:
            assert check_is_viable(False, available) is True

    def test_is_move_pending(self):
        game = create_test_game()
        game._engine.is_move_pending.return_value = True
        assert game.is_move_pending("handle") is True
        # noinspection PyUnresolvedReferences
        game._engine.is_move_pending.assert_called_once_with("handle")

    def test_is_legal_move(self):
        game = create_test_game()
        game._engine.is_legal_move.return_value = True
        assert game.is_legal_move("handle", "move_id") is True
        # noinspection PyUnresolvedReferences
        game._engine.is_legal_move.assert_called_once_with("handle", "move_id")

    # noinspection PyTypeChecker
    def test_mark_active(self):
        now = arrow_utcnow()
        game = create_test_game()
        game.last_active_date = None
        game.activity_state = None
        game.mark_active()
        assert game.last_active_date >= now
        assert game.activity_state == ActivityState.ACTIVE

    def test_mark_idle(self):
        game = create_test_game()
        game.activity_state = None
        game.mark_idle()
        assert game.activity_state == ActivityState.IDLE

    def test_mark_inactive(self):
        game = create_test_game()
        game.activity_state = None
        game.mark_inactive()
        assert game.activity_state == ActivityState.INACTIVE

    def test_mark_joined_illegal_state(self):
        game = create_test_game()
        for game_state in [game_state for game_state in GameState if game_state != GameState.ADVERTISED]:
            game.game_state = game_state
            with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
                game.mark_joined("handle")
            assert "leela" not in game.game_players

    def test_mark_joined(self):
        game = create_test_game()
        game.game_state = GameState.ADVERTISED  # otherwise it's an illegal state
        game.mark_joined("leela")
        assert len(game.game_players) == 1
        assert game.game_players["leela"].handle == "leela"
        assert game.game_players["leela"].player_color is None
        assert game.game_players["leela"].player_type == PlayerType.HUMAN
        assert game.game_players["leela"].player_state == PlayerState.JOINED

    def test_mark_started_illegal_state(self):
        game = create_test_game()
        game.players = 4
        game.mark_joined("leela")
        game.mark_joined("bender")
        for game_state in [game_state for game_state in GameState if game_state != GameState.ADVERTISED]:
            game.game_state = game_state
            with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
                game.mark_started()
            assert game.game_state == game_state  # should not change

    # noinspection PyTypeChecker
    def test_mark_started(self):
        now = arrow_utcnow()
        game = create_test_game()
        game.players = 4
        game.mark_joined("leela")
        game.mark_joined("bender")

        # this stubs out the start_game() method and gives us access to what it returns, for later validation
        # it's a little funky, but works pretty well
        colors = {}

        def stubbed_start_game(mode, handles):
            nonlocal colors
            assert mode == game.mode
            colors = {
                handles[0]: PlayerColor.YELLOW,
                handles[1]: PlayerColor.RED,
                handles[2]: PlayerColor.GREEN,
                handles[3]: PlayerColor.BLUE,
            }
            return colors

        game._engine.start_game = stubbed_start_game

        game.game_state = GameState.ADVERTISED  # otherwise it's an illegal state
        game.last_active_date = None
        game.started_date = None
        game.mark_started()
        assert game.game_state == GameState.PLAYING
        assert game.last_active_date >= now
        assert game.started_date >= now
        assert len(game.game_players) == 4  # the two we added and two programmatic ones

        programmatic = [handle for handle in game.game_players if handle not in ("leela", "bender")]
        assert len(programmatic) == 2
        for handle in programmatic:
            assert handle in _NAMES  # the name gets randomly assigned
            assert game.game_players[handle].handle == handle
            assert game.game_players[handle].player_color == colors[handle]  # check that it gets what was returned by start_game()
            assert game.game_players[handle].player_type == PlayerType.PROGRAMMATIC
            assert game.game_players[handle].player_state == PlayerState.PLAYING

        human = [handle for handle in game.game_players if handle in ("leela", "bender")]
        assert len(human) == 2
        for handle in ["leela", "bender"]:
            assert game.game_players[handle].handle == handle
            assert game.game_players[handle].player_color == colors[handle]  # check that it gets what was returned by start_game()
            assert game.game_players[handle].player_type == PlayerType.HUMAN
            assert game.game_players[handle].player_state == PlayerState.PLAYING

    def test_mark_completed_illegal_state(self):
        game = create_test_game()
        for game_state in [game_state for game_state in GameState if game_state != GameState.PLAYING]:
            game.game_state = game_state
            with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
                game.mark_completed("comment")
            assert game.game_state == game_state  # should not change
            # noinspection PyUnresolvedReferences
            game._engine.stop_game.assert_not_called()

    # noinspection PyTypeChecker
    def test_mark_completed(self):
        gp1 = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.PLAYING)
        gp2 = GamePlayer("gp2", PlayerColor.RED, PlayerType.HUMAN, PlayerState.PLAYING)
        gp1_copy = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.FINISHED)
        gp2_copy = GamePlayer("gp2", PlayerColor.RED, PlayerType.HUMAN, PlayerState.FINISHED)
        now = arrow_utcnow()
        game = create_test_game()
        game.game_players = {"gp1": gp1, "gp2": gp2}
        game.completed_date = None
        game.game_state = GameState.PLAYING  # otherwise it's an illegal state
        game.completed_comment = None
        game.mark_completed("comment")
        assert game.completed_date >= now
        assert game.game_state == GameState.COMPLETED
        assert game.completed_comment == "comment"
        assert game.game_players == {"gp1": gp1_copy, "gp2": gp2_copy}
        # noinspection PyUnresolvedReferences
        game._engine.stop_game.assert_called_once()

    def test_mark_cancelled_illegal_state(self):
        game = create_test_game()
        for game_state in [game_state for game_state in GameState if game_state not in (GameState.PLAYING, GameState.ADVERTISED)]:
            game.game_state = game_state
            with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
                game.mark_cancelled(CancelledReason.CANCELLED, "comment")
            assert game.game_state == game_state  # should not change
            # noinspection PyUnresolvedReferences
            game._engine.stop_game.assert_not_called()

    # noinspection PyTypeChecker
    def test_mark_cancelled(self):
        for game_state in (GameState.PLAYING, GameState.ADVERTISED):  # either state is legal
            gp1 = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.PLAYING)
            gp2 = GamePlayer("gp2", PlayerColor.RED, PlayerType.HUMAN, PlayerState.PLAYING)
            gp1_copy = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.FINISHED)
            gp2_copy = GamePlayer("gp2", PlayerColor.RED, PlayerType.HUMAN, PlayerState.FINISHED)
            now = arrow_utcnow()
            game = create_test_game()
            game.game_players = {"gp1": gp1, "gp2": gp2}
            game.completed_date = None
            game.game_state = game_state
            game.completed_comment = None
            game.mark_cancelled(CancelledReason.NOT_VIABLE, "comment")
            assert game.completed_date >= now
            assert game.game_state == GameState.CANCELLED
            assert game.cancelled_reason == CancelledReason.NOT_VIABLE
            assert game.completed_comment == "comment"
            assert game.game_players == {"gp1": gp1_copy, "gp2": gp2_copy}
            # noinspection PyUnresolvedReferences
            game._engine.stop_game.assert_called_once()

    def test_mark_quit_illegal_state(self):
        for game_state in [game_state for game_state in GameState if game_state not in (GameState.PLAYING, GameState.ADVERTISED)]:
            gp1 = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.PLAYING)
            game = create_test_game()
            game.game_players = {"gp1": gp1}
            game.game_state = game_state
            with pytest.raises(ProcessingError, match=r"Illegal state for operation"):
                game.mark_quit("gp1")
            assert game.game_state == game_state  # should not change
            assert "gp1" in game.game_players  # should not change

    def test_mark_quit_advertised(self):
        gp1 = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.PLAYING)
        game = create_test_game()
        game.game_players = {"gp1": gp1}
        game.game_state = GameState.ADVERTISED
        game.mark_quit("gp1")
        assert "gp1" not in game.game_players

    def test_mark_quit_started(self):
        gp1 = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.PLAYING)
        gp1_copy = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.QUIT)
        game = create_test_game()
        game.game_players = {"gp1": gp1}
        game.game_state = GameState.PLAYING
        game.mark_quit("gp1")
        assert game.game_players["gp1"] == gp1_copy

    def test_execute_move(self):
        game = create_test_game()
        game.execute_move("handle", "move_id")
        # noinspection PyUnresolvedReferences
        game._engine.execute_move.assert_called_once_with("handle", "move_id")

    def test_mark_joined_programmatic(self):
        game = create_test_game()
        game.game_players = {}
        game._mark_joined_programmatic()
        assert len(game.game_players) == 1
        gp1 = next(iter(game.game_players.values()))
        assert gp1.handle in _NAMES
        assert gp1.player_color is None  # will be assigned later
        assert gp1.player_type == PlayerType.PROGRAMMATIC
        assert gp1.player_state == PlayerState.JOINED

    def test_assign_handle(self):
        game = create_test_game()

        game.game_players = {}
        handle = game._assign_handle()
        assert handle in _NAMES

        game.game_players = {"gp1": MagicMock(handle="Frodo"), "gp2": MagicMock(handle="Gimli")}
        for _ in range(100):
            handle = game._assign_handle()
            assert handle in _NAMES and handle not in ("Frodo", "Gimli")


class TestStateManager:
    """
    Test the StateManager class.
    """

    def test_mark_active_no_websocket(self):
        player = MagicMock(websocket=None)
        mgr = StateManager()
        mgr.mark_active(player)
        player.mark_active.assert_called_once()

    def test_mark_active_unknown_websocket(self):
        websocket = MagicMock()
        player = MagicMock(websocket=websocket)
        mgr = StateManager()
        with pytest.raises(ProcessingError, match=r"Did not find player websocket"):
            mgr.mark_active(player)
        player.mark_active.assert_not_called()

    def test_mark_active(self):
        websocket = MagicMock()
        player = MagicMock(websocket=websocket)
        mgr = StateManager()
        mgr._websocket_map[websocket] = MagicMock()
        mgr.mark_active(player)
        player.mark_active.assert_called_once()
        # noinspection PyUnresolvedReferences
        mgr._websocket_map[websocket].mark_active.assert_called_once()

    def test_track_websocket_duplicate(self):
        original = MagicMock()
        websocket = MagicMock()
        mgr = StateManager()
        mgr._websocket_map[websocket] = original
        with pytest.raises(ProcessingError, match=r"Duplicate websocket encountered"):
            mgr.track_websocket(websocket)
        assert mgr._websocket_map[websocket] is original

    @patch("apologiesserver.manager.arrow_utcnow")
    def test_track_websocket(self, now):
        now.return_value = to_date("2020-05-11T16:57:00,000")
        websocket = MagicMock()
        mgr = StateManager()
        mgr.track_websocket(websocket)
        assert mgr._websocket_map[websocket] == TrackedWebsocket(websocket)

    def test_delete_websocket_unknown(self):
        websocket = MagicMock()
        mgr = StateManager()
        mgr.delete_websocket(websocket)  # no-op because it's unknown

    def test_delete_websocket(self):
        websocket = MagicMock()
        mgr = StateManager()
        mgr._websocket_map[websocket] = MagicMock()
        mgr.delete_websocket(websocket)
        assert websocket not in mgr._websocket_map

    def test_get_websocket_count(self):
        mgr = StateManager()
        mgr.track_websocket(MagicMock())
        mgr.track_websocket(MagicMock())
        mgr.track_websocket(MagicMock())
        assert mgr.get_websocket_count() == 3

    def test_lookup_all_websockets(self):
        websocket1 = MagicMock()
        websocket2 = MagicMock()
        websocket3 = MagicMock()
        mgr = StateManager()
        mgr.track_websocket(websocket1)
        mgr.track_websocket(websocket2)
        mgr.track_websocket(websocket3)
        assert mgr.lookup_all_websockets() == [websocket1, websocket2, websocket3]

    def test_lookup_players_for_websocket(self):
        player = MagicMock(player_id="player_id")
        websocket = MagicMock()
        mgr = StateManager()
        mgr.lookup_player = MagicMock()
        mgr.lookup_player.side_effect = [player, None]
        mgr.track_websocket(websocket)
        assert mgr.lookup_players_for_websocket(websocket) == []
        mgr._websocket_map[websocket].player_ids.add("player_id")
        mgr._websocket_map[websocket].player_ids.add("bogus")
        assert mgr.lookup_players_for_websocket(websocket) == [player]

    @patch("apologiesserver.manager.arrow_utcnow")
    @patch("apologiesserver.manager.uuid4")
    def test_track_game(self, uuid4, now):
        uuid4.return_value = "game_id"
        now.return_value = to_date("2020-05-11T16:57:00,000")
        player = MagicMock(handle="leela")
        advertised = AdvertiseGameContext("name", GameMode.STANDARD, 3, Visibility.PUBLIC, ["a", "b"])
        mgr = StateManager()
        game = mgr.track_game(player, advertised)
        assert game == TrackedGame.for_context("leela", "game_id", advertised)
        assert mgr._game_map["game_id"] is game

    def test_delete_game(self):
        game = MagicMock()
        mgr = StateManager()
        mgr._game_map["game"] = game
        mgr.delete_game(MagicMock(game_id="bogus"))  # safe to delete unknown game
        mgr.delete_game(MagicMock(game_id="game"))
        assert "game" not in mgr._game_map

    def test_get_total_game_count(self):
        game1 = MagicMock()
        mgr = StateManager()
        assert mgr.get_total_game_count() == 0
        mgr._game_map["game1"] = game1
        assert mgr.get_total_game_count() == 1

    def test_get_in_progress_game_count(self):
        game1 = MagicMock()
        game1.is_in_progress.return_value = True
        game2 = MagicMock()
        game2.is_in_progress.return_value = False
        game3 = MagicMock()
        game3.is_in_progress.return_value = True
        mgr = StateManager()
        assert mgr.get_in_progress_game_count() == 0
        mgr._game_map["game1"] = game1
        mgr._game_map["game2"] = game2
        mgr._game_map["game3"] = game3
        assert mgr.get_in_progress_game_count() == 2  # game1 and game3

    def test_lookup_game_game_id(self):
        game = MagicMock()
        mgr = StateManager()
        mgr._game_map["game"] = game
        assert mgr.lookup_game(game_id=None) is None
        assert mgr.lookup_game(game_id="bogus") is None
        assert mgr.lookup_game(game_id="game") is game

    def test_lookup_game_player(self):
        game = MagicMock()
        mgr = StateManager()
        mgr._game_map["game"] = game
        assert mgr.lookup_game(player=MagicMock(game_id=None)) is None
        assert mgr.lookup_game(player=MagicMock(game_id="bogus")) is None
        assert mgr.lookup_game(player=MagicMock(game_id="game")) is game

    def test_lookup_all_games(self):
        game1 = MagicMock()
        game2 = MagicMock()
        game3 = MagicMock()
        mgr = StateManager()
        mgr._game_map["game1"] = game1
        mgr._game_map["game2"] = game2
        mgr._game_map["game3"] = game3
        assert mgr.lookup_all_games() == [game1, game2, game3]

    def test_lookup_in_progress_games(self):
        game1 = MagicMock()
        game1.is_in_progress.return_value = True
        game2 = MagicMock()
        game2.is_in_progress.return_value = False
        game3 = MagicMock()
        game3.is_in_progress.return_value = True
        mgr = StateManager()
        mgr._game_map["game1"] = game1
        mgr._game_map["game2"] = game2
        mgr._game_map["game3"] = game3
        assert mgr.lookup_in_progress_games() == [game1, game3]

    def test_lookup_game_players(self):
        player = MagicMock()
        gp1 = GamePlayer("gp1", PlayerColor.YELLOW, PlayerType.PROGRAMMATIC, PlayerState.PLAYING)
        gp2 = GamePlayer("gp2", PlayerColor.RED, PlayerType.HUMAN, PlayerState.PLAYING)
        game = create_test_game()
        game.game_players = {"gp1": gp1, "gp2": gp2}
        mgr = StateManager()
        mgr.lookup_player = MagicMock()
        mgr.lookup_player.return_value = player
        assert mgr.lookup_game_players(game) == [player]
        mgr.lookup_player.assert_called_once_with(handle="gp2")

    def test_lookup_available_games(self):
        player = MagicMock(handle="handle")
        game1 = MagicMock()
        game1.is_available.return_value = True
        game2 = MagicMock()
        game2.is_available.return_value = False
        game3 = MagicMock()
        game3.is_available.return_value = True
        mgr = StateManager()
        mgr._game_map["game1"] = game1
        mgr._game_map["game2"] = game2
        mgr._game_map["game3"] = game3
        assert mgr.lookup_available_games(player) == [game1, game3]
        game1.is_available.assert_called_once_with("handle")
        game2.is_available.assert_called_once_with("handle")
        game3.is_available.assert_called_once_with("handle")

    @patch("apologiesserver.manager.arrow_utcnow")
    @patch("apologiesserver.manager.uuid4")
    def test_track_player(self, uuid4, now):
        uuid4.return_value = "player_id"
        now.return_value = to_date("2020-05-11T16:57:00,000")
        websocket = MagicMock()
        mgr = StateManager()
        mgr.track_websocket(websocket)  # this would always be done ahead of time, so do it here
        mgr._websocket_map[websocket].last_active_date = None  # so we can tell it was reset
        player = mgr.track_player(websocket, "handle")
        assert player == TrackedPlayer.for_context("player_id", websocket, "handle")
        assert mgr._websocket_map[websocket].last_active_date == to_date("2020-05-11T16:57:00,000")  # we always mark the websocket
        assert "player_id" in mgr._websocket_map[websocket].player_ids
        assert mgr._player_map["player_id"] is player
        assert mgr._handle_map["handle"] == "player_id"
        with pytest.raises(ProcessingError, match=r"Handle is already in use"):
            mgr.track_player(websocket, "handle")
        assert mgr._player_map["player_id"] is player
        assert mgr._handle_map["handle"] == "player_id"

    @patch("apologiesserver.manager.arrow_utcnow")
    def test_retrack_player(self, now):
        now.return_value = to_date("2020-05-11T16:57:00,000")
        player = MagicMock(player_id="player_id")
        websocket = MagicMock()
        mgr = StateManager()
        mgr.track_websocket(websocket)  # this would always be done ahead of time, so do it here
        mgr._websocket_map[websocket].last_active_date = None  # so we can tell it was reset
        mgr.retrack_player(player, websocket)
        assert player.websocket == websocket
        assert mgr._websocket_map[websocket].last_active_date == to_date("2020-05-11T16:57:00,000")  # we always mark the websocket
        assert "player_id" in mgr._websocket_map[websocket].player_ids

    def test_delete_player_no_websocket(self):
        mgr = StateManager()
        mgr._player_map["player_id"] = MagicMock()
        mgr._handle_map["handle"] = MagicMock()
        mgr.delete_player(MagicMock(player_id="bogus", handle="bogus", websocket=MagicMock()))  # safe to delete unknown stuff
        mgr.delete_player(MagicMock(player_id="player_id", handle="handle", websocket=None))
        assert "player_id" not in mgr._player_map
        assert "handle" not in mgr._handle_map

    def test_delete_player_unknown_websocket(self):
        websocket = MagicMock()
        mgr = StateManager()
        # note: not tracking the websocket, so it will not exist when we try to look it up
        mgr._player_map["player_id"] = MagicMock()
        mgr._handle_map["handle"] = MagicMock()
        mgr.delete_player(MagicMock(player_id="bogus", handle="bogus", websocket=MagicMock()))  # safe to delete unknown stuff
        mgr.delete_player(MagicMock(player_id="player_id", handle="handle", websocket=websocket))
        assert "player_id" not in mgr._player_map
        assert "handle" not in mgr._handle_map

    def test_delete_player(self):
        websocket = MagicMock()
        mgr = StateManager()
        mgr.track_websocket(websocket)  # this would always be done ahead of time, so do it here
        mgr._websocket_map[websocket].player_ids.add("player_id")
        mgr._player_map["player_id"] = MagicMock()
        mgr._handle_map["handle"] = MagicMock()
        mgr.delete_player(MagicMock(player_id="bogus", handle="bogus", websocket=MagicMock()))  # safe to delete unknown stuff
        mgr.delete_player(MagicMock(player_id="player_id", handle="handle", websocket=websocket))
        assert "player_id" not in list(mgr._websocket_map[websocket].player_ids)
        assert "player_id" not in mgr._player_map
        assert "handle" not in mgr._handle_map

    def test_get_registered_player_count(self):
        player = MagicMock()
        mgr = StateManager()
        assert mgr.get_registered_player_count() == 0
        mgr._player_map["player"] = player
        assert mgr.get_registered_player_count() == 1

    def test_lookup_player_player_id(self):
        player = MagicMock()
        mgr = StateManager()
        mgr._player_map["player"] = player
        assert mgr.lookup_player(player_id=None) is None
        assert mgr.lookup_player(player_id="bogus") is None
        assert mgr.lookup_player(player_id="player") is player

    def test_lookup_player_handle(self):
        player = MagicMock(player_id="player")
        mgr = StateManager()
        mgr._player_map["player"] = player
        mgr._handle_map["handle"] = "player"
        assert mgr.lookup_player(handle=None) is None
        assert mgr.lookup_player(handle="bogus") is None
        assert mgr.lookup_player(handle="handle") is player

    def test_lookup_all_players(self):
        player = MagicMock()
        mgr = StateManager()
        mgr._player_map["player"] = player
        assert mgr.lookup_all_players() == [player]

    def test_lookup_websocket_activity(self):
        date = to_date("2020-05-12T16:26:00,000")
        websocket = MagicMock()
        tracked = MagicMock(websocket=websocket, last_active_date=date, player_ids=OrderedSet(["1", "2", "3"]))
        mgr = StateManager()
        mgr._websocket_map[websocket] = tracked
        assert mgr.lookup_websocket_activity() == [(tracked, date, 3)]

    def test_lookup_player_activity(self):
        date = to_date("2020-05-12T16:26:00,000")
        tracked = MagicMock(player_id="player_id", last_active_date=date, connection_state=ConnectionState.CONNECTED)
        mgr = StateManager()
        mgr._player_map["player_id"] = tracked
        assert mgr.lookup_player_activity() == [(tracked, date, ConnectionState.CONNECTED)]

    def test_lookup_game_activity(self):
        date = to_date("2020-05-12T16:26:00,000")
        tracked1 = MagicMock(game_id="1", last_active_date=date, completed=False)
        tracked2 = MagicMock(game_id="2", last_active_date=None, completed=True)
        mgr = StateManager()
        mgr._game_map["1"] = tracked1
        mgr._game_map["2"] = tracked2
        assert mgr.lookup_game_activity() == [(tracked1, date)]

    def test_lookup_game_completion(self):
        date = to_date("2020-05-12T16:26:00,000")
        tracked1 = MagicMock(game_id="1", completed_date=None, completed=False)
        tracked2 = MagicMock(game_id="2", completed_date=date, completed=True)
        mgr = StateManager()
        mgr._game_map["1"] = tracked1
        mgr._game_map["2"] = tracked2
        assert mgr.lookup_game_completion() == [(tracked2, date)]


class TestGame:
    """
    Test a complete game via the StateManager interface.

    This basically covers the steps that would happen in a real game via the
    public interface, except directly against the manager interface instead.
    Unlike the public interface, the manager interface is synchronous, which
    makes everything a lot easier to deal with if debugging is required.
    """

    def test_complete_game_standard(self):
        TestGame.play_game(mode=GameMode.STANDARD, player_count=4, handles=["leela"])

    def test_complete_game_adult(self):
        TestGame.play_game(mode=GameMode.ADULT, player_count=3, handles=["leela"])

    @staticmethod
    def play_game(mode: GameMode, player_count: int, handles: list[str]) -> None:
        assert len(handles) >= 1

        print("Testing complete game with mode %s and 'human' players %s" % (mode, handles))

        websocket = MagicMock()

        name = "Demo Game"
        visibility = Visibility.PUBLIC
        invited_handles: list[str] = []
        advertised = AdvertiseGameContext(name, mode, player_count, visibility, invited_handles)

        mgr = StateManager()
        print("Created state manager.")

        mgr.track_websocket(websocket)
        print("Tracked simulated websocket")

        players: dict[str, TrackedPlayer] = {}
        for handle in handles:
            player = mgr.track_player(websocket, handle)
            players[handle] = player
            print("Created player %s with id %s" % (player.handle, player.player_id))

        game = mgr.track_game(players[handles[0]], advertised)
        print("Advertised game '%s' with id %s" % (game.name, game.game_id))

        for handle in handles:
            game.mark_joined(handle)
        game.mark_started()
        for player in players.values():
            player.mark_playing()
        print("Started game")
        print(
            "Players: %s" % ["%s (%s)" % (player.handle, player.player_color.value) for player in game.get_game_players()]  # type: ignore
        )

        completed = False
        while not completed:
            handle, player_type = game.get_next_turn()
            if player_type == PlayerType.PROGRAMMATIC:
                view = game.get_player_view(handle)
                moves = game.get_legal_moves(handle)
                move = RewardV1InputSource().choose_move(game.mode, view, moves, Rules.evaluate_move)
                (completed, _winner, comment) = game.execute_move(handle, move.id)
            else:
                moves = game.get_legal_moves(handle)
                context = GamePlayerTurnContext.for_moves(handle=handle, game_id=game.game_id, moves=moves)
                move_id = random.choice(list(context.moves.keys()))  # noqa: S311 # simulates input from client
                (completed, _winner, comment) = game.execute_move(handle, move_id)
            history = game.get_recent_history(1)
            if history:
                color = "General" if not history[0].color else history[0].color.value
                action = history[0].action
                print("%s - %s" % (color, action))
            if completed:
                for player in players.values():
                    player.mark_quit()
                game.mark_completed(comment)
                print("%s" % comment)

        for player in players.values():
            mgr.delete_player(player)
        mgr.delete_game(game)
        mgr.delete_websocket(websocket)

        print("Test completed")
