import time
import csv
from collections import deque
from heapq import heappush, heappop
from abc import ABC, abstractmethod
from typing import List, Optional, Tuple, Dict, Any
import os

RESULTS_DIR = "lab2_results"

class Cell:
    def __init__(self, x: int, y: int, is_wall: bool = False):
        self.x = x
        self.y = y
        self.is_wall = is_wall
        self.is_start = False
        self.is_exit = False

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

    def __repr__(self):
        return f"Cell({self.x},{self.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 set_cell(self, x: int, y: int, cell: Cell):
        if not self.cells:
            self.cells = [[None] * self.width for _ in range(self.height)]
        self.cells[y][x] = cell

    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]:
        neighbors = []
        for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            nx, ny = cell.x + dx, cell.y + dy
            neighbor = self.get_cell(nx, ny)
            if neighbor and neighbor.is_passable():
                neighbors.append(neighbor)
        return neighbors

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

class TextFileMazeBuilder(MazeBuilder):
    def build_from_file(self, filename: str) -> Maze:
        with open(filename, 'r', encoding='utf-8') as f:
            lines = [line.rstrip('\n') for line in f.readlines()]

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

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

        start_cell = None
        exit_cell = None

        for y, line in enumerate(lines):
            for x, ch in enumerate(line):
                is_wall = (ch == '#')
                cell = Cell(x, y, is_wall)
                if ch == 'S':
                    cell.is_start = True
                    start_cell = cell
                elif ch == 'E':
                    cell.is_exit = True
                    exit_cell = cell
                maze.set_cell(x, y, cell)

        if start_cell is None or exit_cell is None:
            for y in range(height):
                for x in range(width):
                    cell = maze.get_cell(x, y)
                    if cell and cell.is_start:
                        start_cell = cell
                    if cell and cell.is_exit:
                        exit_cell = cell

        if start_cell is None:
            raise ValueError("Нет стартовой клетки (S)")
        if exit_cell is None:
            raise ValueError("Нет выходной клетки (E)")

        maze.start = start_cell
        maze.exit = exit_cell
        return maze

class PathFindingStrategy(ABC):
    @abstractmethod
    def find_path(self, maze: Maze, start: Cell, exit: Cell) -> List[Cell]:
        pass

class BFSStrategy(PathFindingStrategy):
    def find_path(self, maze: Maze, start: Cell, exit: Cell) -> List[Cell]:
        if start == exit:
            self.last_visited = 1
            return [start]

        queue = deque()
        queue.append(start)
        parent = {start: None}
        visited = {start}
        visited_count = 1

        while queue:
            current = queue.popleft()
            if current == exit:
                break
            for neighbor in maze.get_neighbors(current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    visited_count += 1
                    parent[neighbor] = current
                    queue.append(neighbor)

        self.last_visited = visited_count
        if exit not in parent:
            return []

        path = []
        cur = exit
        while cur is not None:
            path.append(cur)
            cur = parent[cur]
        path.reverse()
        return path

class DFSStrategy(PathFindingStrategy):
    def find_path(self, maze: Maze, start: Cell, exit: Cell) -> List[Cell]:
        stack = [(start, [start])]
        visited = {start}
        visited_count = 1

        while stack:
            current, path = stack.pop()
            if current == exit:
                self.last_visited = visited_count
                return path
            for neighbor in maze.get_neighbors(current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    visited_count += 1
                    stack.append((neighbor, path + [neighbor]))
        self.last_visited = visited_count
        return []

class AStarStrategy(PathFindingStrategy):
    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) -> List[Cell]:
        open_set = []
        counter = 0
        heappush(open_set, (0, counter, start))
        came_from = {}
        g_score = {start: 0}
        f_score = {start: self.heuristic(start, exit)}
        visited_count = 0

        while open_set:
            _, _, current = heappop(open_set)
            visited_count += 1
            if current == exit:
                path = []
                while current in came_from:
                    path.append(current)
                    current = came_from[current]
                path.append(start)
                path.reverse()
                self.last_visited = visited_count
                return path

            for neighbor in maze.get_neighbors(current):
                tentative_g = g_score[current] + 1
                if neighbor not in g_score or tentative_g < g_score[neighbor]:
                    came_from[neighbor] = current
                    g_score[neighbor] = tentative_g
                    f = tentative_g + self.heuristic(neighbor, exit)
                    f_score[neighbor] = f
                    counter += 1
                    heappush(open_set, (f, counter, neighbor))
        self.last_visited = visited_count
        return []

class SearchStats:
    def __init__(self, time_ms: float, visited_cells: int, path_length: int):
        self.time_ms = time_ms
        self.visited_cells = visited_cells
        self.path_length = path_length

    def __repr__(self):
        return f"Stats(time={self.time_ms:.2f}ms, visited={self.visited_cells}, path_len={self.path_length})"

class MazeSolver:
    def __init__(self, maze: Maze, strategy: PathFindingStrategy):
        self.maze = maze
        self.strategy = strategy
        self.observers = []

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

    def attach(self, observer):
        self.observers.append(observer)

    def detach(self, observer):
        self.observers.remove(observer)

    def notify(self, event: str, data: Any = None):
        for obs in self.observers:
            obs.update(event, data)

    def solve(self) -> Tuple[List[Cell], SearchStats]:
        start_time = time.perf_counter()
        path = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit)
        end_time = time.perf_counter()
        elapsed_ms = (end_time - start_time) * 1000.0
        visited_cells = getattr(self.strategy, 'last_visited', len(path) if path else 0)
        stats = SearchStats(elapsed_ms, visited_cells, len(path))
        self.notify("solved", {"path": path, "stats": stats})
        return path, stats

