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

"""
This program will be used for the PyRat project tournament:
https://hub.imt-atlantique.fr/ueinfo-fise1a/s5/project/tournament/

It performs a selection phase followed by a tournament phase.
The selection phase consists of several groups of teams.
In each group, all teams face each other in a series of matches.
The best teams from each group are then qualified for the tournament phase.
The tournament phase is a knockout tournament until a winner is determined.

Intermediate results are saved in text files to allow resuming the tournament if needed.
The results files are saved in the `results` directory.
The players are expected to be in the `players` directory, in subdirectories named after the teams.
"""

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

# External imports
import os
import ast
import random
import importlib
import inspect
import sys
import math

# PyRat imports
from pyrat import Game, Player, PlayerSkin, GameMode

#####################################################################################################################################################
##################################################################### CONSTANTS #####################################################################
#####################################################################################################################################################

# Path elements
PLAYERS_DIRECTORY = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "contenders")
SAVE_DIRECTORY = os.path.join(os.path.abspath(os.path.dirname(__file__)), "..", "results")
SELECTIONS_DIRECTORY = os.path.join(SAVE_DIRECTORY, "selections")
SELECTIONS_RESULTS = os.path.join(SELECTIONS_DIRECTORY, "selections.log")
TOURNAMENT_DIRECTORY = os.path.join(SAVE_DIRECTORY, "tournament")
TOURNAMENT_RESULTS = os.path.join(TOURNAMENT_DIRECTORY, "tournament.log")

# Max number of matches to make
MAX_MATCHES = 3

# Number of groups in the selection phase, and number of players to keep per group
# The product of both should be a power of 2
SELECTION_GROUPS = 2
KEEP_BEST = 2

# Customize the game elements
CONFIG = {"game_mode": GameMode.MATCH,
          "continue_on_error": True,
          "save_game": True,
          "mud_percentage": 20.0,
          "cell_percentage": 80.0,
          "wall_percentage": 60.0,
          "maze_width": 25,
          "mud_range": [4, 9],
          "maze_height": 20,
          "nb_cheese": 41}

#####################################################################################################################################################
##################################################################### FUNCTIONS #####################################################################
#####################################################################################################################################################

def load_players_from_team ( team_directory: str,
                             skin:           PlayerSkin
                           ) ->              list[Player]:
    
    """
    Loads the players from a team directory.

    Args:
        team_directory: Path to the team directory.
        skin:           The skin to use for the players.

    Returns:
        A list of instantiated players found in the team directory.
    """

    # Determine the players
    team_players_directory = os.path.join(team_directory, "players")
    player_module_names = [file_name[:-3] for file_name in os.listdir(team_players_directory) if file_name.endswith(".py")]

    # Instantiate the players
    players = []
    for player_module_name in player_module_names:

        # Load module from file
        player_file_name = os.path.abspath(os.path.join(team_players_directory, player_module_name + ".py"))
        player_spec = importlib.util.spec_from_file_location(player_module_name, player_file_name)
        player_module = importlib.util.module_from_spec(player_spec)
        player_spec.loader.exec_module(player_module)
        
        # Find the player class and instantiate it
        for name, obj in inspect.getmembers(player_module, inspect.isclass):
            if issubclass(obj, Player) and obj.__module__ == player_module_name:
                player = obj(skin=skin)
                players.append(player)

    # Return the players
    return players

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

