#####################################################################################################################################################
######################################################################## INFO #######################################################################
#####################################################################################################################################################

"""
    This file is part of the PyRat library.
    It is meant to be used as a library, and not to be executed directly.
    Please import necessary elements using the following syntax:
        from pyrat import <element_name>
"""

#####################################################################################################################################################
###################################################################### IMPORTS ######################################################################
#####################################################################################################################################################

# External imports
from typing import *
from typing_extensions import *
from numbers import *
import colored
import re
import math
import sys
import platform
import os
import time

# PyRat imports
from pyrat.src.RenderingEngine import RenderingEngine
from pyrat.src.Player import Player
from pyrat.src.Maze import Maze
from pyrat.src.GameState import GameState

#####################################################################################################################################################
###################################################################### CLASSES ######################################################################
#####################################################################################################################################################

class ShellRenderingEngine (RenderingEngine):

    """
        This class inherits from the RenderingEngine class.
        Therefore, it has the attributes and methods defined in the RenderingEngine class in addition to the ones defined below.

        An ASCII rendering engine is a rendering engine that can render a PyRat game in ASCII.
        It also supports ANSI escape codes to colorize the rendering.
    """

    #############################################################################################################################################
    #                                                               MAGIC METHODS                                                               #
    #############################################################################################################################################

    def __init__ ( self:            Self,
                   use_colors:      bool = True,
                   clear_each_turn: bool = True,
                   *args:           Any,
                   **kwargs:        Any
                 ) ->               None:

        """
            This function is the constructor of the class.
            When an object is instantiated, this method is called to initialize the object.
            This is where you should define the attributes of the object and set their initial values.
            Arguments *args and **kwargs are used to pass arguments to the parent constructor.
            This is useful not to declare again all the parent's attributes in the child class.
            In:
                * self:            Reference to the current object.
                * use_colors:      Boolean indicating whether the rendering engine should use colors or not.
                * clear_each_turn: Boolean indicating whether the rendering engine should clear the screen each turn.
                * args:            Arguments to pass to the parent constructor.
                * kwargs:          Keyword arguments to pass to the parent constructor.
            Out:
                * A new instance of the class (we indicate None as return type per convention, see PEP-484).
        """

        # Inherit from parent class
        super().__init__(*args, **kwargs)

        # Debug
        assert isinstance(use_colors, bool), "Argument 'use_colors' must be a boolean"
        assert isinstance(clear_each_turn, bool), "Argument 'clear_each_turn' must be a boolean"

        # Private attributes
        self.__use_colors = use_colors
        self.__clear_each_turn = clear_each_turn

    #############################################################################################################################################
    #                                                               PUBLIC METHODS                                                              #
    #############################################################################################################################################

    @override
    def render ( self:       Self,
                 players:    List[Player],
                 maze:       Maze,
                 game_state: GameState,
               ) ->          None:
        
        """
            This method redefines the method of the parent class.
            This function renders the game to show its current state.
            It does so by creating a string representing the game state and printing it.
            In:
                * self:       Reference to the current object.
                * players:    Players of the game.
                * maze:       Maze of the game.
                * game_state: State of the game.
            Out:
                * None.
        """

        # Debug
        assert isinstance(players, list), "Argument 'players' must be a list"
        assert all(isinstance(player, Player) for player in players), "All elements of 'players' must be of type 'pyrat.Player'"
        assert isinstance(maze, Maze), "Argument 'maze' must be of type 'pyrat.Maze'"
        assert isinstance(game_state, GameState), "Argument 'game_state' must be of type 'pyrat.GameState'"

        # Dimensions
        max_weight = max([maze.get_weight(*edge) for edge in maze.edges])
        max_weight_len = len(str(max_weight))
        max_player_name_len = max([len(player.name) for player in players]) + (max_weight_len + 5 if max_weight > 1 else 0)
        max_cell_number_len = len(str(maze.width * maze.height - 1))
        cell_width = max(max_player_name_len, max_weight_len, max_cell_number_len + 1) + 2
        
        # Colors
        wall_color = "white"
        ground_color = "grey_23"
        cheese_color = "yellow_1"
        mud_color = "orange_1"
        path_color = "white"
        number_color = "magenta"

        # Game elements
        ground = self.__colorize(" ", colored.bg(ground_color))
        wall = self.__colorize(" ", colored.bg(wall_color) + colored.fg(wall_color), "▉")
        cheese = self.__colorize("▲", colored.bg(ground_color) + colored.fg(cheese_color))
        mud_horizontal = self.__colorize("━", colored.bg(ground_color) + colored.fg(mud_color))
        mud_vertical = self.__colorize("┃", colored.bg(ground_color) + colored.fg(mud_color))
        mud_value = lambda number: self.__colorize(str(number), colored.bg(ground_color) + colored.fg(mud_color))
        path_horizontal = self.__colorize("╌", colored.bg(ground_color) + colored.fg(path_color))
        path_vertical = self.__colorize("┆", colored.bg(ground_color) + colored.fg(path_color))
        cell_number = lambda number: self.__colorize(str(number), colored.bg(ground_color) + colored.fg(number_color))
        score_cheese = self.__colorize("▲ ", colored.fg(cheese_color))
        score_half_cheese = self.__colorize("△ ", colored.fg(cheese_color))
        
        # Player/team elements
        teams = {team: self.__colorize(team, colored.fg(9 + list(game_state.teams.keys()).index(team))) for team in game_state.teams}
        mud_indicator = lambda player_name: " (" + ("⬇" if maze.coords_difference(game_state.muds[player_name]["target"], game_state.player_locations[player_name]) == (-1, 0) else "⬆" if maze.coords_difference(game_state.muds[player_name]["target"], game_state.player_locations[player_name]) == (1, 0) else "➡" if maze.coords_difference(game_state.muds[player_name]["target"], game_state.player_locations[player_name]) == (0, 1) else "⬅") + " " + str(game_state.muds[player_name]["count"]) + ")" if game_state.muds[player_name]["count"] > 0 else ""
        player_names = {player.name: self.__colorize(player.name + mud_indicator(player.name), colored.bg(ground_color) + ("" if len(teams) == 1 else colored.fg(9 + ["team" if player.name in team else 0 for team in game_state.teams.values()].index("team")))) for player in players}
        
        # Game info
        environment_str = "Game over" if game_state.game_over() else "Starting turn %d" % game_state.turn if game_state.turn > 0 else "Initial configuration"
        team_scores = game_state.get_score_per_team()
        for team in game_state.teams:
            environment_str += "\n" + score_cheese * int(team_scores[team]) + score_half_cheese * math.ceil(team_scores[team] - int(team_scores[team]))
            environment_str += "[" + teams[team] + "] " if len(teams) > 1 or len(team) > 0 else ""
            environment_str += " + ".join(["%s (%s)" % (player_in_team, str(round(game_state.score_per_player[player_in_team], 3)).rstrip('0').rstrip('.') if game_state.score_per_player[player_in_team] > 0 else "0") for player_in_team in game_state.teams[team]])

        # Consider cells in lexicographic order
        environment_str += "\n" + wall * (maze.width * (cell_width + 1) + 1)
        for row in range(maze.height):
            players_in_row = [game_state.player_locations[player.name] for player in players if maze.i_to_rc(game_state.player_locations[player.name])[0] == row]
            cell_height = max([players_in_row.count(cell) for cell in players_in_row] + [max_weight_len]) + 2
            environment_str += "\n"
            for subrow in range(cell_height):
                environment_str += wall
                for col in range(maze.width):
                    
                    # Check cell contents
                    players_in_cell = [player.name for player in players if game_state.player_locations[player.name] == maze.rc_to_i(row, col)]
                    cheese_in_cell = maze.rc_to_i(row, col) in game_state.cheese

                    # Find subrow contents (nothing, cell number, cheese, trace, player)
                    background = wall if not maze.rc_exists(row, col) else ground
                    cell_contents = ""
                    if subrow == 0:
                        if background != wall and not self._render_simplified:
                            cell_contents += background
                            cell_contents += cell_number(maze.rc_to_i(row, col))
                    elif cheese_in_cell:
                        if subrow == (cell_height - 1) // 2:
                            cell_contents = background * ((cell_width - self.__colored_len(cheese)) // 2)
                            cell_contents += cheese
                        else:
                            cell_contents = background * cell_width
                    else:
                        first_player_index = (cell_height - len(players_in_cell)) // 2
                        if first_player_index <= subrow < first_player_index + len(players_in_cell):
                            cell_contents = background * ((cell_width - self.__colored_len(player_names[players_in_cell[subrow - first_player_index]])) // 2)
                            cell_contents += player_names[players_in_cell[subrow - first_player_index]]
                        else:
                            cell_contents = background * cell_width
                    environment_str += cell_contents
                    environment_str += background * (cell_width - self.__colored_len(cell_contents))
                    
                    # Right separation
                    right_weight = "0" if not maze.rc_exists(row, col) or not maze.rc_exists(row, col + 1) or not maze.has_edge(maze.rc_to_i(row, col), maze.rc_to_i(row, col + 1)) else str(maze.get_weight(maze.rc_to_i(row, col), maze.rc_to_i(row, col + 1)))
                    if col == maze.width - 1 or right_weight == "0":
                        environment_str += wall
                    else:
                        if right_weight == "1":
                            environment_str += path_vertical
                        elif not self._render_simplified and math.ceil((cell_height - len(right_weight)) / 2) <= subrow < math.ceil((cell_height - len(right_weight)) / 2) + len(right_weight):
                            digit_number = subrow - math.ceil((cell_height - len(right_weight)) / 2)
                            environment_str += mud_value(right_weight[digit_number])
                        else:
                            environment_str += mud_vertical
                environment_str += "\n"
            environment_str += wall
            
            # Bottom separation
            for col in range(maze.width):
                bottom_weight = "0" if not maze.rc_exists(row, col) or not maze.rc_exists(row + 1, col) or not maze.has_edge(maze.rc_to_i(row, col), maze.rc_to_i(row + 1, col)) else str(maze.get_weight(maze.rc_to_i(row, col), maze.rc_to_i(row + 1, col)))
                if bottom_weight == "0":
                    environment_str += wall * (cell_width + 1)
                elif bottom_weight == "1":
                    environment_str += path_horizontal * cell_width + wall
                else:
                    cell_contents = mud_horizontal * ((cell_width - self.__colored_len(bottom_weight)) // 2) + mud_value(bottom_weight) if not self._render_simplified else ""
                    environment_str += cell_contents
                    environment_str += mud_horizontal * (cell_width - self.__colored_len(cell_contents)) + wall
        
        # Render
        if self.__clear_each_turn:
            self.__clear_output()
        print(environment_str, file=sys.stderr, flush=True)

        # Wait a bit
        sleep_time = 1.0 / self._rendering_speed
        time.sleep(sleep_time)
        
    #############################################################################################################################################
    #                                                              PRIVATE METHODS                                                              #
    #############################################################################################################################################

    def __clear_output ( self: Self
                       ) ->    None:

        """
            This method clears the output of the console.
            In:
                * self: Reference to the current object.
            Out:
                * None.
        """

        # If in a Jupyter environment
        if "ipykernel" in sys.modules:
            from IPython.display import clear_output
            clear_output(wait=True)

        # If in a standard environment
        else:
            os.system("cls" if platform.system() == "Windows" else "clear")

    #############################################################################################################################################

    def __colorize ( self:           Self,
                     text:           str,
                     colorization:   str,
                     alternate_text: Optional[str] = None
                   ) ->              str:
        
        """
            This method colorizes a text.
            It does so by adding the colorization to the text and resetting the colorization at the end of the text.
            In:
                * self:           Reference to the current object.
                * text:           Text to colorize.
                * colorization:   Colorization to use.
                * alternate_text: Alternate text to use if we don't use colors and the provided text does not fit.
            Out:
                * colorized_text: Colorized text.
        """

        # Debug
        assert isinstance(text, str), "Argument 'text' must be a string"
        assert isinstance(colorization, str), "Argument 'colorization' must be a string"
        assert isinstance(alternate_text, (str, type(None))), "Argument 'alternate_text' must be a string or None"

        # If we don't use colors, we return the correct text
        if not self.__use_colors:
            if alternate_text is None:
                colorized_text = str(text)
            else:
                colorized_text = str(alternate_text)
        
        # If using colors, we return the colorized text
        else:
            colorized_text = colorization + str(text) + colored.attr(0)

        # Return the colorized (or not) text
        return colorized_text
    
    #############################################################################################################################################

    def __colored_len ( self: Self,
                        text: str
                      ) ->    Integral:
        
        """
            This method returns the true len of a color-formated string.
            In:
                * self: Reference to the current object.
                * text: Text to measure.
            Out:
                * text_length: Length of the text.
        """

        # Debug
        assert isinstance(text, str), "Argument 'text' must be a string"

        # Return the length of the text without the colorization
        text_length = len(re.sub(r"[\u001B\u009B][\[\]()#;?]*((([a-zA-Z\d]*(;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|((\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))", "", text))
        return text_length
    
#####################################################################################################################################################
#####################################################################################################################################################
