import copy
import random
import pygame
from typing import List, Tuple
from pygame import mixer
from tetri5.util import ConfigurationManager
from tetri5.util import Controller

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from tetri5.game import Game

"""
    TODO description
"""
class Entity:
    
    def __init__(self, points: Tuple, color: str, border_color: str = None):
        self.points = points
        self.color = color
        self.border_color = border_color
        self.elapsed_time = 0

    def update(self, elapsed_time: int) -> None:
        self.elapsed_time += elapsed_time

    def draw(self, surface: pygame.Surface) -> None:
        tile_size = ConfigurationManager.get("engine", "tile-size")

        for square in self.points:
            pygame.draw.polygon(surface, pygame.Color(self.color), square, 0)
            if self.border_color:
                pygame.draw.polygon(surface, pygame.Color(self.border_color), square, max(tile_size // 6, 1))

    def collide(self, entity: "Entity") -> bool: # TODO figure out how to do type hint for entity param of type Entity
        for square_one in self.points:
            for square_two in entity.points:
                for i in range(4): # 4 vertices
                    if square_one[i][0] == square_two[i][0] and square_one[i][1] == square_two[i][1]:
                        return True 
        return False

    def _collide(self, points: List) -> bool:
        for square_one in self.points:
            for square_two in points:
                for i in range(4): # 4 vertices
                    if square_one[i][0] == square_two[i][0] and square_one[i][1] == square_two[i][1]:
                        return True 
        return False

"""
    TODO description
"""
class Well(Entity):
    
    WIDTH = 10 # standard tetris well width, should not be changed
    HEIGHT = 20 # standard tetris well height, should not be changed

    def __init__(self, position: Tuple, color: str, border_color: str):
        super().__init__(self._get_points(position), color, border_color)

    def _get_points(self, position: Tuple) -> List:
        tile_size = ConfigurationManager.get("engine", "tile-size")

        shape = []
        for i in range(self.WIDTH + 2):
            for j in range(self.HEIGHT + 2):
                if i in [0, self.WIDTH + 1]:
                    shape.append(((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)))
                elif j in [0, self.HEIGHT + 1]:
                    shape.append(((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)))

        points = []
        for square in shape:
            sub_points = []
            for vertex in square:
                point = [vertex[0] * tile_size + position[0], vertex[1] * tile_size + position[1]]
                sub_points.append(point)
            points.append(sub_points)

        return points

''' 
    For information on the Tetris piece Tetromino go here:
    https://tetris.fandom.com/wiki/Tetromino
'''
class Piece(Entity):

    def __init__(self, shape: Tuple, position: Tuple, color: str, inner_border_color: str, border_color: str):
        super().__init__(self._get_points(shape, position), color, border_color)

        self.inner_border_color = inner_border_color
        self.center = self._get_center(shape, position)
        self.piece_set_sound = mixer.Channel(2)
        self.previous_points = None
        self.previous_center = None

        # Gravity
        self.gravity_time = ConfigurationManager.get("engine", "piece-gravity-time")
        self.current_gravity_time = 0
        self.applying_gravity = True

        # Set
        self.set_time = ConfigurationManager.get("engine", "piece-set-time")
        self.current_set_time = 0
        self.applying_set = False

    def update(self, elapsed_time: int, game: "Game") -> None:
        super().update(elapsed_time)
        tile_size = ConfigurationManager.get("engine", "tile-size")

        if self.applying_gravity:
            self.applying_gravity = self._apply_gravity(elapsed_time, game.well, game.stack)
            self.applying_set = not self.applying_gravity

        """
            For more information on the piece set logic go here:
            https://strategywiki.org/wiki/Tetris/Features#Lock_delay
        """
        if self.applying_set:
            self.applying_set = self._apply_set(elapsed_time, game)
            self.applying_gravity = not self.applying_set

        # handle rotation, left and right movement
        if Controller.key_down(pygame.K_SPACE):
            self.rotate()
            if game.well and self.collide(game.well) or game.stack and self.collide(game.stack):     
                self.revert()
        if Controller.key_down(pygame.K_LEFT):
            self.move((-tile_size, 0))
            if game.well and self.collide(game.well) or game.stack and self.collide(game.stack):     
                self.revert()
        if Controller.key_down(pygame.K_RIGHT):
            self.move((tile_size, 0))
            if game.well and self.collide(game.well) or game.stack and self.collide(game.stack):     
                self.revert()

        # handle soft drop movement and gravity based on level
        gravity_time = ConfigurationManager.get("engine", "piece-gravity-time")
        set_time = ConfigurationManager.get("engine", "piece-set-time")
        gravity_increase = ConfigurationManager.get("engine", "piece-gravity-increase")
    
        if Controller.key_pressed(pygame.K_DOWN):
            self.gravity_time = max(10, (gravity_time - (game.get_level() * gravity_increase)) // 10)
            self.set_time = max(10, set_time // 10)
        if not Controller.key_pressed(pygame.K_DOWN): 
            self.gravity_time = gravity_time - (game.get_level() * gravity_increase)
            self.set_time = set_time

    def draw(self, surface: pygame.Surface, well: Well = None, stack: "Stack" = None) -> None:
        tile_size = ConfigurationManager.get("engine", "tile-size")

        # ghost piece
        if well and stack:
            for square in self._get_ghost_piece_points(well, stack):
                pygame.draw.polygon(surface, pygame.Color("#FFFFFF"), square, max(tile_size // 6, 1)) # TODO add white to the yaml

        super().draw(surface)

        # inner border piece
        for square in self.points:
            if self.inner_border_color:
                vertex_one = (square[0][0] + (tile_size // 10), square[0][1] + (tile_size // 10))
                vertex_two = (square[1][0] - (tile_size // 10), square[1][1] + (tile_size // 10))
                vertex_three = (square[2][0] - (tile_size // 10), square[2][1] - (tile_size // 10))
                vertex_four = (square[3][0] + (tile_size // 10), square[3][1] - (tile_size // 10))
                new_square = (vertex_one, vertex_two, vertex_three, vertex_four)

                pygame.draw.polygon(surface, pygame.Color(self.inner_border_color), new_square, max(tile_size // 6, 1))

    def move(self, vector: Tuple) -> None:
        self.previous_points = copy.deepcopy(self.points)
        self.previous_center = copy.deepcopy(self.center)

        self.center[0] += vector[0]
        self.center[1] += vector[1]

        for square in self.points:
            for vertex in square:
                vertex[0] += vector[0]
                vertex[1] += vector[1]
 
    '''
        For more information on a rotation of a piece go here:
        https://gamedev.stackexchange.com/questions/17974/how-to-rotate-blocks-in-tetris
    '''
    def rotate(self) -> None:
        self.previous_points = copy.deepcopy(self.points)
        self.previous_center = copy.deepcopy(self.center)

        new_points = []
        for square in self.points:
            for vertex in square:
                h = vertex[0] - self.center[0]
                k = vertex[1] - self.center[1]

                vertex[0] = (k * -1) + self.center[0]
                vertex[1] = h + self.center[1]
            new_points.append([square[-1]] + square[0:-1])
        self.points = new_points

    def revert(self) -> None:
        if self.previous_points and self.previous_center:
            self.points = self.previous_points
            self.center = self.previous_center

    def _get_points(self, shape: Tuple, position: Tuple) -> List:
        tile_size =  ConfigurationManager.get("engine", "tile-size")

        points = []
        for square in shape[:-1]:
            sub_points = []
            for vertex in square:
                point = [vertex[0] * tile_size + position[0], vertex[1] * tile_size + position[1]]
                sub_points.append(point)
            points.append(sub_points)

        return points

    def _get_center(self, shape: Tuple, position: Tuple) -> List:
        tile_size = ConfigurationManager.get("engine", "tile-size")
        center = shape[-1]

        # cast to int and avoid exception from pygame (center can be a floating point)
        return [int(center[0] * tile_size + position[0]), int(center[1] * tile_size + position[1])]

    def _apply_gravity(self, elapsed_time: int, well: Well, stack: "Stack") -> bool:
        tile_size = ConfigurationManager.get("engine", "tile-size")
        self.current_gravity_time += elapsed_time

        if self.current_gravity_time >= self.gravity_time:
            self.current_gravity_time = 0
            if not self._entity_is_below(well) and not self._entity_is_below(stack):
                self.move((0, tile_size))
            else:
                return False
        
        return True

    def _apply_set(self, elapsed_time: int, game: "Game") -> bool:
        self.current_set_time += elapsed_time

        if self.current_set_time >= self.set_time:
            self.current_set_time = 0
            if self._entity_is_below(game.well) or self._entity_is_below(game.stack):
                self._play_piece_set_sound()
                game.stack.add_piece(self) # TODO do on tetris object level?
                game.current_piece = None # TODO turn into a method
            else:
                return False

        return True

    def _mimic_move(self, vector: Tuple) -> List:
        mimic_points = copy.deepcopy(self.points)

        for square in mimic_points:
            for vertex in square:
                vertex[0] += vector[0]
                vertex[1] += vector[1]

        return mimic_points

    def _play_piece_set_sound(self) -> None:
        piece_set_sound_file = ConfigurationManager.get("sound", "piece-set")
        self.piece_set_sound.play(mixer.Sound(piece_set_sound_file))

    def _entity_is_below(self, entity: Entity) -> bool:
        tile_size = ConfigurationManager.get("engine", "tile-size")
        mimic_points = self._mimic_move((0, tile_size))
        return entity and entity._collide(mimic_points)

    def _get_ghost_piece_points(self, well: Well, stack: "Stack") -> List:
        tile_size = ConfigurationManager.get("engine", "tile-size")
        prior_points = []
        current_points = copy.deepcopy(self.points)

        while not well._collide(current_points) and not stack._collide(current_points):
            prior_points = copy.deepcopy(current_points)
            for square in current_points:
                for vertex in square:
                    vertex[1] += 1 * tile_size
            
        return prior_points
    
    # shape attributes
    I_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((2, 0), (3, 0), (3, 1), (2, 1)), ((3, 0), (4, 0), (4, 1), (3, 1)), (2, 0))
    J_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((2, 0), (3, 0), (3, 1), (2, 1)), ((2, 1), (3, 1), (3, 2), (2, 2)), (1.5, 0.5))
    L_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((2, 0), (3, 0), (3, 1), (2, 1)), ((0, 1), (1, 1), (1, 2), (0, 2)), (1.5, 0.5))
    O_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((0, 1), (1, 1), (1, 2), (0, 2)), (1, 1))
    S_SHAPE = (((0, 1), (1, 1), (1, 2), (0, 2)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((2, 0), (3, 0), (3, 1), (2, 1)), (1.5, 0.5))
    T_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((2, 0), (3, 0), (3, 1), (2, 1)), (1.5, 0.5))
    Z_SHAPE = (((0, 0), (1, 0), (1, 1), (0, 1)), ((1, 0), (2, 0), (2, 1), (1, 1)), ((1, 1), (2, 1), (2, 2), (1, 2)), ((2, 1), (3, 1), (3, 2), (2, 2)), (1.5, 0.5))

"""
    TODO description
"""
class Stack(Entity):

    def __init__(self, color: str, border_color: str):
        super().__init__([], color, border_color)
        self.lines_completed_count = 0
        self.line_completed_sound = mixer.Channel(1)

    def update(self, elapsed_time: int, game: "Game") -> None:
        super().update(elapsed_time)

        lines_completed = self._complete_rows()
        current_level = game.get_level()
        points_per_lines_completed = ConfigurationManager.get("engine", "points-per-lines-completed")

        game.score += points_per_lines_completed[lines_completed] * (current_level + 1)
        self.lines_completed_count += lines_completed

    def add_piece(self, piece: Piece) -> None:
        self.points += piece.points

    # TODO refactor into multiple functions
    def _complete_rows(self) -> int:
        squares_by_row = {}
        for square in self.points:
            top_left_vertex = square[0]
            if top_left_vertex[1] not in squares_by_row:
                squares_by_row[top_left_vertex[1]] = []
            if square not in squares_by_row[top_left_vertex[1]]:
                squares_by_row[top_left_vertex[1]].append(square)

        squares_to_exclude = []
        rows_completed = []
        for key in squares_by_row:
            if len(squares_by_row[key]) == Well.WIDTH:
                squares_to_exclude += squares_by_row[key]
                rows_completed.append(key)

        if len(squares_to_exclude) == 0:
            return 0

        self._play_line_completed_sound()

        tile_size = ConfigurationManager.get("engine", "tile-size")
        new_points = []
        for square in self.points:
            if square not in squares_to_exclude:
                for vertex in square:
                    distance_to_move = 0
                    for row_completed in rows_completed:
                        if vertex[1] <= row_completed:
                            distance_to_move += 1
                    vertex[1] += tile_size * distance_to_move
                new_points.append(square)
        self.points = new_points

        return len(rows_completed)

    def _play_line_completed_sound(self) -> None:
        line_completed_sound_file = ConfigurationManager.get("sound", "row-completion")
        self.line_completed_sound.play(mixer.Sound(line_completed_sound_file))

"""
    TODO description
"""
class PieceGenerator:
    
    _bucket = []

    @classmethod
    def get_piece(cls, position: Tuple) -> Piece:
        if len(cls._bucket) == 0:
            cls._generate_bucket()

        base_color, inner_border_color, outer_border_color = cls._get_piece_color()
        return Piece(cls._get_piece_shape(cls._bucket.pop()), position, base_color, inner_border_color, outer_border_color)

    @classmethod
    def _generate_bucket(cls) -> None:
        piece_types = list(range(7))
        while len(cls._bucket) != 7:
            random_number = random.randint(0, 6 - len(cls._bucket))
            cls._bucket.append(piece_types.pop(random_number))

    def _get_piece_shape(piece_number: int) -> Tuple:
        if piece_number == 0:
            return Piece.I_SHAPE
        if piece_number == 1:
            return Piece.J_SHAPE
        if piece_number == 2:
            return Piece.L_SHAPE
        if piece_number == 3:
            return Piece.O_SHAPE
        if piece_number == 4:
            return Piece.S_SHAPE
        if piece_number == 5:
            return Piece.T_SHAPE
        if piece_number == 6:
            return Piece.Z_SHAPE

        return None

    def _get_piece_color() -> Tuple:
        random_number = random.randint(1, 3)

        base_color = ConfigurationManager.get("color", "piece-" + str(random_number))
        inner_border_color = None if random_number != 3 else ConfigurationManager.get("color", "piece-inner-border-1")
        outer_border_color = ConfigurationManager.get("color", "piece-outer-border-1")

        return (base_color, inner_border_color, outer_border_color)