from enum import Enum
import numpy as np

class Player(Enum):
    undefined = 0
    one = 1
    two = 2
    grid_full = 3

# Adjust the symbol or color for printing the grid
SYMBOLS = {
    Player.one: "X",
    Player.two: "O",
    Player.undefined: " "
}
COLORS = {
    Player.one: "\033[32m",  # green
    Player.two: "\033[94m",  # blue
    Player.undefined: ""
}
END_COLOR = "\033[0m"

def get_opponent(player:Player) -> Player:
    if player == Player.one:
        return Player.two
    else:
        return Player.one

class TicTacToeError(Exception):
    """TicTacToe Error"""

class TicTacToe():
    def __init__(self) -> None:
        grid = []
        for _ in range(3):
            grid.append([Player.undefined, Player.undefined, Player.undefined])
        self.grid = np.array(grid)

    @staticmethod
    def _get_player_from_int(player:Player|int) -> Player:
        """
        Internal helper methot to return a Player-Object from Int or Player
        """
        if isinstance(player, int):
            try:
                return Player(player)
            except ValueError:
                raise TicTacToeError("The player number is invalid. Only '1' and '2' are accepted")
        return player

    def add(self, row:int, col:int, player:Player|int, grid:np.ndarray|None = None) -> bool:
        """ 
        Change the value of a undefined field.
        The player can be passed as Player Object or plain Int.
        Returns True, if executed correctly.
        Returns False, if the field is already occupied
        or the player number is invalid.
        """
        if grid is None:
            grid = self.grid
        player = self._get_player_from_int(player)
        if self.check_field(row, col, grid):
            grid[row][col] = player
            return True
        return False

    def check_field(self, row:int, col:int, grid:np.ndarray|None = None) -> bool:
        """
        Checks, if a field is occupied
        """
        if grid is None:
            grid = self.grid
        if self.grid[row][col] != Player.undefined:
            return False
        return True

    def print_grid(self, grid:np.ndarray|None=None) -> None:
        """
        Prints the current state of a grid. 
        If no grid is specified, the current game grid (self.grid) is printed
        """
        if grid is None:
            grid = self.grid

        print("\t┌───┬───┬───┐")
        for i, row in enumerate(grid):
            print("\t│", end="")
            for field in row:
                print(f" {COLORS[field]}{SYMBOLS[field]}{END_COLOR} │", end="")
            if i < len(grid) - 1:
                print("\n\t├───┼───┼───┤")
        print("\n\t└───┴───┴───┘\n\n")

    def check_winner(self, grid:np.ndarray|None = None) -> Player:
        """ 
        Checks, if the game is won.
        If the game is won by a player, returns this player.
        Else, returns Player.undefined
        """

        if grid is None:
            grid = self.grid
        # Vertical line
        for row in grid:
            if (row[0] == row[1] == row[2] and row[0] != Player.undefined):
                return row[0]

        # Horizontal line
        for i in range(len(grid)):
            col = grid[:, i]
            if (col[0] == col[1]  == col[2] and col[0] != Player.undefined):
                return col[0]

        # Diagonal line
        if (grid[0, 0] == grid[1, 1] == grid[2, 2]
            or grid[0, 2] == grid[1, 1] == grid[2, 0]):
            if grid[1, 1] != Player.undefined:
                return grid[1, 1]

        # No winner
        if Player.undefined in grid:
            return Player.undefined
        return Player.grid_full

    def _maximize(self, grid:np.ndarray) -> float:
        """
        Helper Method for the maximizing Player
        """
        best = -np.inf
        for i in range(3):
            for j in range(3):
                if grid[i, j] == Player.undefined:
                    grid[i, j] = self.active_player
                    score = self._evaluate_recursively(grid, self.opponent)
                    grid[i, j] = Player.undefined
                    best = max(best, score)
        return best

    def _minimize(self, grid:np.ndarray) -> float:
        """
        Helper Method for the minimizing player
        """
        best = np.inf
        for i in range(3):
            for j in range(3):
                if grid[i, j] == Player.undefined:
                    grid[i, j] = self.opponent
                    score = self._evaluate_recursively(grid, self.active_player)
                    grid[i, j] = Player.undefined
                    best = min(best, score)
        return best

    def _evaluate_recursively(self, grid:np.ndarray, current:Player) -> float:
        """
        Helper Method for calculating the best move.
        Recursively evaluate the board state from the perspective of `current`.
        """
        winner = self.check_winner(grid)
        if winner == self.active_player:
            return 1
        elif winner == get_opponent(self.active_player):
            return -1
        # Draw
        if not any(cell == Player.undefined for row in grid for cell in row):
            return 0
        if current == self.active_player:
            return self._maximize(grid)
        else:
            return self._minimize(grid)

    def get_best_move(self, player:Player|int) -> tuple[int, int]:
        """
        Returns the best move for the given Player as a tuple of integers.
        """
        self.active_player = self._get_player_from_int(player)
        self.opponent = get_opponent(self.active_player)
        best_score = -np.inf
        move = (-1, -1)
        for i in range(3):
            for j in range(3):
                if self.grid[i, j] == Player.undefined:
                    self.grid[i, j] = self.active_player
                    score = self._evaluate_recursively(self.grid, self.opponent)
                    self.grid[i, j] = Player.undefined
                    if score > best_score:
                        best_score = score
                        move = (i, j)
        return move

if __name__ == "__main__":
    ttt = TicTacToe()
    #ttt.add(1, 1, 1)
    #ttt.print_grid()
    #ttt.add(2, 1, 2)
    ttt.print_grid()
    current_player = Player.one
    while ttt.check_winner() == Player.undefined:
        move = ttt.get_best_move(current_player)
        ttt.add(move[0], move[1], current_player)
        if current_player == Player.one:
            current_player = Player.two
        else:
            current_player = Player.one
        ttt.print_grid()