import math
import random
from enum import Enum

import pyxel

WIDTH = 247
HEIGHT = 148

HALF_WIDTH = WIDTH // 2
HALF_HEIGHT = HEIGHT // 2

PLAYER_SPEED = 2
MAX_AI_SPEED = 2


def draw_outline(x, y, img, u, v, w, h, col_key=0):
    pyxel.blt(x + 1, y, img, u, v, w, h, col_key)
    pyxel.blt(x - 1, y, img, u, v, w, h, col_key)
    pyxel.blt(x, y + 1, img, u, v, w, h, col_key)
    pyxel.blt(x, y - 1, img, u, v, w, h, col_key)


def normalised(x, y):
    # Return a unit vector
    # Get length of vector (x,y) - math.hypot uses Pythagoras' theorem to get length of hypotenuse
    # of right-angle triangle with sides of length x and y
    # todo note on safety
    length = math.hypot(x, y)
    return (x / length, y / length)


class Impact:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.time = 0
        self.alive = True

    def update(self):
        self.time += 1
        if self.time >= 10:
            self.alive = False

    def draw(self):
        pyxel.circb(self.x, self.y, (self.time // 2) * 2, 7)


class Ball:
    def __init__(self, dx):
        self.x, self.y = HALF_WIDTH, HALF_HEIGHT

        # dx and dy together describe the direction in which the ball is moving. For example, if dx and dy are 1 and 0,
        # the ball is moving to the right, with no movement up or down. If both values are negative, the ball is moving
        # left and up, with the angle depending on the relative values of the two variables. If you're familiar with
        # vectors, dx and dy represent a unit vector. If you're not familiar with vectors, see the explanation in the
        # book.
        self.dx, self.dy = dx, 0

        self.speed = 5

    def draw(self):
        my_col = 7
        if pyxel.frame_count % 4 < 2:
            my_col = 14
        for i in range(16):
            pyxel.pal(i, my_col)
        draw_outline(self.x, self.y,  0, 8, 152, 5, 5, 0)
        pyxel.pal()
        pyxel.blt(self.x, self.y, 0, 8, 152, 5, 5, 0)

    def update(self):
        # Each frame, we move the ball in a series of small steps - the number of steps being based on its speed attribute
        for i in range(int(self.speed)):
            # Store the previous x position
            original_x = self.x

            # Move the ball based on dx and dy
            self.x += self.dx
            self.y += self.dy

            # Check to see if ball needs to bounce off a bat

            if abs(self.x - HALF_WIDTH) >= (HALF_WIDTH - 12 - 2.5 - 2) and abs(
                original_x - HALF_WIDTH
            ) < (HALF_WIDTH - 12 - 2.5 - 2):
                # Now that we know the edge of the ball has crossed the threshold on the x-axis, we need to check to
                # see if the bat on the relevant side of the arena is at a suitable position on the y-axis for the
                # ball collide with it.

                if self.x < HALF_WIDTH:
                    new_dir_x = 1
                    bat = game.bats[0]
                else:
                    new_dir_x = -1
                    bat = game.bats[1]

                difference_y = (self.y + 2) - (bat.y + (37 / 2))

                if -(37 / 2) < difference_y < (37 / 2):
                    # Ball has collided with bat - calculate new direction vector

                    # To understand the maths used below, we first need to consider what would happen with this kind of
                    # collision in the real world. The ball is bouncing off a perfectly vertical surface. This makes for a
                    # pretty simple calculation. Let's take a ball which is travelling at 1 metre per second to the right,
                    # and 2 metres per second down. Imagine this is taking place in space, so gravity isn't a factor.
                    # After the ball hits the bat, it's still going to be moving at 2 m/s down, but it's now going to be
                    # moving 1 m/s to the left instead of right. So its speed on the y-axis hasn't changed, but its
                    # direction on the x-axis has been reversed. This is extremely easy to code - "self.dx = -self.dx".
                    # However, games don't have to perfectly reflect reality.
                    # In Pong, hitting the ball with the upper or lower parts of the bat would make it bounce diagonally
                    # upwards or downwards respectively. This gives the player a degree of control over where the ball
                    # goes. To make for a more interesting game, we want to use realistic physics as the starting point,
                    # but combine with this the ability to influence the direction of the ball. When the ball hits the
                    # bat, we're going to deflect the ball slightly upwards or downwards depending on where it hit the
                    # bat. This gives the player a bit of control over where the ball goes.

                    # Bounce the opposite way on the X axis
                    self.dx = -self.dx

                    # Deflect slightly up or down depending on where ball hit bat
                    self.dy += difference_y / 128

                    # Limit the Y component of the vector so we don't get into a situation where the ball is bouncing
                    # up and down too rapidly
                    self.dy = min(max(self.dy, -1), 1)

                    # Ensure our direction vector is a unit vector, i.e. represents a distance of the equivalent of
                    # 1 pixel regardless of its angle
                    self.dx, self.dy = normalised(self.dx, self.dy)

                    # Create an impact effect
                    game.impacts.append(Impact(self.x - new_dir_x * 3, self.y))

                    # Increase speed with each hit
                    self.speed += 0.3

                    # Add an offset to the AI player's target Y position, so it won't aim to hit the ball exactly
                    # in the centre of the bat
                    game.ai_offset = random.randint(-3, 3)

                    # Bat glows for 10 frames
                    bat.timer = 10

                    # Play hit sounds, with more intense sound effects as the ball gets faster
                    pyxel.play(0, 0)  # play every time in addition to:

            # The top and bottom of the arena are 30% of 220 pixels from the centre
            if abs(self.y - HALF_HEIGHT) > (220 * 0.3):
                # Invert vertical direction and apply new dy to y so that the ball is no longer overlapping with the
                # edge of the arena
                self.dy = -self.dy
                self.y += self.dy

                # Create impact effect
                game.impacts.append(Impact(self.x, self.y))

                # Sound effect
                pyxel.play(0, 0)

    def out(self):
        # Has ball gone off the left or right edge of the screen?
        return self.x < 0 or self.x > WIDTH


class Bat:
    def __init__(self, player, move_func=None):
        self.x = 12 if player == 0 else WIDTH - 12
        self.y = HALF_HEIGHT

        self.player = player
        self.score = 0

        # move_func is a function we may or may not have been passed by the code which created this object. If this bat
        # is meant to be player controlled, move_func will be a function that when called, returns a number indicating
        # the direction and speed in which the bat should move, based on the keys the player is currently pressing.
        # If move_func is None, this indicates that this bat should instead be controlled by the AI method.
        if move_func != None:
            self.move_func = move_func
        else:
            self.move_func = self.ai

        # Each bat has a timer which starts at zero and counts down by one every frame. When a player concedes a point,
        # their timer is set to 20, which causes the bat to display a different animation frame. It is also used to
        # decide when to create a new ball in the centre of the screen - see comments in Game.update for more on this.
        # Finally, it is used in Game.draw to determine when to display a visual effect over the top of the background
        self.timer = 0

    def draw(self):
        if self.timer > 0:
            my_col = 7
            if pyxel.frame_count % 4 < 2:
                my_col = 14
            for i in range(16):
                pyxel.pal(i, my_col)
            draw_outline(self.x, self.y, 0, 0, 0, 4, 37, 0)
            pyxel.pal()
        pyxel.blt(self.x, self.y, 0, 0, 152, 4, 37, 0)

    def update(self):
        self.timer -= 1

        # Our movement function tells us how much to move on the Y axis
        y_movement = self.move_func()

        # Apply y_movement to y position, ensuring bat does not go through the side walls
        self.y = min(HEIGHT - (37) - 4, max(4, self.y + y_movement))

    def ai(self):
        # Returns a number indicating how the computer player will move - e.g. 4 means it will move 4 pixels down
        # the screen.

        # To decide where we want to go, we first check to see how far we are from the ball.
        x_distance = abs(game.ball.x - self.x)

        # If the ball is far away, we move towards the centre of the screen (HALF_HEIGHT), on the basis that we don't
        # yet know whether the ball will be in the top or bottom half of the screen when it reaches our position on
        # the X axis. By waiting at a central position, we're as ready as it's possible to be for all eventualities.
        target_y_1 = HALF_HEIGHT - (37 / 2)

        # If the ball is close, we want to move towards its position on the Y axis. We also apply a small offset which
        # is randomly generated each time the ball bounces. This is to make the computer player slightly less robotic
        # - a human player wouldn't be able to hit the ball right in the centre of the bat each time.
        target_y_2 = game.ball.y + 2 + game.ai_offset - (37 / 2)

        # The final step is to work out the actual Y position we want to move towards. We use what's called a weighted
        # average - taking the average of the two target Y positions we've previously calculated, but shifting the
        # balance towards one or the other depending on how far away the ball is. If the ball is more than 400 pixels
        # (half the screen width) away on the X axis, our target will be half the screen height (target_y_1). If the
        # ball is at the same position as us on the X axis, our target will be target_y_2. If it's 200 pixels away,
        # we'll aim for halfway between target_y_1 and target_y_2. This reflects the idea that as the ball gets closer,
        # we have a better idea of where it's going to end up.
        weight1 = min(1, x_distance / HALF_WIDTH)
        weight2 = 1 - weight1

        target_y = (weight1 * target_y_1) + (weight2 * target_y_2)

        # Subtract target_y from our current Y position, then make sure we can't move any further than MAX_AI_SPEED
        # each frame
        return min(MAX_AI_SPEED, max(-MAX_AI_SPEED, target_y - self.y))


class Game:
    def __init__(self, controls=(None, None)):
        # Create a list of two bats, giving each a player number and a function to use to receive
        # control inputs (or the value None if this is intended to be an AI player)
        self.bats = [Bat(0, controls[0]), Bat(1, controls[1])]

        # Create a ball object
        self.ball = Ball(-1)

        # Create an empty list which will later store the details of currently playing impact
        # animations - these are displayed for a short time every time the ball bounces
        self.impacts = []

        # Add an offset to the AI player's target Y position, so it won't aim to hit the ball exactly
        # in the centre of the bat
        self.ai_offset = 0

    def update(self):
        # Update all active objects
        for obj in self.bats + [self.ball] + self.impacts:
            obj.update()

        # Remove any expired impact effects from the list. We go through the list backwards, starting from the last
        # element, and delete any elements those time attribute has reached 10. We go backwards through the list
        # instead of forwards to avoid a number of issues which occur in that scenario. In the next chapter we will
        # look at an alternative technique for removing items from a list, using list comprehensions.
        for i in range(len(self.impacts) - 1, -1, -1):
            if self.impacts[i].alive is False:
                del self.impacts[i]

        # Has ball gone off the left or right edge of the screen?
        if self.ball.out():
            # Work out which player gained a point, based on whether the ball
            # was on the left or right-hand side of the screen
            scoring_player = 1 if self.ball.x < WIDTH // 2 else 0
            losing_player = 1 - scoring_player

            # We use the timer of the player who has just conceded a point to decide when to create a new ball in the
            # centre of the level. This timer starts at zero at the beginning of the game and counts down by one every
            # frame. Therefore, on the frame where the ball first goes off the screen, the timer will be less than zero.
            # We set it to 20, which means that this player's bat will display a different animation frame for 20
            # frames, and a new ball will be created after 20 frames
            if self.bats[losing_player].timer < 0:
                self.bats[scoring_player].score += 1

                pyxel.play(0, 2)

                self.bats[losing_player].timer = 20

            elif self.bats[losing_player].timer == 0:
                # After 20 frames, create a new ball, heading in the direction of the player who just missed the ball
                direction = -1 if losing_player == 0 else 1
                self.ball = Ball(direction)

    def draw(self):
        # Draw background
        # screen.blit("table", (0,0))
        pyxel.cls(0)
        pyxel.blt(0, 0, 2, 0, 0, WIDTH, HEIGHT, 0)
        # Draw bats, ball and impact effects - in that order. Square brackets are needed around the ball because
        # it's just an object, whereas the other two are lists - and you can't directly join an object onto a
        # list without first putting it in a list
        for obj in self.bats + [self.ball] + self.impacts:
            obj.draw()

        # Display scores - outer loop goes through each player
        for p in (0, 1):
            # Convert score into a string of 2 digits (e.g. "05") so we can later get the individual digits
            score = "{0:02d}".format(self.bats[p].score)
            # Inner loop goes through each digit
            for i in (0, 1):
                colour = 0
                other_p = 1 - p
                if self.bats[other_p].timer > 0 and game.ball.out():
                    colour = 11 if p == 0 else 12
                pyxel.text(77 + (48 * p) + (i * 17), 12, score, colour)


def p1_controls():
    move = 0
    if pyxel.btn(pyxel.KEY_Z) or pyxel.btn(pyxel.KEY_DOWN):
        move = PLAYER_SPEED
    elif pyxel.btn(pyxel.KEY_A) or pyxel.btn(pyxel.KEY_UP):
        move = -PLAYER_SPEED
    return move


def p2_controls():
    move = 0
    if pyxel.btn(pyxel.KEY_M):
        move = PLAYER_SPEED
    elif pyxel.btn(pyxel.KEY_K):
        move = -PLAYER_SPEED
    return move


class State(Enum):
    MENU = 1
    PLAY = 2
    GAME_OVER = 3


class App:
    def __init__(self):
        pyxel.init(WIDTH, HEIGHT)
        pyxel.load("res3.pyxres")
        pyxel.run(self.update, self.draw)

    def update(self):
        global state, game, num_players, space_down

        # Work out whether the space key has just been pressed - i.e. in the previous frame it wasn't down,
        # and in this frame it is.
        space_pressed = False
        if pyxel.btnp(pyxel.KEY_SPACE) and not space_down:
            space_pressed = True
        space_down = pyxel.btnp(pyxel.KEY_SPACE)

        if state == State.MENU:
            if space_pressed:
                # Switch to play state, and create a new Game object, passing it the controls function for
                # player 1, and if we're in 2 player mode, the controls function for player 2 (otherwise the
                # 'None' value indicating this player should be computer-controlled)
                state = State.PLAY
                controls = [p1_controls]
                controls.append(p2_controls if num_players == 2 else None)
                game = Game(controls)
            else:
                # Detect up/down keys
                if num_players == 2 and pyxel.btnp(pyxel.KEY_UP):
                    num_players = 1
                elif num_players == 1 and pyxel.btnp(pyxel.KEY_DOWN):
                    num_players = 2

                # Update the 'attract mode' game in the background (two AIs playing each other)
                game.update()

        elif state == State.PLAY:
            # Has anyone won?
            if max(game.bats[0].score, game.bats[1].score) > 9:
                state = State.GAME_OVER
            else:
                game.update()

        elif state == State.GAME_OVER:
            if space_pressed:
                # Reset to menu state
                state = State.MENU
                num_players = 1

                # Create a new Game object, without any players
                game = Game()

    def draw(self):
        game.draw()
        if state == State.MENU:
            menu_image = num_players - 1
            pyxel.blt(0, 0, menu_image, 0, 0, 240, 144, 10)

        elif state == State.GAME_OVER:
            pyxel.blt(16, 56, 2, 0, 208, 223, 47, 10)



num_players = 1

# Is space currently being held down?
space_down = False

game = Game()
state = State.MENU
App()