
import os
import time
import heapq
import csv
import random
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass
from typing import Optional


#1. Модель лабиринта — классы Cell и Maze

class Cell:
    """Клетка лабиринта."""

    def __init__(self, x: int, y: int, is_wall: bool = False,
                 is_start: bool = False, is_exit: bool = False, weight: int = 1):
        self.x = x
        self.y = y
        self.is_wall = is_wall
        self.is_start = is_start
        self.is_exit = is_exit
        self.weight = weight  # для взвешенного лабиринта (Этап 6 доп.)

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

    def __repr__(self):
        return f"Cell({self.x},{self.y})"

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

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

    def __lt__(self, other):
        return (self.x, self.y) < (other.x, other.y)


class Maze:
    """Лабиринт — двумерный массив клеток."""

    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height
        self.cells: list[list[Cell]] = []
        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]:
        """Возвращает проходимых соседей (вверх, вниз, влево, вправо)."""
        directions = [(0, -1), (0, 1), (-1, 0), (1, 0)]
        neighbors = []
        for dx, dy in directions:
            neighbor = self.get_cell(cell.x + dx, cell.y + dy)
            if neighbor and neighbor.is_passable():
                neighbors.append(neighbor)
        return neighbors


#2. Загрузка лабиринта из файла — паттерн Builder

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

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


class TextFileMazeBuilder(MazeBuilder):

    WEIGHT_MAP = {' ': 1, 'S': 1, 'E': 1, '.': 2, '~': 3, '#': 0}

    def build_from_file(self, filename: str) -> Maze:
        with open(filename, 'r', encoding='utf-8') as f:
            lines = f.read().splitlines()

        if not lines:
            raise ValueError("Файл лабиринта пуст")

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

        for y, line in enumerate(lines):
            row = []
            for x in range(width):
                ch = line[x] if x < len(line) else ' '
                is_wall = ch == '#'
                is_start = ch == 'S'
                is_exit = ch == 'E'
                weight = self.WEIGHT_MAP.get(ch, 1)
                cell = Cell(x, y, is_wall=is_wall, is_start=is_start,
                            is_exit=is_exit, weight=weight)
                row.append(cell)
                if is_start:
                    maze.start = cell
                if is_exit:
                    maze.exit = cell
            maze.cells.append(row)

        if maze.start is None:
            raise ValueError("В лабиринте не задана стартовая клетка (S)")
        if maze.exit is None:
            raise ValueError("В лабиринте не задана выходная клетка (E)")

        return maze


#3. Стратегии поиска пути — паттерн Strategy

@dataclass
class SearchStats:
    """Статистика одного запуска поиска."""
    time_ms: float = 0.0
    visited_cells: int = 0
    path_length: int = 0


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

    @abstractmethod
    def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]:
        pass

    def _reconstruct_path(self, parent: dict, start: Cell, exit_cell: Cell) -> list[Cell]:
        path = []
        current = exit_cell
        while current is not None:
            path.append(current)
            current = parent.get(current)
        path.reverse()
        if path and path[0] == start:
            return path
        return []


class BFSStrategy(PathFindingStrategy):
    """Поиск в ширину"""

    def __init__(self):
        self.visited_count = 0

    def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]:
        queue = deque([start])
        parent: dict[Cell, Optional[Cell]] = {start: None}
        self.visited_count = 0

        while queue:
            current = queue.popleft()
            self.visited_count += 1

            if current == exit_cell:
                return self._reconstruct_path(parent, start, exit_cell)

            for neighbor in maze.get_neighbors(current):
                if neighbor not in parent:
                    parent[neighbor] = current
                    queue.append(neighbor)

        return []


class DFSStrategy(PathFindingStrategy):
    """Поиск в глубину"""

    def __init__(self):
        self.visited_count = 0

    def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]:
        stack = [start]
        parent: dict[Cell, Optional[Cell]] = {start: None}
        self.visited_count = 0

        while stack:
            current = stack.pop()
            self.visited_count += 1

            if current == exit_cell:
                return self._reconstruct_path(parent, start, exit_cell)

            for neighbor in maze.get_neighbors(current):
                if neighbor not in parent:
                    parent[neighbor] = current
                    stack.append(neighbor)

        return []


