"""
maze_solver.py — Поиск выхода из лабиринта.

Паттерны GoF: Builder, Strategy, Observer, Command.
Алгоритмы: BFS, DFS, A*, Dijkstra.

Запуск:
    python maze_solver.py --solve mazes/small_10x10.txt --algo bfs
    python maze_solver.py --walk  mazes/small_10x10.txt
    python maze_solver.py --experiment
"""

from __future__ import annotations

import argparse
import csv
import heapq
import os
import statistics
import sys
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple

sys.setrecursionlimit(100_000)


# ЭТАП 1. МОДЕЛЬ ЛАБИРИНТА — Cell, Maze

@dataclass
class Cell:
    """Одна клетка лабиринта."""
    x: int
    y: int
    is_wall:  bool = False
    is_start: bool = False
    is_exit:  bool = False
    weight:   int  = 1       # 1=асфальт  2=песок  3=болото

    def is_passable(self) -> bool:
        return not self.is_wall

    def __repr__(self) -> str:
        if self.is_wall:  return "#"
        if self.is_start: return "S"
        if self.is_exit:  return "E"
        return " "

    # heapq требует сравнения объектов
    def __lt__(self, other: Cell) -> bool:
        return (self.x, self.y) < (other.x, other.y)

    def __hash__(self):
        return hash((self.x, self.y))

    def __eq__(self, other):
        return isinstance(other, Cell) and self.x == other.x and self.y == other.y


class Maze:
    """Двумерная сетка клеток."""

    def __init__(self, width: int, height: int):
        self.width  = width
        self.height = height
        self._cells: List[List[Cell]] = [
            [Cell(x, y) for x in range(width)] for y in range(height)
        ]
        self.start: Optional[Cell] = None
        self.exit:  Optional[Cell] = None

    def get_cell(self, x: int, y: int) -> Optional[Cell]:
        if 0 <= x < self.width and 0 <= y < self.height:
            return self._cells[y][x]
        return None

    def get_neighbors(self, cell: Cell) -> List[Cell]:
        """Четыре соседа — только проходимые, в пределах границ."""
        result = []
        for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
            nb = self.get_cell(cell.x + dx, cell.y + dy)
            if nb and nb.is_passable():
                result.append(nb)
        return result



# ЭТАП 2. ПАТТЕРН BUILDER — загрузка лабиринта из файла

_WEIGHT_MAP = {' ': 1, '.': 2, '~': 3}


class MazeBuilder(ABC):
    """Интерфейс строителя лабиринта."""

    @abstractmethod
    def build_from_file(self, filename: str) -> Maze: ...

    @abstractmethod
    def build_from_string(self, text: str) -> Maze: ...


class TextFileMazeBuilder(MazeBuilder):
    """
    Строит Maze из текстового файла:
      #  — стена       S  — старт      E  — выход
      ' '— путь (w=1)  .  — песок (w=2)  ~  — болото (w=3)
    """

    def build_from_file(self, filename: str) -> Maze:
        return self.build_from_string(Path(filename).read_text(encoding="utf-8"))

    def build_from_string(self, text: str) -> Maze:
        lines = text.splitlines()
        while lines and not lines[-1].strip():
            lines.pop()
        if not lines:
            raise ValueError("Пустой лабиринт")

        height = len(lines)
        width  = max(len(l) for l in lines)
        maze   = Maze(width, height)

        for y, line in enumerate(lines):
            for x, ch in enumerate(line):
                cell = maze.get_cell(x, y)
                if cell is None:
                    continue
                if ch == '#':
                    cell.is_wall = True
                elif ch == 'S':
                    cell.is_start = True
                    maze.start = cell
                elif ch == 'E':
                    cell.is_exit = True
                    maze.exit = cell
                else:
                    cell.weight = _WEIGHT_MAP.get(ch, 1)
            for x in range(len(line), width):   # дополнить стенами
                c = maze.get_cell(x, y)
                if c:
                    c.is_wall = True

        if maze.start is None:
            raise ValueError("Нет стартовой клетки 'S'")
        if maze.exit is None:
            raise ValueError("Нет выходной клетки 'E'")
        return maze



# ЭТАП 3. ПАТТЕРН STRATEGY — алгоритмы поиска пути


def _reconstruct(parent: Dict[Cell, Optional[Cell]], end: Cell) -> List[Cell]:
    """Восстановить путь от старта до end по словарю предшественников."""
    path, node = [], end
    while node is not None:
        path.append(node)
        node = parent.get(node)
    path.reverse()
    return path


