#!/usr/bin/env python3

"""
File Name:      my_agent.py
Authors:        TODO: Your names here!
Date:           TODO: The date you finally started working on this.

Description:    Python file for my agent.
Source:         Adapted from recon-chess (https://pypi.org/project/reconchess/)
"""

import random, numpy, chess
from player import Player


# TODO: Rename this class to what you would like your bot to be named during the game.
class BottyFischer(Player):

    def __init__(self):
        self.board = None
        self.color = None
        pass

    def handle_game_start(self, color, board):
        """
        This function is called at the start of the game.

        :param color: chess.BLACK or chess.WHITE -- your color assignment for the game
        :param board: chess.Board -- initial board state
        :return:
        """
        # TODO: implement this method
        self.board = board
        self.color = color

    def handle_opponent_move_result(self, captured_piece, captured_square):
        """
        This function is called at the start of your turn and gives you the chance to update your board.

        :param captured_piece: bool - true if your opponents captured your piece with their last move
        :param captured_square: chess.Square - position where your piece was captured
        """
        pass

    def choose_sense(self, possible_sense, possible_moves, seconds_left):
        """
        This function is called to choose a square to perform a sense on.

        :param possible_sense: List(chess.SQUARES) -- list of squares to sense around
        :param possible_moves: List(chess.Moves) -- list of acceptable moves based on current board
        :param seconds_left: float -- seconds left in the game

        :return: chess.SQUARE -- the center of 3x3 section of the board you want to sense
        :example: choice = chess.A1
        """
        # TODO: update this method
        # return AgentReward.choose_best_sense(possible_sense)
        return AgentReward.choose_best_sense(possible_sense)

    def handle_sense_result(self, sense_result):
        """
        This is a function called after your picked your 3x3 square to sense and gives you the chance to update your
        board.

        :param sense_result: A list of tuples, where each tuple contains a :class:`Square` in the sense, and if there
                             was a piece on the square, then the corresponding :class:`chess.Piece`, otherwise `None`.
        :example:
        [
            (A8, Piece(ROOK, BLACK)), (B8, Piece(KNIGHT, BLACK)), (C8, Piece(BISHOP, BLACK)),
            (A7, Piece(PAWN, BLACK)), (B7, Piece(PAWN, BLACK)), (C7, Piece(PAWN, BLACK)),
            (A6, None), (B6, None), (C8, None)
        ]
        """
        # TODO: implement this method
        # Hint: until this method is implemented, any senses you make will be lost.
        AgentReward.update_sense_table(sense_result, self.color)
        pass

    def choose_move(self, possible_moves, seconds_left):
        """
        Choose a move to enact from a list of possible moves.

        :param possible_moves: List(chess.Moves) -- list of acceptable moves based only on pieces
        :param seconds_left: float -- seconds left to make a move

        :return: chess.Move -- object that includes the square you're moving from to the square you're moving to
        :example: choice = chess.Move(chess.F2, chess.F4)

        :condition: If you intend to move a pawn for promotion other than Queen, please specify the promotion parameter
        :example: choice = chess.Move(chess.G7, chess.G8, promotion=chess.KNIGHT) *default is Queen
        """
        # TODO: update this method

        move = AgentReward.get_best_move(3, self.board, True, possible_moves)
        move = chess.Move.from_uci(str(move))
        self.board.push(move)
        if seconds_left <= 10:
            move = random.choice(possible_moves)
        return move

    def handle_move_result(self, requested_move, taken_move, reason, captured_piece, captured_square):
        """
        This is a function called at the end of your turn/after your move was made and gives you the chance to update
        your board.

        :param requested_move: chess.Move -- the move you intended to make
        :param taken_move: chess.Move -- the move that was actually made
        :param reason: String -- description of the result from trying to make requested_move
        :param captured_piece: bool - true if you captured your opponents piece
        :param captured_square: chess.Square - position where you captured the piece
        """
        # TODO: implement this method
        if taken_move:
            self.board.push(taken_move)
        else:
            chess.Move.null()
        pass

    def handle_game_end(self, winner_color, win_reason):  # possible GameHistory object...
        """
        This function is called at the end of the game to declare a winner.

        :param winner_color: Chess.BLACK/chess.WHITE -- the winning color
        :param win_reason: String -- the reason for the game ending
        """
        # TODO: implement this method
        pass