class Observer(ABC):
    @abstractmethod
    def update(self, event: str, data: Any):
        pass

class ConsoleView(Observer):
    def __init__(self):
        self.player_pos = None
        self.path = []

    def update(self, event: str, data: Any):
        if event == "maze_loaded":
            self.maze = data["maze"]
            self.render()
        elif event == "player_moved":
            self.player_pos = data["player_cell"]
            self.render()
        elif event == "path_found":
            self.path = data["path"]
            self.render()
        elif event == "solved":
            self.path = data["path"]
            self.render()

    def render(self, maze: Maze = None, player_cell: Cell = None, path: List[Cell] = None):
        if maze:
            self.maze = maze
        if player_cell:
            self.player_pos = player_cell
        if path is not None:
            self.path = path

        if not hasattr(self, 'maze'):
            print("Нет лабиринта для отображения")
            return

        for y in range(self.maze.height):
            row = ""
            for x in range(self.maze.width):
                cell = self.maze.get_cell(x, y)
                if cell is None:
                    row += " "
                    continue
                if self.player_pos and cell == self.player_pos:
                    row += "P"
                elif cell == self.maze.start:
                    row += "S"
                elif cell == self.maze.exit:
                    row += "E"
                elif self.path and cell in self.path:
                    row += "."
                elif cell.is_wall:
                    row += "#"
                else:
                    row += " "
            print(row)
        print()

class MoveCommand(ABC):
    @abstractmethod
    def execute(self):
        pass
    @abstractmethod
    def undo(self):
        pass   
     
class Player:
    def __init__(self, start_cell: Cell):
        self.current_cell = start_cell

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

class MoveCommandImpl(MoveCommand):
    def __init__(self, player: Player, direction: str, maze: Maze):
        self.player = player
        self.direction = direction
        self.maze = maze
        self.previous_cell = player.current_cell

    def execute(self):
        dx, dy = 0, 0
        if self.direction == 'w':
            dy = -1
        elif self.direction == 's':
            dy = 1
        elif self.direction == 'a':
            dx = -1
        elif self.direction == 'd':
            dx = 1
        else:
            return False

        new_x = self.player.current_cell.x + dx
        new_y = self.player.current_cell.y + dy
        new_cell = self.maze.get_cell(new_x, new_y)
        if new_cell and new_cell.is_passable():
            self.player.move_to(new_cell)
            return True
        return False

    def undo(self):
        self.player.move_to(self.previous_cell)