class PathFindingStrategy(ABC):
    """Интерфейс стратегии поиска пути."""

    @property
    @abstractmethod
    def name(self) -> str: ...

    @abstractmethod
    def find_path(
        self, maze: Maze, start: Cell, exit_cell: Cell
    ) -> Tuple[List[Cell], int]:
        """Возвращает (path, visited_count). path пуст если пути нет."""
        ...


class BFSStrategy(PathFindingStrategy):
    """Поиск в ширину — гарантирует кратчайший путь (по числу шагов)."""

    @property
    def name(self) -> str:
        return "BFS"

    def find_path(self, maze, start, exit_cell):
        queue = deque([start])
        parent: Dict[Cell, Optional[Cell]] = {start: None}
        visited = 0
        while queue:
            cur = queue.popleft()
            visited += 1
            if cur == exit_cell:
                return _reconstruct(parent, exit_cell), visited
            for nb in maze.get_neighbors(cur):
                if nb not in parent:
                    parent[nb] = cur
                    queue.append(nb)
        return [], visited


class DFSStrategy(PathFindingStrategy):
    """Поиск в глубину — не гарантирует кратчайший путь."""

    @property
    def name(self) -> str:
        return "DFS"

    def find_path(self, maze, start, exit_cell):
        stack = [start]
        parent: Dict[Cell, Optional[Cell]] = {start: None}
        visited = 0
        while stack:
            cur = stack.pop()
            visited += 1
            if cur == exit_cell:
                return _reconstruct(parent, exit_cell), visited
            for nb in maze.get_neighbors(cur):
                if nb not in parent:
                    parent[nb] = cur
                    stack.append(nb)
        return [], visited


class AStarStrategy(PathFindingStrategy):
    """A* с манхэттенской эвристикой — направленный поиск, учитывает веса."""

    @property
    def name(self) -> str:
        return "A*"

    @staticmethod
    def _h(a: Cell, b: Cell) -> int:
        return abs(a.x - b.x) + abs(a.y - b.y)

    def find_path(self, maze, start, exit_cell):
        heap: List[Tuple[int, int, Cell]] = [(0, 0, start)]
        g: Dict[Cell, int] = {start: 0}
        parent: Dict[Cell, Optional[Cell]] = {start: None}
        visited = 0
        while heap:
            _, g_cur, cur = heapq.heappop(heap)
            visited += 1
            if cur == exit_cell:
                return _reconstruct(parent, exit_cell), visited
            if g_cur > g.get(cur, float('inf')):
                continue
            for nb in maze.get_neighbors(cur):
                new_g = g_cur + nb.weight
                if new_g < g.get(nb, float('inf')):
                    g[nb] = new_g
                    parent[nb] = cur
                    heapq.heappush(heap, (new_g + self._h(nb, exit_cell), new_g, nb))
        return [], visited


class DijkstraStrategy(PathFindingStrategy):
    """Дейкстра — оптимален для взвешенных графов, без эвристики."""

    @property
    def name(self) -> str:
        return "Dijkstra"

    def find_path(self, maze, start, exit_cell):
        dist: Dict[Cell, int] = {start: 0}
        parent: Dict[Cell, Optional[Cell]] = {start: None}
        heap: List[Tuple[int, Cell]] = [(0, start)]
        visited = 0
        while heap:
            d, cur = heapq.heappop(heap)
            visited += 1
            if cur == exit_cell:
                return _reconstruct(parent, exit_cell), visited
            if d > dist.get(cur, float('inf')):
                continue
            for nb in maze.get_neighbors(cur):
                new_d = d + nb.weight
                if new_d < dist.get(nb, float('inf')):
                    dist[nb] = new_d
                    parent[nb] = cur
                    heapq.heappush(heap, (new_d, nb))
        return [], visited



# ПАТТЕРН OBSERVER 


@dataclass
class SearchStats:
    time_ms:       float
    visited_cells: int
    path_length:   int
    strategy_name: str

    def __str__(self) -> str:
        return (f"[{self.strategy_name}] "
                f"время={self.time_ms:.2f} мс  "
                f"посещено={self.visited_cells}  "
                f"длина пути={self.path_length}")


class Observer(ABC):
    @abstractmethod
    def update(self, event: dict) -> None: ...


