"""
    3.5 Вставка рисунков. Управление рисунков
    https://stepik.org/lesson/937437/step/2?unit=943412
    и
    https://stepik.org/lesson/937437/step/3?unit=943412

    Задача: 
    Попробуйте сделать управление объектом, который есть не просто прямоугольник, а, например, рисунок.
    и
    Сделайте программу, чтобы при движении влево было одно изображение, а при движении вправо другое изображение героя.
    
    Решение: 
    Игра "Призрачный лабиринт: сокровища небесного замка".
    Призрак в лабиринте, управление стрелками и WASD, выход Esc или Q.
    Чтобы пройти уровень, нужно дойти до выхода, желательно собрав все монетки.
    После прохождения уровня можно начать новый или выйти из игры.

    Картинки берутся из папки assets, если их нет то автоматически скачиваются из репозитория.
    Если скачать не получилось то будут нарисованы заглушки.

    Зависимости: pygame
"""

import os
import tempfile
import urllib.request
from abc import ABC, abstractmethod
from contextlib import contextmanager
from enum import Enum
from random import choice, randrange, sample
from typing import NamedTuple, Optional

import pygame

FONT_NAME = "Arial"
FPS = 30


def download_asset(asset, path):
    """
    Загрузка картинок из репозитория
    """
    prefix = "https://gitea.b4tman.ru/temp/py_stepik/raw/branch/master/assets/"
    print("Качаю картинку", asset, end=" ... ", flush=True)
    try:
        urllib.request.urlretrieve(prefix + asset, path)
    except Exception:
        print("не смог :(")
        return False
    print("скачал!")
    return True


def make_stub_image(path, name):
    """Создание пустой картинки, на случай если скачать не получилось"""

    img = pygame.surface.Surface((200, 200), flags=pygame.SRCALPHA)
    img.fill((255, 255, 255, 0))
    pygame.draw.line(img, "#ff000065", (5, 5), (195, 195), 2)
    pygame.draw.line(img, "#ff000065", (195, 5), (5, 195), 2)

    rect = pygame.Rect(5, 5, 190, 190)
    pygame.draw.rect(img, "black", rect, 3)

    font = pygame.font.SysFont(FONT_NAME, 44)
    text1 = font.render(name, True, "blue")
    text1_rect = text1.get_rect()
    text1_rect.center = img.get_rect().center
    img.blit(text1, text1_rect)
    pygame.image.save(img, path)


@contextmanager
def get_assets(names):
    """Получение соответствия с расположением файлов картинок"""

    assets_dir = "assets"
    files = {}
    tempfiles = []

    for asset in names:
        # поиск файлов
        filepath = os.path.join(assets_dir, asset)
        if os.path.isfile(filepath):
            files[asset] = filepath
            continue

        # создание временного файла
        _, ext = os.path.splitext(asset)
        fd, temppath = tempfile.mkstemp(suffix=ext)
        os.close(fd)
        tempfiles.append(temppath)
        # попытка загрузки, если не получилось то создание заглушки
        if not download_asset(asset, temppath):
            make_stub_image(temppath, asset)
        files[asset] = temppath

    # передача управления
    yield files
    # очистка
    del files
    for filename in tempfiles:
        try:
            os.remove(filename)
        except FileNotFoundError:
            pass