def make_match ( team_1_name:       str,
                 team_2_name:       str,
                 results_file_name: str,
                 extra_game_config: dict[str, object],
               ) ->                 tuple[dict[str, float], dict[str, int], str]:
    
    """
    Makes a match between two teams.
    We play up to MAX_MATCHES times.

    Args:
        team_1_name:       The name of the first team.
        team_2_name:       The name of the second team.
        results_file_name: The file where to save the results.
        extra_game_config: Extra configuration for the game.

    Returns:
        A tuple containing:
        - The total time per team.
        - The number of games won per team.
        - The name of the winning team (or "Nobody" in case of a tie).
    """

    # Print header
    print("    %s [Vs] %s" % (team_1_name, team_2_name))

    # We keep the same instances for all matches
    game = None
    team_1 = None
    team_2 = None

    # We make up to MAX_MATCHES matches
    times_per_team = {team_1_name: 0.0, team_2_name: 0.0}
    games_won_per_team = {team_1_name: 0, team_2_name: 0}
    for match_nb in range(MAX_MATCHES):

        # Stop when it is not possible to change the winner
        if min(games_won_per_team.values()) + (MAX_MATCHES - match_nb) < max(games_won_per_team.values()):
            break

        # Check if the match has already been played
        match_already_played = False
        with open(results_file_name, "r") as results_file:
            for line in results_file:
                stats = ast.literal_eval(line)
                if isinstance(stats, dict) and "team_1" in stats and stats["team_1"] == team_1_name and stats["team_2"] == team_2_name and stats["match"] == match_nb:
                    match_already_played = True
                    break
                    
        # If needed, we play the match
        if not match_already_played:

            # If this is match 1, we instantiate the game and the players
            if team_1 is None:

                # Instantiate objects
                game = Game(**CONFIG, **extra_game_config)
                team_1 = load_players_from_team(os.path.join(PLAYERS_DIRECTORY, team_1_name), PlayerSkin.RAT)
                team_2 = load_players_from_team(os.path.join(PLAYERS_DIRECTORY, team_2_name), PlayerSkin.PYTHON)
                for team_name, team in zip([team_1_name, team_2_name], [team_1, team_2]):
                    for player in team:
                        game.add_player(player, team_name)

            # We reset the game for a new match
            else:
                game.reset(keep_players=True, same=False)
            
            # Start the game
            # We redirect stderr to avoid printing possible errors and have nice outputs
            old_stderr = sys.stderr
            sys.stderr = open(os.devnull, "w")
            stats = game.start()
            sys.stderr.close()
            sys.stderr = old_stderr

            # Check for errors (manual stop, timeout, etc.)
            if not stats:
                raise Exception("Game stopped")
            
            # Update stats
            stats["match"] = match_nb
            stats["team_1"] = team_1_name
            stats["team_2"] = team_2_name

            # Save the results to the selections file
            with open(results_file_name, "a") as results_file:
                results_file.write(str(stats) + "\n")
        
        # Check the winner
        scores_per_team = {team_1_name: sum([stats["players"][player_name]["score"] for player_name in stats["players"] if stats["players"][player_name]["team"] == team_1_name]),
                            team_2_name: sum([stats["players"][player_name]["score"] for player_name in stats["players"] if stats["players"][player_name]["team"] == team_2_name])}
        times_per_team[team_1_name] += sum([stats["players"][player_name]["preprocessing_duration"] or 0.0 + sum(stats["players"][player_name]["turn_durations"]) for player_name in stats["players"] if stats["players"][player_name]["team"] == team_1_name])
        times_per_team[team_2_name] += sum([stats["players"][player_name]["preprocessing_duration"] or 0.0 + sum(stats["players"][player_name]["turn_durations"]) for player_name in stats["players"] if stats["players"][player_name]["team"] == team_2_name])
        winner = team_1_name if scores_per_team[team_1_name] > scores_per_team[team_2_name] else team_2_name if scores_per_team[team_2_name] > scores_per_team[team_1_name] else "Nobody"
        if winner != "Nobody":
            games_won_per_team[winner] += 1

        # Check for errors
        players_with_errors = [player_name for player_name in stats["players"] if stats["players"][player_name]["actions"]["error"] > 0]
        errors_str = "" if len(players_with_errors) == 0 else "   # Players with errors: " + ", ".join(players_with_errors)

        # Print the game results
        print("      - Match %d: %s wins (%d - %d)%s" % (match_nb + 1, winner, max(scores_per_team[team_1_name], scores_per_team[team_2_name]), min(scores_per_team[team_1_name], scores_per_team[team_2_name]), errors_str))
    
    # Print the match results
    winner = team_1_name if games_won_per_team[team_1_name] > games_won_per_team[team_2_name] else team_2_name if games_won_per_team[team_2_name] > games_won_per_team[team_1_name] else "Nobody"
    print()
    if winner == "Nobody":
        winner = team_1_name if times_per_team[team_1_name] < times_per_team[team_2_name] else team_2_name if times_per_team[team_2_name] < times_per_team[team_1_name] else "Nobody"
        print("    Using total time to determine winner")
        print("      - %s: %.5fs" % (team_1_name, times_per_team[team_1_name]))
        print("      - %s: %.5fs" % (team_2_name, times_per_team[team_2_name]))
        print()
    print("    => Winner: %s" % (winner))
    print()

    # Return needed info
    return times_per_team, games_won_per_team, winner

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