class AStarStrategy(PathFindingStrategy):
    """A* с манхэттенской эвристикой"""

    def __init__(self):
        self.visited_count = 0

    def _heuristic(self, a: Cell, b: Cell) -> int:
        return abs(a.x - b.x) + abs(a.y - b.y)

    def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]:
        g_score = {start: 0}
        parent: dict[Cell, Optional[Cell]] = {start: None}
        open_heap = [(self._heuristic(start, exit_cell), 0, start)]
        closed_set: set[Cell] = set()  # уже обработанные клетки
        self.visited_count = 0
        counter = 0  # счётчик для устранения неоднозначности

        while open_heap:
            _, _, current = heapq.heappop(open_heap)

            if current in closed_set:
                continue
            closed_set.add(current)
            self.visited_count += 1

            if current == exit_cell:
                return self._reconstruct_path(parent, start, exit_cell)

            for neighbor in maze.get_neighbors(current):
                if neighbor in closed_set:
                    continue
                tentative_g = g_score[current] + neighbor.weight
                if tentative_g < g_score.get(neighbor, float('inf')):
                    g_score[neighbor] = tentative_g
                    parent[neighbor] = current
                    f = tentative_g + self._heuristic(neighbor, exit_cell)
                    counter += 1
                    heapq.heappush(open_heap, (f, counter, neighbor))

        return []


class DijkstraStrategy(PathFindingStrategy):
    """Дейкстра"""

    def __init__(self):
        self.visited_count = 0

    def find_path(self, maze: Maze, start: Cell, exit_cell: Cell) -> list[Cell]:
        dist = {start: 0}
        parent: dict[Cell, Optional[Cell]] = {start: None}
        open_heap = [(0, 0, start)]
        self.visited_count = 0
        counter = 0

        while open_heap:
            cost, _, current = heapq.heappop(open_heap)
            if cost > dist.get(current, float('inf')):
                continue
            self.visited_count += 1

            if current == exit_cell:
                return self._reconstruct_path(parent, start, exit_cell)

            for neighbor in maze.get_neighbors(current):
                new_cost = dist[current] + neighbor.weight
                if new_cost < dist.get(neighbor, float('inf')):
                    dist[neighbor] = new_cost
                    parent[neighbor] = current
                    counter += 1
                    heapq.heappush(open_heap, (new_cost, counter, neighbor))

        return []


#4. Оркестратор — MazeSolver

class MazeSolver:
    """Оркестратор"""

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

    def set_strategy(self, strategy: PathFindingStrategy):
        self.strategy = strategy

    def add_observer(self, observer: 'Observer'):
        self._observers.append(observer)

    def _notify(self, event: str, **kwargs):
        for obs in self._observers:
            obs.update(event, **kwargs)

    def solve(self) -> SearchStats:
        start = self.maze.start
        exit_cell = self.maze.exit
        self._notify("search_start", strategy=type(self.strategy).__name__)

        t0 = time.perf_counter()
        path = self.strategy.find_path(self.maze, start, exit_cell)
        t1 = time.perf_counter()

        self._last_path = path
        visited = getattr(self.strategy, 'visited_count', 0)
        stats = SearchStats(
            time_ms=(t1 - t0) * 1000,
            visited_cells=visited,
            path_length=len(path)
        )
        self._notify("path_found", path=path, stats=stats)
        return stats

    def get_last_path(self) -> list[Cell]:
        return self._last_path


#5.1. Observer — ConsoleView

class Observer(ABC):
    """Интерфейс наблюдателя."""

    @abstractmethod
    def update(self, event: str, **kwargs):
        pass


class ConsoleView(Observer):
    """Консольный вид"""

    SYMBOLS = {
        'wall': '█',
        'path': '·',
        'start': 'S',
        'exit': 'E',
        'player': '@',
        'visited': '°',
        'empty': ' ',
    }

    def update(self, event: str, **kwargs):
        if event == "search_start":
            print(f"\n[Поиск] Алгоритм: {kwargs.get('strategy', '?')}")
        elif event == "path_found":
            path = kwargs.get('path', [])
            stats = kwargs.get('stats')
            if path:
                print(f"[Готово] Путь найден! Длина: {stats.path_length}, "
                      f"Посещено: {stats.visited_cells}, Время: {stats.time_ms:.2f} мс")
            else:
                print("[Готово] Путь не найден!")
        elif event == "move":
            cell = kwargs.get('cell')
            print(f"[Ход] Игрок перемещается в {cell}")

    def render(self, maze: Maze, player_pos: Optional[Cell] = None,
               path: Optional[list[Cell]] = None):
        """Рисует лабиринт в консоли."""
        path_set = set(path) if path else set()
        print()
        for y in range(maze.height):
            row = ''
            for x in range(maze.width):
                cell = maze.cells[y][x]
                if cell.is_wall:
                    row += self.SYMBOLS['wall']
                elif player_pos and cell == player_pos:
                    row += self.SYMBOLS['player']
                elif cell.is_start:
                    row += self.SYMBOLS['start']
                elif cell.is_exit:
                    row += self.SYMBOLS['exit']
                elif cell in path_set:
                    row += self.SYMBOLS['path']
                else:
                    row += self.SYMBOLS['empty']
            print(row)
        print()