class Coords(NamedTuple):
    """
    Вспомогательный класс для упрощения работы с координатами
    """

    x: int | float
    y: int | float

    def __add__(self, other):
        if isinstance(other, self.__class__):
            return self.__class__(self.x + other.x, self.y + other.y)
        if isinstance(other, (int, float)):
            return self.__class__(self.x + other, self.y + other)
        return NotImplemented

    def __sub__(self, other):
        if isinstance(other, self.__class__):
            return self.__class__(self.x - other.x, self.y - other.y)
        if isinstance(other, (int, float)):
            return self.__class__(self.x - other, self.y - other)
        return NotImplemented

    def __floordiv__(self, other):
        if isinstance(other, self.__class__):
            return self.__class__(self.x // other.x, self.y // other.y)
        if isinstance(other, (int, float)):
            return self.__class__(self.x // other, self.y // other)
        return NotImplemented

    def __mul__(self, other):
        if isinstance(other, self.__class__):
            return self.__class__(self.x * other.x, self.y * other.y)
        if isinstance(other, (int, float)):
            return self.__class__(self.x * other, self.y * other)
        return NotImplemented

    def transform(self, ref: "Coords"):
        return self * ref

    @classmethod
    def zero(cls):
        return cls(0, 0)


def maze_gen(row=4, col=4):
    """генератор карты
    взял с коментария
    https://stepik.org/lesson/502494/step/3?discussion=6527620&unit=494196
    """
    row = max(2 * row + 1, 3)
    col = max(2 * col + 1, 3)
    maze = [[2] * col]
    maze.extend([[2] + [1] * (col - 2) + [2] for _ in range(row - 2)])
    maze.append(maze[0])

    curr = (randrange(1, len(maze) - 1, 2), randrange(1, len(maze[0]) - 1, 2))
    path = [curr]
    maze[curr[0]][curr[1]] = 0

    while path:
        nexts = [
            (r1, c1, r2, c2)
            for r, c in zip((1, 0, -1, 0), (0, 1, 0, -1))
            if (
                (r1 := curr[0] + r) is None
                or (c1 := curr[1] + c) is None
                or (r2 := curr[0] + 2 * r) is None
                or (c2 := curr[1] + 2 * c) is None
                or 1 == maze[r1][c1] == maze[r2][c2]
            )
        ]
        if nexts:
            r1, c1, r2, c2 = choice(nexts)
            maze[r1][c1] = maze[r2][c2] = 0
            path.append((r2, c2))
        else:
            curr = path.pop()

    upd = {
        ("22", "20"): (None, "00"),
        ("02", "22"): ("00", None),
        ("11101", "00101", "11111", "10100", "10111"): (
            None,
            None,
            None,
            "10001",
            None,
        ),
        ("10111", "10100", "11111", "00101", "11101"): (
            None,
            "10001",
            None,
            None,
            None,
        ),
    }
    for pattern, replacement in upd.items():
        for i in range(len(maze) - len(pattern) + 1):
            for j in range(len(maze[0]) - len(pattern[0]) + 1):
                if all(
                    maze[i + k][j : j + len(v)] == list(map(int, v))
                    for k, v in enumerate(pattern)
                ):
                    for k, v in filter(lambda x: x[1], enumerate(replacement)):
                        maze[i + k][j : j + len(v)] = list(map(int, v))

    return maze


def get_maze_sz(maze: list[list[int]]) -> Coords:
    return Coords(len(maze[0]), len(maze))


class Direction(Enum):
    LEFT = 1
    RIGHT = 2
    UP = 3
    DOWN = 4

    def as_coords(self):
        match self:
            case Direction.LEFT:
                return Coords(-1, 0)
            case Direction.RIGHT:
                return Coords(1, 0)
            case Direction.UP:
                return Coords(0, -1)
            case Direction.DOWN:
                return Coords(0, 1)


class SurfaceWithRect(NamedTuple):
    surface: pygame.Surface
    rect: pygame.Rect

    def draw_to(self, target: pygame.Surface):
        target.blit(self.surface, self.rect)


class DrawableGameObject(ABC):
    """обобщение игрового элемента"""

    coords = property(
        lambda self: self.get_coords(), lambda self, c: self.set_coords(c)
    )

    def __init__(
        self,
        coords: Coords,
        parent: Optional["DrawableGameObject"] = None,
        assets: dict | None = None,
    ):
        self.parent = parent
        self.rect = pygame.Rect(coords, coords)
        self.assets = assets or (parent.assets if parent else None)
        self._surface = None

    @property
    def surface(self) -> pygame.Surface | None:
        return self._surface or (self.parent.surface if self.parent else None)

    @property
    def scene(self):
        return self.parent.scene if self.parent else self

    def get_coords(self) -> Coords:
        return Coords(*self.rect.topleft)

    def set_coords(self, coords: Coords):
        new_rect = self.rect.copy()
        new_rect.topleft = coords
        if self.parent:
            if self.parent.rect:
                if not self.parent.rect.contains(new_rect):
                    return
        self.rect = new_rect

    @abstractmethod
    def draw(self):
        pass


class EventHandler(ABC):
    @abstractmethod
    def handle_event(self):
        pass


class Hero(DrawableGameObject, EventHandler):
    """объект главного героя"""

    def __init__(
        self,
        coords: Coords,
        parent: DrawableGameObject,
        assets: dict | None = None,
    ):
        super().__init__(coords, parent, assets)
        self._surface = pygame.image.load(self.assets["ghost.png"])
        self.rect = self.surface.get_rect()
        sf = Coords(0.8, 0.8)
        self._surface, self.rect = self.scene.scale_box(self.surface, self.rect, sf)
        self.rect.topleft = coords
        self.active = True
        self.looking_right = False
        self._speed = 1
        self.direction = Direction.RIGHT
        # картинка изначально влево, а надо бы начинать со взгляда вправо
        self.flip()

    def draw(self):
        self.parent.surface.blit(self.surface, self.rect)

    def _check_collision(self, coords):
        """Проверка пересечения со стенами"""
        new_rect = self.surface.get_bounding_rect()
        new_rect.topleft = coords
        new_rect.scale_by_ip(0.99, 0.99)
        return self.scene.walls.check_collision(new_rect)

    @property
    def speed(self):
        return max(self._speed, 1)

    @speed.setter
    def speed(self, value):
        self._speed = min(value, 15)

    def _reduce_step(self, coords):
        """Уменьшение шага движения, с целью подойти вплотную к стене"""
        delta = coords - self.coords
        dx, dy = 0, 0
        if abs(delta.x) > 1:
            dx = 1 * (delta.x < 0 or -1)
        if abs(delta.y) > 1:
            dy = 1 * (delta.y < 0 or -1)
        return coords + Coords(dx, dy)

    def set_coords(self, coords: Coords):
        # проверка колизии
        has_collision = self._check_collision(coords)
        if not has_collision:
            return super().set_coords(coords)

        # уменьшение шага
        while has_collision and coords != self.coords:
            coords_new = self._reduce_step(coords)
            if coords_new == coords:
                return  # не могу уменьшить шаг
            coords = coords_new
            has_collision = self._check_collision(coords)
        super().set_coords(coords)

    def flip(self):
        self.looking_right = not self.looking_right
        self._surface = pygame.transform.flip(self.surface, flip_x=True, flip_y=False)

    def update_direction(self, direction: Direction):
        if direction in (Direction.LEFT, Direction.RIGHT):
            going_right = direction == Direction.RIGHT
            if self.looking_right != going_right:
                self.flip()

        if direction != self.direction:
            self.speed = 0
            self.direction = direction
        else:
            self.speed += 1

    def move(self, direction: Direction, step: int = 1):
        self.update_direction(direction)
        self.coords += direction.as_coords() * step * self.speed // 3
        self.scene.coins.collect(self.rect)

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return

        wide, short = 3, 1
        if event.type == pygame.KEYDOWN:
            match event.key:
                case pygame.K_UP:
                    self.move(Direction.UP, wide)
                case pygame.K_DOWN:
                    self.move(Direction.DOWN, wide)
                case pygame.K_LEFT:
                    self.move(Direction.LEFT, wide)
                case pygame.K_RIGHT:
                    self.move(Direction.RIGHT, wide)
                case pygame.K_w:
                    self.move(Direction.UP, short)
                case pygame.K_s:
                    self.move(Direction.DOWN, short)
                case pygame.K_a:
                    self.move(Direction.LEFT, short)
                case pygame.K_d:
                    self.move(Direction.RIGHT, short)


class WallBlock(DrawableGameObject):
    """объект элемента стены"""

    def __init__(
        self,
        coords: Coords,
        parent: DrawableGameObject,
        assets: dict | None = None,
    ):
        super().__init__(coords, parent, assets)
        self._surface = pygame.image.load(self.assets["brick.png"])
        self.rect = self.surface.get_rect()
        self.rect.topleft = coords
        # уменьшаем размер монетки
        sf = Coords(1, 1)
        self._surface, self.rect = self.scene.scale_box(self.surface, self.rect, sf)

    def draw(self):
        self.parent.surface.blit(self.surface, self.rect)


class Walls(DrawableGameObject):
    """объект стен"""

    def __init__(
        self,
        parent: DrawableGameObject,
        maze: list[list[int]],
        box_sz: Coords,
        assets: dict | None = None,
    ):
        super().__init__(Coords.zero(), parent, assets)
        self.box_sz = box_sz
        self.blocks = [
            WallBlock(Coords(j, i).transform(box_sz), self, self.assets)
            for i, row in enumerate(maze)
            for j, item in enumerate(row)
            if item > 0
        ]

    def draw(self):
        for block in self.blocks:
            block.draw()

    def check_collision(self, rect: pygame.Rect) -> bool:
        for block in self.blocks:
            if block.rect.colliderect(rect):
                return True
        return False


class Coin(DrawableGameObject):
    """объект монетки"""

    def __init__(
        self,
        coords: Coords,
        parent: DrawableGameObject,
        assets: dict | None = None,
    ):
        super().__init__(coords, parent, assets)
        self._surface = pygame.image.load(self.assets["coin.png"])
        self.rect = self.surface.get_rect()
        self.rect.topleft = coords
        # уменьшаем размер монетки
        sf = Coords(0.7, 0.7)
        self._surface, self.rect = self.scene.scale_box(self.surface, self.rect, sf)

    def draw(self):
        self.parent.surface.blit(self.surface, self.rect)

    @property
    def bounding_rect(self) -> pygame.Rect:
        new_rect = self.surface.get_bounding_rect()
        new_rect.center = self.rect.center
        return new_rect


class Coins(DrawableGameObject):
    """объект коллекции монеток"""

    def __init__(
        self,
        parent: DrawableGameObject,
        maze: list[list[int]],
        box_sz: Coords,
        count: int,
        assets: dict | None = None,
    ):
        super().__init__(Coords.zero(), parent, assets)
        self.box_sz = box_sz
        self._capacity = count

        free_points = []
        excluded = Coords(0, 1), get_maze_sz(maze) - Coords(1, 2)
        for i, row in enumerate(maze):
            for j, item in enumerate(row):
                p = Coords(j, i)
                if item < 1 and p not in excluded:
                    free_points.append(p)
                    continue

        coin_points = sample(free_points, min(count, len(free_points)))
        self.coins = [
            Coin(point.transform(box_sz), self, self.assets) for point in coin_points
        ]
        self.collected_coins = []

        # Надпись, если все монетки собраны
        font = pygame.font.SysFont(FONT_NAME, 30)
        text = "Все монетки собраны!"
        self.done_txt = font.render(text, 1, "#050366e3")
        self.done_txt_rect = self.done_txt.get_rect()
        self.done_txt_rect.topleft = Coords(10, 10)

    @property
    def capacity(self) -> int:
        return self._capacity

    @property
    def coins_left(self) -> int:
        return len(self.coins)

    @property
    def coins_collected(self) -> int:
        return self.capacity - self.coins_left

    @property
    def all_collected(self) -> int:
        return self.coins_left == 0

    def draw(self):
        for coin in self.collected_coins:
            coin.draw()
        for coin in self.coins:
            coin.draw()
        if self.all_collected:
            self.parent.surface.blit(self.done_txt, self.done_txt_rect)

    def add_to_collected(self, coin: Coin):
        last_pos = Coords(10, 10)
        if self.collected_coins:
            last_pos = Coords(*self.collected_coins[-1].rect.topright)
            last_pos -= Coords(coin.rect.width // 2, 0)
        coin.coords = last_pos
        self.collected_coins.append(coin)

    def collect(self, rect: pygame.Rect):
        mined = [*filter(lambda coin: coin.bounding_rect.colliderect(rect), self.coins)]
        for coin in mined:
            self.coins.remove(coin)
            self.add_to_collected(coin)


class EndLevelMenu(DrawableGameObject, EventHandler):
    def __init__(self, scene: DrawableGameObject):
        super().__init__(Coords.zero(), scene, scene.assets)
        self._surface, self.rect = self._create_end_game_label()
        self.win_image = self._create_win_image()
        self.win_label = self._create_win_label()
        self.keys_hint = self._create_keys_hint()
        self.stats_label = None
        self.active = False

    def _create_end_game_label(self) -> SurfaceWithRect:
        """Надпись завершения игры"""
        font = pygame.font.SysFont(FONT_NAME, 70)
        text = "Конец игры!"
        surface = font.render(text, 1, "#1b10a8c4")
        rect = surface.get_rect()
        rect.center = Coords(*self.parent.rect.center) + Coords(0, 38)
        return SurfaceWithRect(surface, rect)

    def _create_keys_hint(self) -> SurfaceWithRect:
        """Совет по кнопкам"""
        hint_text = "Для новой игры нажмите N, для выхода Q"
        font_hint = pygame.font.SysFont(FONT_NAME, 27)
        surface = font_hint.render(hint_text, 1, "#24053da4")
        rect = surface.get_rect()
        rect.center = self.parent.rect.center
        rect = rect.move(Coords(0, 220))
        return SurfaceWithRect(surface, rect)

    def _create_win_label(self) -> SurfaceWithRect:
        """Надпись для хорошего финала"""
        font = pygame.font.SysFont(FONT_NAME, 33)
        text = "Все монетки собраны!"
        surface = font.render(text, 1, "#96081ba4")
        rect = surface.get_rect()
        rect.center = self.parent.rect.center
        rect.move_ip(Coords(0, -200))
        return SurfaceWithRect(surface, rect)

    def _create_win_image(self) -> SurfaceWithRect:
        """Картинка для хорошего финала"""
        surface = pygame.image.load(self.scene.assets["win.png"])
        rect = surface.get_rect()
        rect.center = self.parent.rect.center
        return SurfaceWithRect(surface, rect)

    def _create_stats_label(self) -> SurfaceWithRect:
        """Общая статистика игры"""
        stats_text = f"Всего пройдено уровней: {self.scene.total_levels}, собрано монет: {self.scene.total_coins}"
        stats_font = pygame.font.SysFont(FONT_NAME, 27)
        surface = stats_font.render(stats_text, 1, "#031f03a4")
        rect = surface.get_rect()
        rect.center = Coords(*self.scene.rect.center) + Coords(0, 350)
        return SurfaceWithRect(surface, rect)

    def draw(self):
        if not self.active:
            return
        if self.scene.coins.all_collected:
            self.win_image.draw_to(self.parent.surface)
            self.win_label.draw_to(self.parent.surface)

        self.parent.surface.blit(self.surface, self.rect)
        self.keys_hint.draw_to(self.parent.surface)

        # статистика
        if self.stats_label is None:
            self.stats_label = self._create_stats_label()
        self.stats_label.draw_to(self.parent.surface)

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_n:
                self.parent.want_new_level = True
                self.parent.done = True


class Scene(DrawableGameObject, EventHandler):
    """основной игровой объект"""

    # кнопки для выхода из игры
    exit_keys = (pygame.K_ESCAPE, pygame.K_q)

    def __init__(
        self, assets: dict, screen_sz: Coords, maze_sz: Coords, coins_count: int
    ):
        super().__init__(Coords.zero(), None, assets)
        self.maze = maze_gen(*maze_sz)
        maze_sz = get_maze_sz(self.maze)

        box_sz = screen_sz // get_maze_sz(self.maze)
        self.box_sz = box_sz
        self._surface = pygame.display.set_mode(screen_sz)
        self.surface.fill("white")
        self.rect = self.surface.get_rect()
        self.background = pygame.image.load(self.assets["bg1k.png"])
        self.background = pygame.transform.scale(self.background, self.rect.size)

        self.total_levels, self.total_coins = 0, 0

        hero_sz = Coords(*map(int, box_sz * 0.8))
        hero_y_offset = (box_sz.y - hero_sz.y) // 2 + box_sz.y
        self.hero = Hero(Coords(0, hero_y_offset), self)
        self.done = False
        self.level_completed = False

        self.maze = maze_gen(6, 6)
        self.walls = Walls(self, self.maze, box_sz)
        self.coins = Coins(self, self.maze, box_sz, coins_count)

        self.end = EndLevelMenu(self)
        self.end.active = False
        self.want_new_level = False
        self.exit_rect = self.get_exit_rect()

    def get_exit_rect(self) -> pygame.Rect:
        # находим клетку в которой будет выход с карты
        maze_sz = get_maze_sz(self.maze)
        coords = (maze_sz - Coords(1, 2)) * self.box_sz
        rect = pygame.Rect(coords, coords)
        rect.width, rect.height = 1, self.box_sz.y
        return rect.move((self.box_sz.x, 0))

    def check_level_completed(self):
        level_completed = self.exit_rect.colliderect(self.hero.rect)
        if level_completed and not self.level_completed:
            self.total_coins += self.coins.coins_collected
            self.total_levels += 1
            self.end.active = True
            self.hero.active = False
            self.level_completed = True

    def draw(self):
        if self.done:
            return
        self.surface.fill("white")
        self.surface.blit(self.background, self.coords)
        if self.level_completed:
            self.end.draw()
        else:
            self.hero.draw()
            self.walls.draw()
            self.coins.draw()

    def scale_box(
        self, surface: pygame.Surface, rect: pygame.Rect, scale_factor: Coords
    ):
        rect.size = self.box_sz
        rect.scale_by_ip(*scale_factor)
        surface = pygame.transform.scale(surface, rect.size)
        return surface, rect

    def handle_event(self, event: pygame.event.Event):
        if self.done:
            return
        if (
            event.type == pygame.QUIT
            or event.type == pygame.KEYDOWN
            and event.key in self.exit_keys
        ):
            self.done = True
        if not self.done:
            self.hero.handle_event(event)
            self.check_level_completed()
            self.end.handle_event(event)

    def event_loop(self):
        clock = pygame.time.Clock()
        pygame.key.set_repeat(50, 30)
        while not self.done:
            for event in pygame.event.get():
                self.handle_event(event)
            self.draw()
            pygame.display.flip()
            clock.tick(FPS)


def game(assets):
    screen_sz = Coords(1000, 1000)
    maze_sz = Coords(6, 6)
    coins_count = 10
    pygame.display.set_caption("Призрачный лабиринт: сокровища небесного замка")

    total_levels = 0
    total_coins = 0
    want_new_level = True
    while want_new_level:
        scene = Scene(assets, screen_sz, maze_sz, coins_count)
        scene.total_levels, scene.total_coins = total_levels, total_coins
        scene.event_loop()

        want_new_level = scene.want_new_level
        total_levels = scene.total_levels
        total_coins = scene.total_coins

    pygame.quit()


def main():
    pygame.init()
    required_assets = [
        "bg1k.png",
        "ghost.png",
        "brick.png",
        "win.png",
        "coin.png",
    ]
    with get_assets(required_assets) as assets:
        game(assets)


main()