#!/usr/bin/env python2


import argparse
from argparse import RawTextHelpFormatter
import ast
import copy
from importlib import import_module
import inspect
import pkg_resources
import random
import os
import sys
import time

try:
    import_module('rgkit')
except ImportError:
    # force rgkit to appear as a module when run from current directory
    from os.path import dirname, abspath
    cdir = dirname(abspath(inspect.getfile(inspect.currentframe())))
    parentdir = dirname(cdir)
    sys.path.insert(0, parentdir)

from rgkit.settings import settings as default_settings
from rgkit import game
from rgkit.game import Player


class Options(object):
    def __init__(self, map_filepath=None, headless=False, print_info=False,
                 animate_render=False, play_in_thread=False, curses=False,
                 game_seed=None, match_seeds=None, quiet=0, symmetric=True,
                 n_of_games=1, start=0):

        if map_filepath is None:
            map_filepath = os.path.join(os.path.dirname(__file__),
                                        'maps/default.py')
        self.animate_render = animate_render
        self.curses = curses
        self.game_seed = game_seed
        self.headless = headless
        self.map_filepath = map_filepath
        self.match_seeds = match_seeds
        self.n_of_games = n_of_games
        self.play_in_thread = play_in_thread
        self.print_info = print_info
        self.quiet = quiet
        self.start = start
        self.symmetric = symmetric

    def __eq__(self, other):
        return (self.animate_render == other.animate_render and
                self.curses == other.curses and
                self.game_seed == other.game_seed and
                self.headless == other.headless and
                self.map_filepath == other.map_filepath and
                self.match_seeds == other.match_seeds and
                self.n_of_games == other.n_of_games and
                self.play_in_thread == other.play_in_thread and
                self.print_info == other.print_info and
                self.quiet == other.quiet and
                self.start == other.start and
                self.symmetric == other.symmetric)


class Runner(object):
    def __init__(self, players=None, player_files=None, settings=None,
                 options=None, delta_callback=None):

        if settings is None:
            settings = Runner.default_settings()
        if options is None:
            options = Options()
        if players is None:
            players = []

        self._map_data = ast.literal_eval(open(options.map_filepath).read())
        self.settings = settings
        self.settings.init_map(self._map_data)
        # Players can only be initialized from file after initializing settings
        if player_files is not None:
            for player_file in player_files:
                players.append(self._make_player(player_file))
        self._players = players
        self._delta_callback = delta_callback
        self._names = []
        for player in players:
            self._names.append(player.name())
        self.options = options

        if Runner.is_multiprocessing_supported():
            import multiprocessing
            self._rgcurses_lock = multiprocessing.Lock()
        else:
            self._rgcurses_lock = None

    @staticmethod
    def from_robots(robots, settings=None, options=None,
                    delta_callback=None):

        players = []
        for robot in robots:
            players.append(Player(robot=robot))

        return Runner(players,
                      settings=settings, options=options,
                      delta_callback=delta_callback)

    @staticmethod
    def from_command_line_args(args):
        map_name = os.path.join(args.map)

        options = Options(animate_render=args.animate,
                          curses=args.curses,
                          game_seed=args.game_seed,
                          headless=args.headless,
                          map_filepath=map_name,
                          match_seeds=args.match_seeds,
                          n_of_games=args.count,
                          play_in_thread=args.play_in_thread,
                          print_info=not args.headless and args.quiet <= 2,
                          quiet=args.quiet,
                          start=args.start,
                          symmetric=not args.random)
        # TODO: generalize to N player files
        player_files = [args.player1, args.player2]
        return Runner(player_files=player_files, options=options)

    @staticmethod
    def _make_player(file_name):
        try:
            return game.Player(file_name=file_name)
        except IOError as msg:
            if pkg_resources.resource_exists('rgkit', file_name):
                bot_filename = pkg_resources.resource_filename('rgkit',
                                                               file_name)
                return game.Player(file_name=bot_filename)
            raise IOError(msg)

    @staticmethod
    def default_map():
        map_path = os.path.join(os.path.dirname(__file__), 'maps/default.py')
        return map_path

    @staticmethod
    def default_settings():
        return default_settings

    def game(self, record_turns=False, unit_testing=False):
        return game.Game(self._players, record_turns=record_turns,
                         unit_testing=unit_testing)

    def run(self):
        scores = []
        printed = []
        for i in range(self.options.start,
                       self.options.start + self.options.n_of_games):
            # A sequential, deterministic seed is used for each match that can
            # be overridden by user provided ones.
            match_seed = str(self.options.game_seed) + '-' + str(i)
            if self.options.match_seeds and i < len(self.options.match_seeds):
                match_seed = self.options.match_seeds[i]
            for player in self._players:
                player.load()
            result = self.play(match_seed)
            scores.append(result)
            printed.append('{0} - seed: {1}'.format(result, match_seed))
        if self.options.quiet < 4:
            unmute_all()
            if printed:
                print('\n'.join(printed))
        return scores

    def play(self, match_seed):
        if self.options.play_in_thread:
            g = game.ThreadedGame(self._players,
                                  print_info=self.options.print_info,
                                  record_actions=not self.options.headless,
                                  record_history=True,
                                  seed=match_seed,
                                  quiet=self.options.quiet,
                                  delta_callback=self._delta_callback,
                                  symmetric=self.options.symmetric)
        else:
            g = game.Game(self._players,
                          print_info=self.options.print_info,
                          record_actions=not self.options.headless,
                          record_history=True,
                          seed=match_seed,
                          quiet=self.options.quiet,
                          delta_callback=self._delta_callback,
                          symmetric=self.options.symmetric)

        if not self.options.headless and not self.options.curses:
            # only import render if we need to render the game;
            # this way, people who don't have tkinter can still
            # run headless
            from rgkit.render import render

        g.run_all_turns()

        if not self.options.headless and not self.options.curses:
            # print "rendering %s animations" % ("with"
            #                                    if animate_render
            #                                    else "without")
            render.Render(g, self.options.animate_render, names=self._names)

        # TODO: Displaying multiple games using curses is still a little bit
        # buggy but at least it doesn't completely screw up the state of the
        # terminal anymore.  The plan is to show each game sequentially.
        # Concurrency in run.py needs some more work before the bugs can be
        # fixed. Need to make sure nothing is printing when curses is running.
        if not self.options.headless and self.options.curses:
            from rgkit import rgcurses
            rgc = rgcurses.RGCurses(g, self._names)
            if self._rgcurses_lock:
                self._rgcurses_lock.acquire()
            rgc.run()
            if self._rgcurses_lock:
                self._rgcurses_lock.release()

        return g.get_scores()

    @staticmethod
    def is_multiprocessing_supported():
        is_multiprocessing_supported = True
        try:
            import_module('multiprocessing')
        except ImportError:
            # the OS does not support it. See http://bugs.python.org/issue3770
            is_multiprocessing_supported = False

        return is_multiprocessing_supported