class ConsoleView(Observer):
    """Выводит события и рисует лабиринт в консоли."""

    def update(self, event: dict) -> None:
        kind = event.get("type")
        if kind == "maze_loaded":
            print(f"[Лабиринт загружен] {event['width']}×{event['height']}")
        elif kind == "search_start":
            print(f"[Поиск] алгоритм={event['strategy']}")
        elif kind == "path_found":
            print(f"[Готово] {event['stats']}")
        elif kind == "no_path":
            print("[Результат] Путь не найден!")

    def render(
        self,
        maze: Maze,
        player_pos: Optional[Cell] = None,
        path: Optional[List[Cell]] = None,
    ) -> None:
        path_set = set(path) if path else set()
        for y in range(maze.height):
            row = []
            for x in range(maze.width):
                cell = maze.get_cell(x, y)
                if cell is None:
                    row.append("?")
                elif player_pos and cell == player_pos:
                    row.append("@")
                elif cell.is_wall:
                    row.append("█")
                elif cell.is_start:
                    row.append("S")
                elif cell.is_exit:
                    row.append("E")
                elif cell in path_set:
                    row.append("*")
                elif cell.weight == 3:
                    row.append("~")
                elif cell.weight == 2:
                    row.append(".")
                else:
                    row.append(" ")
            print("".join(row))
        print()



# ЭТАП 4. ОРКЕСТРАТОР MazeSolver

class MazeSolver:
    """
    Связывает Maze + PathFindingStrategy + список Observer.
    Паттерны: Strategy (алгоритм подключается снаружи),
              Observer (уведомления при завершении).
    """

    def __init__(self, maze: Maze, strategy: PathFindingStrategy):
        self.maze       = maze
        self._strategy  = strategy
        self._observers: List[Observer] = []
        self._last_path: List[Cell]     = []

    def add_observer(self, obs: Observer) -> None:
        self._observers.append(obs)

    def set_strategy(self, strategy: PathFindingStrategy) -> None:
        self._strategy = strategy

    def _notify(self, event: dict) -> None:
        for obs in self._observers:
            obs.update(event)

    def solve(self) -> SearchStats:
        if not self.maze.start or not self.maze.exit:
            raise ValueError("Лабиринт не содержит старт или выход")

        self._notify({"type": "search_start", "strategy": self._strategy.name})

        t0 = time.perf_counter()
        path, visited = self._strategy.find_path(
            self.maze, self.maze.start, self.maze.exit
        )
        elapsed_ms = (time.perf_counter() - t0) * 1000

        self._last_path = path
        stats = SearchStats(elapsed_ms, visited, len(path), self._strategy.name)

        self._notify({"type": "path_found" if path else "no_path", "stats": stats})
        return stats

    @property
    def last_path(self) -> List[Cell]:
        return self._last_path



# ЭТАП 5. ПАТТЕРН COMMAND — пошаговое управление игроком


_DIRECTIONS = {'W': (0, -1), 'S': (0, 1), 'A': (-1, 0), 'D': (1, 0)}


class Command(ABC):
    @abstractmethod
    def execute(self) -> bool: ...
    @abstractmethod
    def undo(self) -> None: ...


class Player:
    def __init__(self, cell: Cell):
        self.current_cell = cell

    def move_to(self, cell: Cell) -> None:
        self.current_cell = cell


class MoveCommand(Command):
    """Перемещение игрока в направлении direction (W/A/S/D)."""

    def __init__(self, player: Player, direction: str, maze: Maze,
                 observers: Optional[List[Observer]] = None):
        self._player    = player
        self._direction = direction.upper()
        self._maze      = maze
        self._observers = observers or []
        self._prev:  Optional[Cell] = None

    def execute(self) -> bool:
        dx, dy = _DIRECTIONS.get(self._direction, (0, 0))
        target = self._maze.get_cell(
            self._player.current_cell.x + dx,
            self._player.current_cell.y + dy,
        )
        if target and target.is_passable():
            self._prev = self._player.current_cell
            self._player.move_to(target)
            for obs in self._observers:
                obs.update({"type": "move", "cell": target})
            return True
        return False

    def undo(self) -> None:
        if self._prev:
            self._player.move_to(self._prev)
            self._prev = None


class CommandHistory:
    """Стек выполненных команд (undo/redo)."""

    def __init__(self):
        self._stack: List[Command] = []

    def execute(self, cmd: Command) -> bool:
        ok = cmd.execute()
        if ok:
            self._stack.append(cmd)
        return ok

    def undo(self) -> bool:
        if self._stack:
            self._stack.pop().undo()
            return True
        return False



# ЭТАП 6. ЭКСПЕРИМЕНТАЛЬНАЯ ЧАСТЬ