#5.2. Command — Player и MoveCommand

class Player:
    """Игрок с текущей позицией в лабиринте."""

    def __init__(self, start_cell: Cell):
        self.current_cell = start_cell

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


class Command(ABC):
    """Интерфейс команды."""

    @abstractmethod
    def execute(self):
        pass

    @abstractmethod
    def undo(self):
        pass


class MoveCommand(Command):
    """Команда перемещения игрока в указанную клетку."""

    def __init__(self, player: Player, target_cell: Cell, solver: MazeSolver):
        self.player = player
        self.target_cell = target_cell
        self.previous_cell = player.current_cell
        self.solver = solver

    def execute(self):
        self.previous_cell = self.player.current_cell
        self.player.move_to(self.target_cell)
        self.solver._notify("move", cell=self.target_cell)

    def undo(self):
        self.player.move_to(self.previous_cell)
        self.solver._notify("move", cell=self.previous_cell)


#6. Экспериментальная часть

def generate_maze_file(filename: str, width: int, height: int,
                       wall_density: float = 0.3, no_exit: bool = False):
    """Генерирует случайный лабиринт и сохраняет в файл."""
    random.seed(42)
    grid = []
    for y in range(height):
        row = []
        for x in range(width):
            if x == 0 or y == 0 or x == width - 1 or y == height - 1:
                row.append('#')
            else:
                row.append('#' if random.random() < wall_density else ' ')
        grid.append(row)

    # Старт и выход
    grid[1][1] = 'S'
    grid[height - 2][width - 2] = 'E'
    if no_exit:
        ex, ey = width - 2, height - 2
        for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]:
            nx, ny = ex + dx, ey + dy
            if 0 < nx < width - 1 and 0 < ny < height - 1:
                grid[ny][nx] = '#'

    with open(filename, 'w', encoding='utf-8') as f:
        for row in grid:
            f.write(''.join(row) + '\n')


def run_experiments(mazes_config: list[dict], strategies: dict,
                    runs: int = 5) -> list[dict]:
    builder = TextFileMazeBuilder()
    results = []

    for maze_cfg in mazes_config:
        name = maze_cfg['name']
        filepath = maze_cfg['file']

        # Генерируем файл лабиринта
        if 'generate' in maze_cfg:
            gen = maze_cfg['generate']
            generate_maze_file(filepath, gen['width'], gen['height'],
                               gen.get('density', 0.3),
                               gen.get('no_exit', False))

        try:
            maze = builder.build_from_file(filepath)
        except Exception as e:
            print(f"[Пропуск] {name}: {e}")
            continue

        for strat_name, strategy_cls in strategies.items():
            times, visited, lengths = [], [], []
            for _ in range(runs):
                strategy = strategy_cls()
                solver = MazeSolver(maze, strategy)
                stats = solver.solve()
                times.append(stats.time_ms)
                visited.append(stats.visited_cells)
                lengths.append(stats.path_length)

            results.append({
                'лабиринт': name,
                'стратегия': strat_name,
                'время_мс': round(sum(times) / runs, 4),
                'посещено_клеток': int(sum(visited) / runs),
                'длина_пути': int(sum(lengths) / runs),
            })
            print(f"  {name} / {strat_name}: "
                  f"time={results[-1]['время_мс']:.3f}ms, "
                  f"visited={results[-1]['посещено_клеток']}, "
                  f"path={results[-1]['длина_пути']}")

    return results


def save_csv(results: list[dict], filename: str):
    if not results:
        return
    with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
        writer = csv.DictWriter(f, fieldnames=results[0].keys())
        writer.writeheader()
        writer.writerows(results)
    print(f"\nРезультаты сохранены в {filename}")