class AgentReward:

    # Update this table to get best sense
    SENSE_TABLE = numpy.array([
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0]
    ])

    # sense
    SENSE = 0

    # Remove edges from possible sense
    @staticmethod
    def choose_best_sense(possible_sense):
        for sense in possible_sense:
            if sense < 8 or sense > 55 or sense % 8 == 0 or (sense + 1) % 8 == 0:
                possible_sense.remove(sense)

        sense = AgentReward.pick_sense()
        return sense

    # Check sense table for maximum total weight in sense table
    @staticmethod
    def pick_sense():
        global_max = -1
        for row in range(1, 7):
            local_max = 0
            for col in range(1, 7):
                local_max += AgentReward.SENSE_TABLE[row][col]
                local_max += AgentReward.SENSE_TABLE[row + 1][col]
                local_max += AgentReward.SENSE_TABLE[row - 1][col]
                local_max += AgentReward.SENSE_TABLE[row][col - 1]
                local_max += AgentReward.SENSE_TABLE[row][col + 1]
                local_max += AgentReward.SENSE_TABLE[row - 1][col - 1]
                local_max += AgentReward.SENSE_TABLE[row - 1][col + 1]
                local_max += AgentReward.SENSE_TABLE[row + 1][col - 1]
                local_max += AgentReward.SENSE_TABLE[row + 1][col + 1]

                if local_max > global_max:
                    global_max = local_max
                    AgentReward.SENSE = (8 - (row + 1)) * 8 + col
        return AgentReward.SENSE

    # Update the sense table by incrementing each value by a certain weight threshold corresponding to piece values
    @staticmethod
    def update_sense_table(sense_result, color):
        for sense in sense_result:
            square = sense[0]

            row = abs(((square + 1) // 8) - 7)
            col = square % 8

            weight = 0
            if sense[1] is not None:

                if sense[1].symbol() == 'P':
                    if color == chess.WHITE:
                        weight -= 10
                    else:
                        weight += 10
                if sense[1].symbol() == 'p':
                    if color == chess.BLACK:
                        weight -= 10
                    else:
                        weight += 10

                if sense[1].symbol() == 'N':
                    if color == chess.WHITE:
                        weight -= 30
                    else:
                        weight += 30
                if sense[1].symbol() == 'n':
                    if color == chess.BLACK:
                        weight -= 30
                    else:
                        weight += 30

                if sense[1].symbol() == 'B':
                    if color == chess.WHITE:
                        weight -= 30
                    else:
                        weight += 30
                if sense[1].symbol() == 'b':
                    if color == chess.BLACK:
                        weight -= 30
                    else:
                        weight += 30

                if sense[1].symbol() == 'R':
                    if color == chess.WHITE:
                        weight -= 50
                    else:
                        weight += 50
                if sense[1].symbol() == 'r':
                    if color == chess.BLACK:
                        weight -= 50
                    else:
                        weight += 50

                if sense[1].symbol() == 'Q':
                    if color == chess.WHITE:
                        weight -= 900
                    else:
                        weight += 900
                if sense[1].symbol() == 'q':
                    if color == chess.BLACK:
                        weight -= 900
                    else:
                        weight += 900

                if sense[1].symbol() == 'K':
                    if color == chess.WHITE:
                        weight -= 90
                    else:
                        weight += 90
                if sense[1].symbol() == 'k':
                    if color == chess.BLACK:
                        weight -= 90
                    else:
                        weight += 90

            AgentReward.SENSE_TABLE[row][col] += weight

    # Evaluate the board as part of minimax solution
    @staticmethod
    def evaluate_board(board):
        total_eval = 0
        color_check = True
        try:
            color_check = bool(board.piece_at(0).color)
        except AttributeError as error:
            color_check = color_check
        for i in range(63):
            score = AgentReward.get_reward_val(str(board.piece_at(i)))
            if color_check:
                total_eval += score
            else:
                total_eval += -score

        return total_eval

    # Reward function based on Chess programming on Wiki
    @staticmethod
    def get_reward_val(piece):
        reward = 0

        if piece is None:
            return reward

        if piece == "P" or piece == "p":
            reward = 10
        if piece == "N" or piece == "n":
            reward = 30
        if piece == "B" or piece == "b":
            reward = 30
        if piece == "R" or piece == "r":
            reward = 50
        if piece == "Q" or piece == "q":
            reward = 90
        if piece == 'K' or piece == 'k':
            reward = 900

        return reward

    # Find best move to take given some depth
    @staticmethod
    def get_best_move(depth, board, is_maximizing, possible_moves):
        return AgentReward.mini_max(depth, board, is_maximizing, possible_moves, -float("inf"), float("inf"))


    @staticmethod
    def mini_max(depth, board, is_maximizing, possible_moves, alpha, beta):
        best_move = -float("inf")

        move_choice = None
        for m in possible_moves:
            move = chess.Move.from_uci(str(m))
            board.push(move)
            value = max(best_move,
                        AgentReward.mini_max_helper(depth - 1, board, not is_maximizing, possible_moves, alpha, beta))
            board.pop()
            if value > best_move:

                best_move = value
                move_choice = move

        return move_choice

    # Helper minimax function
    @staticmethod
    def mini_max_helper(depth, board, is_maximizing, possible_moves, alpha, beta):

        if depth == 0:
            return -AgentReward.evaluate_board(board)

        if is_maximizing:
            best_move = -float("inf")
            for move in possible_moves:
                best_choice = chess.Move.from_uci(str(move))
                board.push(best_choice)
                best_move = max(best_move,
                                AgentReward.mini_max_helper(depth - 1, board, not is_maximizing, possible_moves,
                                                            alpha, beta))
                board.pop()
                alpha = max(alpha, best_move)
                if beta <= alpha:
                    return best_move
        else:
            best_move = float("inf")
            for move in possible_moves:
                best_choice = chess.Move.from_uci(str(move))
                board.push(best_choice)
                best_move = min(best_move,
                                AgentReward.mini_max_helper(depth - 1, board, not is_maximizing, possible_moves,
                                                            alpha, beta))
                board.pop()
                beta = min(beta, best_move)
                if beta <= alpha:
                    return best_move
        return best_move