def run_single_from_command_line(args):
    return Runner.from_command_line_args(args).run()


def run_concurrently(args):
    import multiprocessing
    num_cpu = multiprocessing.cpu_count()
    (games_per_cpu, remainder) = divmod(args.count, num_cpu)
    data = []
    start = 0

    for _ in range(num_cpu):
        copy_args = copy.deepcopy(args)

        copy_args.start = start
        copy_args.count = games_per_cpu
        start += games_per_cpu
        # Distribute remainder of games evenly among CPUs
        if remainder > 0:
            copy_args.count += 1
            start += 1
            remainder -= 1

        data.append(copy_args)

    pool = multiprocessing.Pool(num_cpu)
    results = pool.map(run_single_from_command_line, data)
    return [score for scores in results for score in scores]


def get_arg_parser():
    parser = argparse.ArgumentParser(
        description="Robot game execution script.",
        formatter_class=RawTextHelpFormatter)
    parser.add_argument("player1",
                        help="File containing first robot class definition.")
    parser.add_argument("opponents", nargs="+",
                        help="File(s) containing opponent robots.")
    default_map = pkg_resources.resource_filename('rgkit', 'maps/default.py')
    parser.add_argument("-m", "--map",
                        help="User-specified map file.",
                        default=default_map)
    parser.add_argument("-c", "--count", type=int,
                        default=1,
                        help="Game count, default: 1, multithreading if >1")
    parser.add_argument("-A", "--animate", action="store_true",
                        default=False,
                        help="Enable animations in rendering.")
    parser.add_argument(
        "-q", "--quiet", action="count", default=0, help="""Quiet execution.
-q : suppresses bot stdout
-qq: suppresses bot stdout and stderr
-qqq: supresses all rgkit and bot output
-qqqq: final summary only""")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("-H", "--headless", action="store_true",
                       default=False,
                       help="Disable rendering game output.")
    group.add_argument("-T", "--play-in-thread", action="store_true",
                       default=False,
                       help="Separate GUI thread from robot move calculations."
                       )
    group.add_argument("-C", "--curses", action="store_true",
                       default=False,
                       help="Display game in command line using curses.")
    parser.add_argument("--game-seed",
                        default=random.randint(0, default_settings.max_seed),
                        help="Appended with game countfor per-match seeds.")
    parser.add_argument(
        "--match-seeds", nargs='*',
        help="Used for random seed of the first matches in order.")
    parser.add_argument("-r", "--random", action="store_true",
                        default=False,
                        help="Bots spawn randomly instead of symmetrically.")
    parser.add_argument("-M", "--heatmap", action="store_true",
                        default=False,
                        help="Print heatmap after playing a number of games.")
    parser.add_argument("-s", "--start", type=int, default=0,
                        help="Starting index of matches, useful for resuming.")
    try:
        os.nice(0)
        parser.add_argument("--nice", type=int, default=5,
                            help="Value for os.nice to lower runner priority.")
    except:
        # Not available on this platform, no need to add the option.
        pass

    return parser