def print_table(results: list[dict]):
    if not results:
        print("Нет результатов.")
        return
    header = f"{'Лабиринт':<20} {'Стратегия':<12} {'Время,мс':>10} {'Посещено':>10} {'Путь':>8}"
    print("\n" + "=" * len(header))
    print(header)
    print("=" * len(header))
    for r in results:
        print(f"{r['лабиринт']:<20} {r['стратегия']:<12} "
              f"{r['время_мс']:>10.4f} {r['посещено_клеток']:>10} {r['длина_пути']:>8}")
    print("=" * len(header))


#Демонстрация

def demo_interactive(maze: Maze, solver: MazeSolver, view: ConsoleView):
    """Пошаговый режим с Command."""
    path = solver.get_last_path()
    if not path:
        print("Путь не найден — пошаговый режим недоступен.")
        return

    player = Player(maze.start)
    history: list[MoveCommand] = []
    view.render(maze, player_pos=player.current_cell, path=path)

    print("Пошаговый режим: [N] — следующий шаг, [U] — отмена, [Q] — выход")
    step_index = 1  # 0-й шаг — старт, уже там

    while True:
        cmd = input("Команда: ").strip().upper()
        if cmd == 'Q':
            break
        elif cmd == 'N':
            if step_index < len(path):
                move = MoveCommand(player, path[step_index], solver)
                move.execute()
                history.append(move)
                step_index += 1
                os.system('cls' if os.name == 'nt' else 'clear')
                view.render(maze, player_pos=player.current_cell, path=path)
                if player.current_cell == maze.exit:
                    print("🎉 Вы достигли выхода!")
                    break
            else:
                print("Вы уже в конце пути.")
        elif cmd == 'U':
            if history:
                move = history.pop()
                move.undo()
                step_index -= 1
                os.system('cls' if os.name == 'nt' else 'clear')
                view.render(maze, player_pos=player.current_cell, path=path)
            else:
                print("Нечего отменять.")
        else:
            print("Неизвестная команда.")


def main():
    print("=" * 60)
    print("  Поиск выхода из лабиринта — ООП + паттерны GoF")
    print("=" * 60)

    small_maze_file = "maze_small.txt"

    builder = TextFileMazeBuilder()
    maze = builder.build_from_file(small_maze_file)

    #2. Создаём представление (Observer)
    view = ConsoleView()

    #3. Демонстрация стратегий
    strategies = {
        'BFS': BFSStrategy,
        'DFS': DFSStrategy,
        'A*': AStarStrategy,
        'Дейкстра': DijkstraStrategy,
    }

    print(f"\nЛабиринт ({maze.width}×{maze.height}):")
    view.render(maze)

    for name, cls in strategies.items():
        strategy = cls()
        solver = MazeSolver(maze, strategy)
        solver.add_observer(view)
        stats = solver.solve()

    #Визуализация пути A*
    print("\n--- Путь, найденный A* ---")
    a_star = AStarStrategy()
    solver = MazeSolver(maze, a_star)
    solver.add_observer(view)
    solver.solve()
    view.render(maze, path=solver.get_last_path())

    #4. Пошаговый режим Command
    ans = input("Запустить пошаговый режим? (y/n): ").strip().lower()
    if ans == 'y':
        demo_interactive(maze, solver, view)

    #5. Экспериментальная часть
    print("\n" + "=" * 60)
    print("  Экспериментальная часть")
    print("=" * 60)

    mazes_config = [
        {
            'name': 'Маленький 10×10',
            'file': 'maze_small.txt',
        },
        {
            'name': 'Средний 50×50',
            'file': 'maze_medium.txt',
        },
        {
            'name': 'Большой 100×100',
            'file': 'maze_large.txt',
        },
        {
            'name': 'Пустой 50×50',
            'file': 'maze_empty.txt',
        },
        {
            'name': 'Без выхода 20×20',
            'file': 'maze_no_exit.txt',
        },
    ]

    all_strategies = {
        'BFS': BFSStrategy,
        'DFS': DFSStrategy,
        'A*': AStarStrategy,
        'Дейкстра': DijkstraStrategy,
    }

    print("\nЗапуск экспериментов (5 прогонов каждого)...")
    results = run_experiments(mazes_config, all_strategies, runs=5)
    print_table(results)
    save_csv(results, "results.csv")

    print("\nГотово!")


if __name__ == '__main__':
    main()