def run_experiment(
    maze_files: List[Tuple[str, str]],
    strategies: List[PathFindingStrategy],
    repeats: int = 7,
    out_csv: str = "results.csv",
) -> None:
    builder = TextFileMazeBuilder()
    rows = [["maze", "strategy", "run", "time_ms", "visited_cells", "path_length"]]

    for maze_name, maze_file in maze_files:
        print(f"\n=== {maze_name} ===")
        maze = builder.build_from_file(maze_file)
        print(f"  Размер: {maze.width}×{maze.height}")

        for strategy in strategies:
            solver = MazeSolver(maze, strategy)
            times, visits, lengths = [], [], []

            for run in range(1, repeats + 1):
                stats = solver.solve()
                times.append(stats.time_ms)
                visits.append(stats.visited_cells)
                lengths.append(stats.path_length)
                rows.append([maze_name, strategy.name, run,
                             round(stats.time_ms, 4),
                             stats.visited_cells,
                             stats.path_length])

            print(f"  {strategy.name:10} | "
                  f"t={statistics.mean(times):.3f} мс | "
                  f"посещено={statistics.mean(visits):.0f} | "
                  f"путь={statistics.mean(lengths):.0f}")

    os.makedirs(os.path.dirname(os.path.abspath(out_csv)), exist_ok=True)
    with open(out_csv, "w", newline="", encoding="utf-8") as f:
        csv.writer(f).writerows(rows)
    print(f"\nСохранено: {out_csv}")



# CLI


_ALGO_MAP = {
    "bfs":      BFSStrategy(),
    "dfs":      DFSStrategy(),
    "astar":    AStarStrategy(),
    "dijkstra": DijkstraStrategy(),
}

_MAZES = [
    ("small_10x10",    "small_10x10.txt"),
    ("medium_50x50",   "medium_50x50.txt"),
    ("large_100x100",  "large_100x100.txt"),
    ("empty_30x30",    "empty_30x30.txt"),
    ("no_exit_20x20",  "no_exit_20x20.txt"),
    ("weighted_40x40", "weighted_40x40.txt"),
]


def cmd_solve(maze_file: str, algo: str) -> None:
    view    = ConsoleView()
    maze    = TextFileMazeBuilder().build_from_file(maze_file)
    view.update({"type": "maze_loaded", "width": maze.width, "height": maze.height})

    strategy = _ALGO_MAP.get(algo.lower())
    if not strategy:
        print(f"Неизвестный алгоритм '{algo}'. Доступны: {', '.join(_ALGO_MAP)}")
        return

    solver = MazeSolver(maze, strategy)
    solver.add_observer(view)
    solver.solve()
    view.render(maze, path=solver.last_path)


def cmd_walk(maze_file: str) -> None:
    view    = ConsoleView()
    maze    = TextFileMazeBuilder().build_from_file(maze_file)
    player  = Player(maze.start)
    history = CommandHistory()

    solver = MazeSolver(maze, BFSStrategy())
    solver.add_observer(view)
    solver.solve()
    path = solver.last_path

    print("W=вверх  S=вниз  A=влево  D=вправо  Z=отмена  Q=выход")
    while True:
        view.render(maze, player_pos=player.current_cell, path=path)
        if player.current_cell == maze.exit:
            print("Вы достигли выхода!")
            break
        key = input("Ход: ").strip().upper()
        if key == "Q":
            break
        elif key == "Z":
            if not history.undo():
                print("Нечего отменять.")
        elif key in _DIRECTIONS:
            if not history.execute(MoveCommand(player, key, maze, [view])):
                print("Туда нельзя — стена.")
        else:
            print("Неизвестная клавиша.")


def cmd_experiment() -> None:
    run_experiment(_MAZES, list(_ALGO_MAP.values()))


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Maze solver")
    parser.add_argument("--solve",      metavar="FILE", help="Решить лабиринт")
    parser.add_argument("--algo",       metavar="ALGO", default="bfs",
                        help="bfs | dfs | astar | dijkstra")
    parser.add_argument("--walk",       metavar="FILE", help="Ручное управление (WASD)")
    parser.add_argument("--experiment", action="store_true",
                        help="Запустить замеры и сохранить CSV")
    args = parser.parse_args()

    if args.solve:
        cmd_solve(args.solve, args.algo)
    elif args.walk:
        cmd_walk(args.walk)
    elif args.experiment:
        cmd_experiment()
    else:
        parser.print_help()