def ensure_results_dir():
    if not os.path.exists(RESULTS_DIR):
        os.makedirs(RESULTS_DIR)
        print(f"Создана папка: {RESULTS_DIR}")

def generate_test_maze_file(filename: str, maze_type: str):
    full_path = os.path.join(RESULTS_DIR, filename)
    if maze_type == "small":
        lines = [
            "##########",
            "#S       #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "#       E#",
            "##########"
        ]
    elif maze_type == "medium":
        height, width = 50, 50
        lines = []
        for y in range(height):
            row = []
            for x in range(width):
                if y == 0 or y == height-1 or x == 0 or x == width-1:
                    row.append('#')
                elif (y % 5 == 0 and x % 7 == 0) or (y % 8 == 0 and x % 3 == 0):
                    row.append('#')
                else:
                    row.append(' ')
            row_str = ''.join(row)
            lines.append(row_str)
        lines[1] = 'S' + lines[1][1:]
        lines[height-2] = lines[height-2][:width-2] + 'E' + lines[height-2][width-1:]
    elif maze_type == "large":
        import random
        height, width = 100, 100
        random.seed(42)
        lines = []
        for y in range(height):
            row = []
            for x in range(width):
                if y == 0 or y == height-1 or x == 0 or x == width-1:
                    row.append('#')
                else:
                    if random.random() < 0.2:
                        row.append('#')
                    else:
                        row.append(' ')
            lines.append(''.join(row))
        lines[1] = 'S' + lines[1][1:]
        lines[height-2] = lines[height-2][:width-2] + 'E' + lines[height-2][width-1:]
    elif maze_type == "empty":
        height, width = 50, 50
        lines = []
        for y in range(height):
            if y == 0 or y == height-1:
                lines.append('#' * width)
            else:
                lines.append('#' + ' ' * (width-2) + '#')
        lines[1] = 'S' + lines[1][1:]
        lines[height-2] = lines[height-2][:width-2] + 'E' + lines[height-2][width-1:]
    elif maze_type == "no_exit":
        lines = [
            "##########",
            "#S       #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "#        #",
            "##########"
        ]
    else:
        raise ValueError("Unknown maze type")

    with open(full_path, 'w', encoding='utf-8') as f:
        f.write('\n'.join(lines))

def run_experiment():
    ensure_results_dir()
    maze_types = ["small", "medium", "large", "empty", "no_exit"]
    strategies = {
        "BFS": BFSStrategy(),
        "DFS": DFSStrategy(),
        "AStar": AStarStrategy()
    }
    results = []

    for maze_type in maze_types:
        filename = f"maze_{maze_type}.txt"
        generate_test_maze_file(filename, maze_type)
        full_path = os.path.join(RESULTS_DIR, filename)
        builder = TextFileMazeBuilder()
        try:
            maze = builder.build_from_file(full_path)
        except ValueError as e:
            print(f"Лабиринт {maze_type} пропущен: {e}")
            continue

        for strat_name, strat_obj in strategies.items():
            times = []
            path_lengths = []
            visited_counts = []
            for run in range(5):
                solver = MazeSolver(maze, strat_obj)
                path, stats = solver.solve()
                times.append(stats.time_ms)
                path_lengths.append(stats.path_length)
                visited_counts.append(stats.visited_cells)
            avg_time = sum(times) / len(times)
            avg_path_len = sum(path_lengths) / len(path_lengths)
            avg_visited = sum(visited_counts) / len(visited_counts)
            results.append({
                "maze": maze_type,
                "strategy": strat_name,
                "avg_time_ms": avg_time,
                "avg_visited": avg_visited,
                "avg_path_length": avg_path_len
            })
            print(f"{maze_type} / {strat_name}: время={avg_time:.2f}ms, посещено={avg_visited:.1f}, путь={avg_path_len:.1f}")

    csv_path = os.path.join(RESULTS_DIR, "experiment_results.csv")
    with open(csv_path, "w", newline='', encoding='utf-8') as f:
        writer = csv.DictWriter(f, fieldnames=["maze", "strategy", "avg_time_ms", "avg_visited", "avg_path_length"])
        writer.writeheader()
        writer.writerows(results)
    try:
        import matplotlib.pyplot as plt
        for maze_type in ["small", "medium", "large", "empty"]:
            data = [r for r in results if r["maze"] == maze_type]
            if not data:
                continue
            names = [d["strategy"] for d in data]
            times = [d["avg_time_ms"] for d in data]
            visited = [d["avg_visited"] for d in data]
            fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
            ax1.bar(names, times)
            ax1.set_title(f"Время (мс) - {maze_type}")
            ax2.bar(names, visited)
            ax2.set_title(f"Посещено клеток - {maze_type}")
            plt.tight_layout()
            plot_path = os.path.join(RESULTS_DIR, f"plot_{maze_type}.png")
            plt.savefig(plot_path)
            plt.close()
        print(f"Графики сохранены в папку {RESULTS_DIR}")
    except ImportError:
        print("matplotlib не установлен. Графики не построены.")

