"""This module contains a class for representing the gameboard."""
from random import randint
from collections import namedtuple
import json
import pandas as pd
from .constants import Constants as C
from .gameboard_delegate import GameBoardDelegate
from .combiners import combine_left, combine_right
from .combiners import combine_left_mapped, combine_right_mapped


# a named tuple for the history points of a gameboard
GameBoardSnapshot = namedtuple('GameBoardSnapshot',
                               'board_bits board_json score move')


class GameBoard(object):
    """
    This class models a 2048 game board with a table of tiles holding numbers.

    The board is modeled by a 64 bit integer and all tile, row, column lookups
    are performed using bit-shift operations.
    """

    # the board to put the tiles on in row-major ordering this means the first
    #  index refers to the row and the second index to the column.
    _board = 0

    # the score of the game
    _score = 0

    # the history of metadata throughout the game
    _history = []

    # the delegate to send events to
    _delegate = None

    def __init__(self, delegate=None):
        """
        Create a new gameboard.

        - parameters:
          - delegate: {GameBoardEventHandler} the delegate to pass events to.
        """
        # reset the board (i.e. score and board set to 0)
        self.reset()
        # assign the delegate
        if delegate and not isinstance(delegate, GameBoardDelegate):
            msg = 'delegate must be of type None or GameBoardDelegate'
            raise ValueError(msg)
        self._delegate = delegate

    @property
    def copy(self):
        """Return a copy of this object."""
        copy = GameBoard()
        # copy the board from the other board, use the private setters, no
        # need to run the verification logic as the board should always be
        # in a valid state when copy is called and copy should be fast.
        copy._board = self.board
        # copy the score
        copy._score = self.score
        # setup the move map
        # copy._move_map = self._move_map
        return copy

    def reset(self):
        """Reset the board and score."""
        # reset the score
        self._score = 0
        # create the tile objects by iterating over the array
        self._board = 0
        # restore the history
        self._history = []

    @property
    def history(self):
        """Return the history of this gameboard."""
        return self._history

    @property
    def data_frame(self):
        """
        Return the history as a `pandas` DataFrame.

        - returns: {pandas.DataFrame} the history as a data frame
        """
        columns = ['64-bit board', 'JSON board', 'score', 'move']
        return pd.DataFrame.from_records(self._history, columns=columns)

    @property
    def board(self):
        """Return the board encoded as a 64 bit unsigned int."""
        return self._board

    @property
    def board_json(self):
        """Return the board encoded into json."""
        rows = []
        for column in range(0, C.columns()):
            rows.append(self.get_row_array(column))
        return json.dumps({'board': rows})

    @board.setter
    def board(self, new_board):
        """
        Set the board to a new value.

        - parameters:
            - new_board: {long} the new board to set
        """
        self._board = new_board

    @property
    def board_array(self):
        """Return the board as a 2D array of bytes."""
        board = []
        for row in range(0, C.rows()):
            # add a column list to the array
            board.append([])
            for column in range(0, C.columns()):
                # append the tile in the column to the array
                board[row].append(self.get_tile(row, column))
        return board

    @property
    def score(self):
        """Return the current score of the game."""
        return self._score

    @score.setter
    def score(self, new_score):
        """
        Set the score to a new value.

        - parameters:
            - new_score: {number} the new score for the game, must be positive.
        """
        if new_score < 0:
            raise ValueError('scores cannot be negative')
        self._score = new_score

    def is_equal(self, other):
        """
        Return a boolean determining the equality of this board to the given.

        - parameters:
            - other: {GameBoard} the gameboard to compare against
        - returns: {boolean} true if the boards are equal, false otherwise
        """
        # if the boards are the same, there was no change
        if self.board == other.board and self.score == other.score:
            return False
        return True

    def get_tile(self, row, column):
        """
        Get a tile at a given row column index.

        - parameters:
            - row: {int} the row to get the tile from
            - column: {int} the column to get the tile from
        - return: {int} the tile as a nibble
        """
        # verify the bound of the row
        if row < 0 or row >= C.rows():
            msg = 'row must be in [0, %i)'
            raise ValueError(msg % C.rows())
        # verify the bounds of the column
        if column < 0 or column >= C.columns():
            msg = 'column must be in [0, %i)'
            raise ValueError(msg % C.columns())
        # get the tiles unwrapped index
        index = (row * C.rows()) + column
        return self.get_tile_at(index)

    def get_tile_at(self, index):
        """
        Get the tile at the given index in the bitmap.

        - parameters:
            - index: {number} the index of the tile to get
        - returns: {int} a nibble representing a single tiles exponent value
        """
        # verify the bound of the row
        if index < 0 or index >= C.tiles():
            msg = 'index must be in [0, %s)'
            raise ValueError(msg % C.tiles())
        # get the offset based on index
        row_offset = (C.rows() * index)
        offset = C.board_size() - C.tile_size() - row_offset
        # shift the board and mask it to get the value
        return (self._board >> offset) & C.tile_value_mask()

    def place_tile(self, row, column, value):
        """
        Place a tile at the given row column index with the given value.

        - parameters:
            - row: {int} the index of the row to place at
            - column: {int} the index of the column to place at
            - value: {int} the value to place in [0, 15]
        """
        # verify the bounds of the row
        if row < 0 or row >= C.rows():
            msg = 'row must be in [0, %i)'
            raise ValueError(msg % C.columns())
        # verify the bounds of the column
        if column < 0 or column >= C.columns():
            msg = 'column must be in [0, %i)'
            raise ValueError(msg % C.columns())
        # get the index
        index = (row * C.rows()) + column
        # place the tile
        self.place_tile_at(index, value)

    def place_tile_at(self, index, value):
        """
        Place a new tile at the unwrapped index.

        - parameters:
            - index: {int} the index to place the value at
            - value: {long} the value to place in the tile
        """
        # verify the bound of the index
        if index < 0 or index >= C.tiles():
            msg = 'tile must be in [0, %i)'
            raise ValueError(msg % C.tiles())
        if value < 0 or value > 15:
            raise ValueError('value must be in [0, 15]')
        # calculate the offset
        offset = C.rows() * index
        # shift the zero to delete the old value
        self._board = self._board & ~(C.tile_mask() >> offset)
        # zero out garbage and shift value for placement, then add the new
        # value to the board
        mask = C.board_size() - C.tile_size() - offset
        masked_value = ((value & C.tile_value_mask()) << mask)
        self._board = self._board + masked_value

    def place_random_tile(self):
        """Randomly place a new tile on the board."""
        # make sure there is a place for the tile
        if len(self.active_tiles) is C.tiles():
            return
        # get a list of the inactive tiles
        inactive_tiles = self.inactive_tiles
        value = 1  # 2 ^ 1 = 2