import copy
import random
import pygame
from typing import List, Tuple
from types import FunctionType
from tetri5.util import ConfigurationManager
from tetri5.util import Controller
from tetri5.util import SoundManager

"""
    TODO description
"""
class Entity:
    
    def __init__(self, points: Tuple, base_color: str, outer_color: str = None):
        self._tile_size = ConfigurationManager.get("engine", "tile-size")
        self._points = points
        self._base_color = base_color
        self._outer_color = outer_color
        self._elapsed_time = 0 # TODO does nothing right now

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

    def draw(self, surface: pygame.Surface) -> None:
        for square in self._points:
            if self._base_color is not None:
                square_color = pygame.Color(self._base_color)
                pygame.draw.polygon(surface, square_color, square, 0)
            if self._outer_color is not None:
                pygame.draw.polygon(surface, pygame.Color(self._outer_color), square, max(self._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, base_color: str, outer_color: str):
        super().__init__(Well._get_points(position), base_color, outer_color)

    @classmethod
    def _get_points(cls, position: Tuple) -> List:
        tile_size = ConfigurationManager.get("engine", "tile-size")
        shape = []
        for i in range(cls.WIDTH + 2):
            for j in range(cls.HEIGHT + 2):
                if i in [0, cls.WIDTH + 1]:
                    shape.append(((i, j), (i + 1, j), (i + 1, j + 1), (i, j + 1)))
                elif j in [0, cls.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, base_color: str, inner_color: str, outer_color: str):
        super().__init__(Piece._get_points(shape, position), base_color, outer_color)
        self._inner_color = inner_color
        self._ghost_piece_base_color = ConfigurationManager.get("color", "piece-ghost")
        self._center = self._get_center(shape, position)
        self._previous_points = None
        self._previous_center = None

        # Drop delay
        self._drop_delay = ConfigurationManager.get("engine", "piece-drop-delay") # in ms
        self._drop_delay_acc = 0 # acumulated drop delay, in ms
        self._applying_drop_delay = True

        # Lock delay
        self._lock_delay = ConfigurationManager.get("engine", "piece-lock-delay") # in ms
        self._lock_delay_acc = 0 # acumulated lock delay, in ms
        self._applying_lock_delay = False

    def update(self, elapsed_time: int, well: Well, stack: "Stack", level: int, clear_current_piece: FunctionType) -> None:
        super().update(elapsed_time)

        # handle rotation, left and right movement
        if Controller.key_down(pygame.K_UP):
            if self.rotate_with_wall_kick(well, stack):
                SoundManager.play_piece_rotate_sfx()
        if Controller.key_down(pygame.K_LEFT):
            self.move((-self._tile_size, 0))
            if well and self.collide(well) or stack and self.collide(stack):     
                self.revert()
        if Controller.key_down(pygame.K_RIGHT):
            self.move((self._tile_size, 0))
            if well and self.collide(well) or stack and self.collide(stack):     
                self.revert()

        # handle soft drop movement and gravity based on level
        start_drop_delay = ConfigurationManager.get("engine", "piece-drop-delay")
        start_lock_delay = ConfigurationManager.get("engine", "piece-lock-delay")
        drop_delay_decrease = ConfigurationManager.get("engine", "piece-drop-delay-decrease")
    
        if Controller.key_pressed(pygame.K_DOWN):
            self._drop_delay = max(10, (start_drop_delay - (level * drop_delay_decrease)) // 10)
            self._set_time = max(10, start_lock_delay // 10)
        if not Controller.key_pressed(pygame.K_DOWN): 
            self._drop_delay = start_drop_delay - (level * drop_delay_decrease)
            self._set_time = start_lock_delay

        # handle hard drop
        if Controller.key_down(pygame.K_SPACE):
            self._points = self._get_ghost_piece_points(well, stack)
            self._add_to_stack(stack, clear_current_piece)

        if self._applying_drop_delay:
            self._applying_drop_delay = self._apply_drop(elapsed_time, well, stack)
            self._applying_lock_delay = not self._applying_drop_delay

        """
            For more information on the piece set logic go here:
            https://strategywiki.org/wiki/Tetris/Features#Lock_delay
        """
        if self._applying_lock_delay:
            self._applying_lock_delay = self._apply_lock(elapsed_time, well, stack, clear_current_piece)
            self._applying_drop_delay = not self._applying_lock_delay


    def draw(self, surface: pygame.Surface, well: Well = None, stack: "Stack" = None, ghost_piece_off: bool = False) -> None:
        # ghost piece
        if well is not None and stack is not None and not ghost_piece_off:
            ghost_piece_points = self._get_ghost_piece_points(well, stack)
            if ghost_piece_points is not None:
                for square in ghost_piece_points:
                    pygame.draw.polygon(surface, pygame.Color(self._ghost_piece_base_color), square, max(self._tile_size // 6, 1)) # TODO add white to the yaml
        
        for square in self._points:
            if self._base_color is not None:
                square_color = pygame.Color(ConfigurationManager.get("color", self._base_color))
                pygame.draw.polygon(surface, square_color, square, 0)
            if self._outer_color is not None:
                pygame.draw.polygon(surface, pygame.Color(ConfigurationManager.get("color", self._outer_color)), square, max(self._tile_size // 6, 1))

        for square in self._points:
            # inner color border piece
            if self._inner_color:
                vertex_one = (square[0][0] + (self._tile_size // 10), square[0][1] + (self._tile_size // 10))
                vertex_two = (square[1][0] - (self._tile_size // 10), square[1][1] + (self._tile_size // 10))
                vertex_three = (square[2][0] - (self._tile_size // 10), square[2][1] - (self._tile_size // 10))
                vertex_four = (square[3][0] + (self._tile_size // 10), square[3][1] - (self._tile_size // 10))
                new_square = (vertex_one, vertex_two, vertex_three, vertex_four)
                pygame.draw.polygon(surface, pygame.Color(ConfigurationManager.get("color", self._inner_color)), new_square, max(self._tile_size // 6, 1))
        
            # draw glimmer
            surface.set_at((square[0][0]+3, square[0][1]+3), "white")
            surface.set_at((square[0][0]+4, square[0][1]+4), "white")
            surface.set_at((square[0][0]+5, square[0][1]+5), "white")
            surface.set_at((square[0][0]+4, square[0][1]+5), "white")
            surface.set_at((square[0][0]+5, square[0][1]+4), "white")

    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]
 
    def rotate_with_wall_kick(self, well: Well, stack: "Stack") -> bool:
        self._rotate()
        if well and self.collide(well) or stack and self.collide(stack):     
            self.revert()

            # trying kick to the left
            self.move((-self._tile_size, 0))
            self._rotate(True)
            if well and self.collide(well) or stack and self.collide(stack):     
                self.revert()
            else:
                # successful kick to the left
                return True

            # trying kick to the right
            self.move((self._tile_size, 0))
            self._rotate(True)
            if well and self.collide(well) or stack and self.collide(stack):     
                self.revert()
            else:
                # successful kick to the right
                return True

            # trying kick from top
            self.move((0, self._tile_size))
            self._rotate(True)
            if well and self.collide(well) or stack and self.collide(stack):     
                self.revert()
            else:
                # successful kick from top
                return True

            # trying kick 2 tiles from top
            self.move((0, self._tile_size * 2))
            self._rotate(True)
            if well and self.collide(well) or stack and self.collide(stack):     
                self.revert()
            else:
                # successful kick 2 tiles from top
                return True
        else:
            return True

        # failed rotation
        return False

    def revert(self) -> None:
        if self._previous_points and self._previous_center:
            self._points = self._previous_points
            self._center = self._previous_center

    '''
        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, no_revert: bool = False) -> None:
        if not no_revert:
            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

    @classmethod
    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:
        center = shape[-1]
        return [int(center[0] * self._tile_size + position[0]), int(center[1] * self._tile_size + position[1])]

    def _apply_drop(self, elapsed_time: int, well: Well, stack: "Stack") -> bool:
        self._drop_delay_acc += elapsed_time

        if self._drop_delay_acc >= self._drop_delay:
            self._drop_delay_acc = 0
            if not self._entity_is_below(well) and not self._entity_is_below(stack):
                self.move((0, self._tile_size))
            else:
                return False

        return True

    def _apply_lock(self, elapsed_time: int, well: Well, stack: "Stack", clear_current_piece: FunctionType) -> bool:
        self._lock_delay_acc += elapsed_time

        if self._lock_delay_acc >= self._lock_delay:
            self._add_to_stack(stack, clear_current_piece)
            return False

        return self._entity_is_below(well) or self._entity_is_below(stack)

    def _add_to_stack(self, stack: "Stack", clear_current_piece: FunctionType) -> None:
        SoundManager.play_piece_set_sfx()
        stack.add_piece(self)
        clear_current_piece()

    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 _entity_is_below(self, entity: Entity) -> bool:
        mimic_points = self._mimic_move((0, self._tile_size))
        return entity and entity._collide(mimic_points)

    def _get_ghost_piece_points(self, well: Well, stack: "Stack") -> List:
        current_points = copy.deepcopy(self._points)
        prior_points = None
        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 * self._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):
        super().__init__([], None, None)
        self._square_colors = []
        self.total_lines = 0
        self.lines_completed_last = 0

    def update(self, elapsed_time: int) -> None:
        super().update(elapsed_time)
        self.lines_completed_last = self._complete_rows()
        self.total_lines += self.lines_completed_last

    def draw(self, surface: pygame.Surface) -> None:
        for i in range(len(self._points)):
            square = self._points[i]
            square_design = self._square_colors[i]
            if square_design.base_color is not None:
                pygame.draw.polygon(surface, pygame.Color(ConfigurationManager.get("color", square_design.base_color)), square, 0)
            if square_design.outer_color is not None:
                pygame.draw.polygon(surface, pygame.Color(ConfigurationManager.get("color", square_design.outer_color)), square, max(self._tile_size // 6, 1))
            if square_design.inner_color is not None:
                vertex_one = (square[0][0] + (self._tile_size // 10), square[0][1] + (self._tile_size // 10))
                vertex_two = (square[1][0] - (self._tile_size // 10), square[1][1] + (self._tile_size // 10))
                vertex_three = (square[2][0] - (self._tile_size // 10), square[2][1] - (self._tile_size // 10))
                vertex_four = (square[3][0] + (self._tile_size // 10), square[3][1] - (self._tile_size // 10))
                new_square = (vertex_one, vertex_two, vertex_three, vertex_four)
                pygame.draw.polygon(surface, pygame.Color(ConfigurationManager.get("color", square_design.inner_color)), new_square, max(self._tile_size // 6, 1))

            # draw glimmer
            surface.set_at((square[0][0]+3, square[0][1]+3), pygame.Color(255, 255, 255))
            surface.set_at((square[0][0]+4, square[0][1]+4), pygame.Color(255, 255, 255))
            surface.set_at((square[0][0]+5, square[0][1]+5), pygame.Color(255, 255, 255))
            surface.set_at((square[0][0]+4, square[0][1]+5), pygame.Color(255, 255, 255))
            surface.set_at((square[0][0]+5, square[0][1]+4), pygame.Color(255, 255, 255))
        
    def add_piece(self, piece: Piece) -> None:
        self._points += piece._points
        self._square_colors += [SquareColor(piece._base_color, piece._inner_color, piece._outer_color) for _ in range(len(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, value in squares_by_row.items():
            if len(squares_by_row[key]) == Well.WIDTH:
                squares_to_exclude += value
                rows_completed.append(key)

        if not squares_to_exclude:
            return 0

        if len(rows_completed) == 4:
            SoundManager.play_four_lines_complete_sfx()
        else:
            SoundManager.play_line_complete_sfx()

        new_points = []
        self.new_square_colors = []
        for i in range(len(self._points)):
            square = self._points[i]
            if square not in squares_to_exclude:
                for vertex in square:
                    distance_to_move = sum(
                        vertex[1] <= row_completed
                        for row_completed in rows_completed
                    )
                    vertex[1] += self._tile_size * distance_to_move
                new_points.append(square)
                self.new_square_colors.append(self._square_colors[i])     
        self._points = new_points
        self._square_colors = self.new_square_colors

        return len(rows_completed)

"""
    TODO description
"""
class SquareColor:
    def __init__(self, base_color: str, inner_color: str, outer_color: str):
        self.base_color = base_color
        self.inner_color = inner_color
        self.outer_color = outer_color

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

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

        base_color, inner_color, outer_color = cls._get_piece_color()
        return Piece(cls._get_piece_shape(cls._bucket.pop()), position, base_color, inner_color, outer_color)

    @classmethod
    def get_opponent_piece(cls, base_color: str, inner_color: str, outer_color: str) -> Piece:
        return Piece(Piece.Z_SHAPE, (-250, -250), base_color, inner_color, outer_color)

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

    @classmethod
    def _get_piece_shape(cls, 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

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

        base_color = "piece-" + str(random_number) + "-player-1"
        inner_color = None if random_number != 3 else "piece-inner-border-1-player-1"
        outer_color = "piece-outer-border-1"
        
        return (base_color, inner_color, outer_color)