def demo_interactive():
    ensure_results_dir()
    builder = TextFileMazeBuilder()
    filename = input("Введите имя файла с лабиринтом (например, maze_small.txt): ").strip()
    if not os.path.exists(filename) and not os.path.exists(os.path.join(RESULTS_DIR, filename)):
        print(f"Файл {filename} не найден. Создаю тестовый лабиринт small в папке {RESULTS_DIR}")
        generate_test_maze_file("demo_maze.txt", "small")
        filename = os.path.join(RESULTS_DIR, "demo_maze.txt")
    elif os.path.exists(os.path.join(RESULTS_DIR, filename)):
        filename = os.path.join(RESULTS_DIR, filename)

    maze = builder.build_from_file(filename)
    view = ConsoleView()
    view.update("maze_loaded", {"maze": maze})

    print("Выберите алгоритм поиска:")
    print("1. BFS")
    print("2. DFS")
    print("3. A*")
    choice = input("Ваш выбор: ")
    if choice == "1":
        strategy = BFSStrategy()
    elif choice == "2":
        strategy = DFSStrategy()
    else:
        strategy = AStarStrategy()

    solver = MazeSolver(maze, strategy)
    solver.attach(view)
    path, stats = solver.solve()
    print(f"Поиск завершён. Статистика: {stats}")

    if path:
        print("Найден путь. Хотите пройти по нему пошагово? (y/n): ", end="")
        ans = input().lower()
        if ans == 'y':
            player = Player(maze.start)
            cmd_history = []
            for step_cell in path[1:]:
                dx = step_cell.x - player.current_cell.x
                dy = step_cell.y - player.current_cell.y
                if dx == 1:
                    dir_char = 'd'
                elif dx == -1:
                    dir_char = 'a'
                elif dy == 1:
                    dir_char = 's'
                else:
                    dir_char = 'w'
                cmd = MoveCommandImpl(player, dir_char, maze)
                if cmd.execute():
                    cmd_history.append(cmd)
                    view.update("player_moved", {"player_cell": player.current_cell})
                    input("Нажмите Enter для следующего шага...")
            print("Вы достигли выхода!")
            print("Отменить последний шаг? (y/n): ", end="")
            if input().lower() == 'y' and cmd_history:
                cmd_history[-1].undo()
                view.update("player_moved", {"player_cell": player.current_cell})
                print("Последний шаг отменён.")
    else:
        print("Путь не найден.")

if __name__ == "__main__":
    print("Лабораторная работа: Поиск выхода из лабиринта")
    print("1. Запустить эксперименты (сравнение алгоритмов)")
    print("2. Интерактивный режим (загрузка своего лабиринта)")
    mode = input("Выберите режим (1 или 2): ")
    if mode == "1":
        run_experiment()
    elif mode == "2":
        demo_interactive()
    else:
        print("Неверный выбор.")