def mute_all():
    sys.stdout = game.NullDevice()
    # sys.stderr = game.NullDevice()


def unmute_all():
    sys.stdout = sys.__stdout__
    # sys.stderr = sys.__stderr__


def print_score_grid(scores, player1, player2, size):
    max_score = 50

    def to_grid(n):
        return int(round(float(n) / max_score * (size - 1)))

    def print_heat(n):
        if n > 9:
            sys.stdout.write(" +")
        else:
            sys.stdout.write(" " + str(n))

    grid = [[0 for c in range(size)] for r in range(size)]

    for s1, s2 in scores:
        grid[to_grid(s1)][to_grid(s2)] += 1

    p1won = sum(p1 > p2 for p1, p2 in scores)
    str1 = player1 + " : " + str(p1won)
    if len(str1) + 2 <= 2 * size - len(str1):
        str1 = " " + str1 + " "
        print("*" + str1 + "-" * (2 * size - len(str1)) + "*")
    else:
        print(str1)
        print("*" + "-" * (2 * size) + "*")

    for r in range(size - 1, -1, -1):
        sys.stdout.write("|")
        for c in range(size):
            if grid[r][c] == 0:
                if r == c:
                    sys.stdout.write(". ")
                else:
                    sys.stdout.write("  ")
            else:
                print_heat(grid[r][c])
        sys.stdout.write("|\n")

    p2won = sum(p2 > p1 for p1, p2 in scores)
    str2 = player2 + " : " + str(p2won)
    if len(str2) + 2 <= 2 * size - len(str2):
        str2 = " " + str2 + " "
        print("*" + "-" * (2 * size - len(str2)) + str2 + "*")
    else:
        print("*" + "-" * (2 * size) + "*")
        print(str2)


def main():
    args = get_arg_parser().parse_args()

    if "nice" in args:
        os.nice(args.nice)

    num_opponents = len(args.opponents)
    total_won, total_lost, total_draw, total_avg_score, total_diff = (
        0, 0, 0, (0, 0), 0)
    for opponent in args.opponents:
        args.player2 = opponent
        if args.quiet >= 3:
            mute_all()
        print('Game seed: {0}'.format(args.game_seed))
        if Runner.is_multiprocessing_supported() and args.count > 1:
            runner = run_concurrently
        else:
            runner = run_single_from_command_line

        start_time = time.time()
        scores = runner(args)
        total_time = time.time() - start_time

        print('{0:6.2f}s per game, {1} games, total {2:.0f}s'.format(
            total_time / args.count, args.count, total_time))
        if args.quiet >= 3:
            unmute_all()
        p1won = sum(p1 > p2 for p1, p2 in scores)
        p2won = sum(p2 > p1 for p1, p2 in scores)
        draw = args.count - p1won - p2won
        avg_score = [float(sum(x))/len(x) for x in zip(*scores)]
        diff = avg_score[0] - avg_score[1]
        if args.heatmap:
            print_score_grid(scores, args.player1, args.player2, 26)
        total_won += p1won
        total_lost += p2won
        total_draw += draw
        total_avg_score = (total_avg_score[0] + avg_score[0],
                           total_avg_score[1] + avg_score[1])
        total_diff += diff
        avg_score = list(map(int, avg_score))
        diff = int(diff)
        print('{:10} - {:15} - {:8} ({})'.format(
            os.path.basename(opponent)[:10], repr([p1won, p2won, draw]),
            repr(avg_score), diff))

    if num_opponents > 1:
        total_avg_score = list(map(int, (total_avg_score[0] / num_opponents,
                                         total_avg_score[1] / num_opponents)))
        total_diff = int(total_diff / num_opponents)
        win_rate = (100 * float(total_won + 0.5 * total_draw) /
                    (total_won + total_lost + total_draw))
        print('Overall: [{}, {}, {}] WR: {:<5.1f} Score: {} ({})'.format(
            total_won, total_lost, total_draw, win_rate, total_avg_score,
            total_diff))


if __name__ == '__main__':
    main()