def print_separator ( width:      int = 80,
                      character:  str = "-",
                      line_after: bool = True
                    ) -> None:

    """
        Prints a separator line to the terminal.

        Args:
            width:      The width of the line.
            character:  The character to use for the line.
            line_after: Whether to print a line after the separator.
    """

    # Print the line
    print(character * width)
    if line_after:
        print()

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

def print_title ( title:     str,
                  width:     int = 80,
                  character: str = "="
                ) ->         None:
    
    """
        Prints a title to the terminal.
        
        Args:
            title:     The title to print.
            width:     The width of the title line.
            character: The character to use for the line.
    """

    # Print the title
    print_separator(width, character, False)
    title = " " + title + " "
    print(character * math.ceil((width - len(title)) / 2) + title + character * ((width - len(title)) // 2))
    print_separator(width, character, True)

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

def split_teams_in_groups () -> list[list[str]]:

    """
    Splits the teams in groups for the selection phase.
    The groups are saved in the selections results file.

    Returns:
        The groups of teams.
    """

    # Parse the teams from the players directory
    # No need to check if there are duplicates, as the teams are directories
    team_directories = os.listdir(PLAYERS_DIRECTORY)

    # If we are resuming a tournament, load the groups from the selection results (first line of the file)
    if os.path.exists(SELECTIONS_RESULTS):
        with open(SELECTIONS_RESULTS, "r") as selection_results_file:
            selection_groups = ast.literal_eval(selection_results_file.readline())

    # Otherwise, we create the groups
    else:

        # Create the selections directory if needed
        if not os.path.exists(SELECTIONS_DIRECTORY):
            os.makedirs(SELECTIONS_DIRECTORY)

        # Shuffle the teams
        random.shuffle(team_directories)

        # Create the groups
        selection_groups = [[] for _ in range(SELECTION_GROUPS)]
        for i_team, team in enumerate(team_directories):
            selection_groups[i_team % SELECTION_GROUPS].append(team)

        # Check that all players have distinct names
        player_files_per_name = {}
        for group in selection_groups:
            for team in group:
                players = load_players_from_team(os.path.join(PLAYERS_DIRECTORY, team), PlayerSkin.RAT)
                for player in players:
                    player_file_name = os.path.join(PLAYERS_DIRECTORY, team, "players", player.__class__.__name__ + ".py")
                    if player.get_name() not in player_files_per_name:
                        player_files_per_name[player.get_name()] = []
                    player_files_per_name[player.get_name()].append(player_file_name)
        error = False
        for player_name, player_files in player_files_per_name.items():
            if len(player_files) > 1:
                print("Error: player name '%s' is not unique. Found in:" % player_name)
                for player_file in player_files:
                    print("  -", player_file)
                error = True
        if error:
            raise ValueError("Player names are not unique")

        # Save the groups
        with open(SELECTIONS_RESULTS, "w") as selection_results_file:
            selection_results_file.write(str(selection_groups) + "\n")

    # Print the groups
    print_title("Groups")
    for i_group, group in enumerate(selection_groups):
        print("    Group %d:" % (i_group + 1))
        for team in group:
            print("      -", team)
        print()
    
    # Return the groups
    return selection_groups

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

def perform_selection_matches ( selection_groups: list[list[str]]
                              ) ->                list[list[str]]:

    """
    Performs the selection matches.
    The results are saved in the selections results file.

    Args:
        selection_groups: The groups of teams.

    Returns:
        The ranking per group.
    """
    
    # We perform matches per group
    ranking_per_group = []
    for i_group, group in enumerate(selection_groups):

        # Print the group
        print_title("Selections in group %d" % (i_group + 1))
        
        # We will attribute points depending on by how many games a team wins
        # Total time will be used to break ties
        points_per_team = {team: [0, 0.0] for team in group}

        # Make matches for all pairs of players
        for i_team_1, team_1_name in enumerate(group):
            for team_2_name in group[i_team_1+1:]:

                # Make the match
                extra_game_config = {"save_path": os.path.join(SELECTIONS_DIRECTORY, "group_ %d" % (i_group + 1), team_1_name + "___vs___" + team_2_name)}
                wins_per_team, times_per_team, winner = make_match(team_1_name, team_2_name, SELECTIONS_RESULTS, extra_game_config)
                
                # If there is a clear winner, we attribute points equal to the difference in wins + 1
                # If there is a tie, we attribute 1 point to the team with the least total time
                points = max(wins_per_team.values()) - min(wins_per_team.values()) + 1 if winner != "Nobody" else 1
                points_per_team[winner][0] += points
                points_per_team[winner][1] -= times_per_team[winner]

                # Print a separator
                if team_1_name != group[-2] or team_2_name != group[-1]:
                    print_separator()

        # Print the group results
        print_separator()
        print("    Group %d results:" % (i_group + 1))
        sorted_results = sorted(group, key=lambda team: points_per_team[team], reverse=True)
        for i_team in range(len(sorted_results)):
            print("      - %s: %d pts" % (sorted_results[i_team], points_per_team[sorted_results[i_team]][0]), end="")
            if i_team > 0 and points_per_team[sorted_results[i_team]][0] == points_per_team[sorted_results[i_team - 1]][0]:
                print(" (+%.5fs)" % -(points_per_team[sorted_results[i_team]][1] - points_per_team[sorted_results[i_team - 1]][1]), end="")
            print()
        ranking_per_group.append(sorted_results)
        print()

    # Return the ranking per group
    return ranking_per_group

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

def analyze_selection_results (ranking_per_group: list[list[str]]
                              ) ->                 list[str]:

    """
    Analyzes the selection results and determines the teams qualified for the tournament.
    The results are saved in the tournament results file.

    Args:
        ranking_per_group: The ranking per group.

    Returns:
        The teams qualified for the tournament.
    """

    # If we are resuming a tournament, load the teams from the tournament results (first line of the file)
    if os.path.exists(TOURNAMENT_RESULTS):
        with open(TOURNAMENT_RESULTS, "r") as tournament_results_file:
            tournament_teams = ast.literal_eval(tournament_results_file.readline())

    # Otherwise, we create the tournament teams
    else:

        # Create the tournament directory if needed
        if not os.path.exists(TOURNAMENT_DIRECTORY):
            os.makedirs(TOURNAMENT_DIRECTORY)

        # Shuffle the best team from each group
        tournament_teams = [team for ranking in [ranking[:KEEP_BEST] for ranking in ranking_per_group] for team in ranking]
        random.shuffle(tournament_teams)

        # Save the teams
        with open(TOURNAMENT_RESULTS, "w") as tournament_results_file:
            tournament_results_file.write(str(tournament_teams) + "\n")

    # Print the best teams
    print_title("Qualified teams")
    for team in tournament_teams:
        print("    ", team)
    print()
    
    # Return the teams
    return tournament_teams

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

def run_tournament ( tournament_teams: list[str]
                   ) ->                None:

    """
    Runs the tournament.
    The results are saved in the tournament results file.

    Args:
        tournament_teams: The teams qualified for the tournament.
    """

    # Start with the teams that were qualified
    if os.path.exists(TOURNAMENT_RESULTS):
        with open(TOURNAMENT_RESULTS, "r") as tournament_results_file:
            tournament_teams = ast.literal_eval(tournament_results_file.readline())

    # Play until there is only one team left
    nb_matches = int(math.log2(len(tournament_teams)))
    for round in range(nb_matches, 0, -1):

        # Print the list of teams still in the tournament
        print_title("1/%d finals" % (2 ** round) if round > 1 else "Finals")
        print("    Remaining teams:")
        for team in tournament_teams:
            print("      -", team)
        print()
        print_separator()

        # Initialize a list of winners
        winners = []

        # We make matches
        while tournament_teams:

            # Get players
            team_1_name = tournament_teams.pop(0)
            team_2_name = tournament_teams.pop(0)

            # Make the match
            extra_game_config = {"save_path": os.path.join(TOURNAMENT_DIRECTORY, team_1_name + "___vs___" + team_2_name)}
            wins_per_team, times_per_team, winner = make_match(team_1_name, team_2_name, TOURNAMENT_RESULTS, extra_game_config)

            # Add the winner to the list of winners
            winners.append(winner)

            # Print a separator
            if tournament_teams:
                print_separator()
        
        # Update the tournament teams
        tournament_teams = winners

#####################################################################################################################################################
####################################################################### SCRIPT ######################################################################
#####################################################################################################################################################

if __name__ == "__main__":

    # Selections
    selection_groups = split_teams_in_groups()
    ranking_per_group = perform_selection_matches(selection_groups)
    tournament_teams = analyze_selection_results(ranking_per_group)

    # Tournament
    run_tournament(tournament_teams)

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