from abc import ABC, abstractmethod
from collections import deque
import heapq
import time
import csv
import random
import os

BASE = os.path.dirname(os.path.abspath(__file__))


class Cell:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.isWall = False
        self.isStart = False
        self.isExit = False

    def isPassable(self):
        return not self.isWall

    def __eq__(self, other):
        if other is None:
            return False
        return 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, height):
        self.width = width
        self.height = height
        self.grid = [[Cell(x, y) for y in range(height)] for x in range(width)]
        self.start = None
        self.exit = None

    def getCell(self, x, y):
        if 0 <= x < self.width and 0 <= y < self.height:
            return self.grid[x][y]
        return None

    def getNeighbors(self, cell):
        neighbors = []
        for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            n = self.getCell(cell.x + dx, cell.y + dy)
            if n and n.isPassable():
                neighbors.append(n)
        return neighbors


class MazeBuilder(ABC):
    @abstractmethod
    def buildFromFile(self, filename):
        pass


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

        height = len(lines)
        width = len(lines[0]) if height > 0 else 0

        for line in lines:
            if len(line) != width:
                raise ValueError("все строки должны быть одной длины")

        maze = Maze(width, height)

        for y in range(height):
            for x in range(width):
                ch = lines[y][x]
                cell = maze.getCell(x, y)
                if ch == "#":
                    cell.isWall = True
                elif ch == " ":
                    cell.isWall = False
                elif ch == "S":
                    cell.isWall = False
                    cell.isStart = True
                    maze.start = cell
                elif ch == "E":
                    cell.isWall = False
                    cell.isExit = True
                    maze.exit = cell
                else:
                    raise ValueError(f"неизвестный символ: {ch}")

        if maze.start is None:
            raise ValueError("нет старта (S)")
        if maze.exit is None:
            raise ValueError("нет выхода (E)")

        return maze


class PathFindingStrategy(ABC):
    @abstractmethod
    def findPath(self, maze, start, exit_cell):
        pass

    def _reconstruct(self, parent, exit_cell):
        path = []
        curr = exit_cell
        while curr is not None:
            path.append(curr)
            curr = parent.get(curr)
        path.reverse()
        return path


class BFSStrategy(PathFindingStrategy):
    def findPath(self, maze, start, exit_cell):
        if exit_cell is None:
            return []
        queue = deque([start])
        visited = {start}
        parent = {start: None}

        while queue:
            curr = queue.popleft()
            if curr == exit_cell:
                return self._reconstruct(parent, exit_cell)
            for n in maze.getNeighbors(curr):
                if n not in visited:
                    visited.add(n)
                    parent[n] = curr
                    queue.append(n)
        return []


class DFSStrategy(PathFindingStrategy):
    def findPath(self, maze, start, exit_cell):
        if exit_cell is None:
            return []
        stack = [start]
        visited = {start}
        parent = {start: None}

        while stack:
            curr = stack.pop()
            if curr == exit_cell:
                return self._reconstruct(parent, exit_cell)
            for n in maze.getNeighbors(curr):
                if n not in visited:
                    visited.add(n)
                    parent[n] = curr
                    stack.append(n)
        return []


class AStarStrategy(PathFindingStrategy):
    def _heuristic(self, cell, exit_cell):
        return abs(cell.x - exit_cell.x) + abs(cell.y - exit_cell.y)

    def findPath(self, maze, start, exit_cell):
        if exit_cell is None:
            return []
        open_set = []
        heapq.heappush(open_set, (0, start))
        parent = {start: None}
        g_score = {start: 0}

        while open_set:
            curr = heapq.heappop(open_set)[1]
            if curr == exit_cell:
                return self._reconstruct(parent, exit_cell)

            for n in maze.getNeighbors(curr):
                new_g = g_score[curr] + 1
                if n not in g_score or new_g < g_score[n]:
                    g_score[n] = new_g
                    parent[n] = curr
                    f = new_g + self._heuristic(n, exit_cell)
                    heapq.heappush(open_set, (f, n))
        return []


class SearchStats:
    def __init__(self, time_ms, visited, path_len):
        self.time_ms = time_ms
        self.visited_cells = visited
        self.path_length = path_len


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

    def setStrategy(self, strategy):
        self.strategy = strategy

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

    def notify(self, event):
        for obs in self.observers:
            obs.update(event)

    def solve(self):
        if self.strategy is None:
            raise ValueError("стратегия не выбрана")

        start_time = time.perf_counter()
        path = self.strategy.findPath(self.maze, self.maze.start, self.maze.exit)
        elapsed_ms = (time.perf_counter() - start_time) * 1000

        stats = SearchStats(elapsed_ms, len(path), len(path))
        self.notify({"type": "path_found", "maze": self.maze, "path": path, "stats": stats})
        return path, stats


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


class ConsoleView(Observer):
    def update(self, event):
        if event["type"] == "path_found":
            stats = event["stats"]
            print(f"длина пути {stats.path_length}, время {stats.time_ms:.2f} мс")


def save_maze(maze, filename):
    path = os.path.join(BASE, filename)
    with open(path, "w", encoding="utf-8") as f:
        for y in range(maze.height):
            line = ""
            for x in range(maze.width):
                cell = maze.getCell(x, y)
                if cell == maze.start:
                    line += "S"
                elif cell == maze.exit:
                    line += "E"
                elif cell.isWall:
                    line += "#"
                else:
                    line += " "
            f.write(line + "\n")


def generate_with_walls(w, h, prob=0.3):
    maze = Maze(w, h)
    for x in range(w):
        for y in range(h):
            if random.random() < prob:
                maze.getCell(x, y).isWall = True
    maze.getCell(0, 0).isWall = False
    maze.getCell(w - 1, h - 1).isWall = False
    for x in range(w):
        maze.getCell(x, 0).isWall = False
    for y in range(h):
        maze.getCell(w - 1, y).isWall = False
    maze.getCell(0, 0).isStart = True
    maze.start = maze.getCell(0, 0)
    maze.getCell(w - 1, h - 1).isExit = True
    maze.exit = maze.getCell(w - 1, h - 1)
    return maze


def generate_empty(w, h):
    maze = Maze(w, h)
    for x in range(w):
        for y in range(h):
            maze.getCell(x, y).isWall = False
    maze.getCell(0, 0).isStart = True
    maze.start = maze.getCell(0, 0)
    maze.getCell(w - 1, h - 1).isExit = True
    maze.exit = maze.getCell(w - 1, h - 1)
    return maze


def generate_no_exit(w, h):
    maze = generate_with_walls(w, h, 0.3)
    exit_cell = maze.getCell(w - 1, h - 1)
    exit_cell.isWall = True
    exit_cell.isExit = False
    maze.exit = None
    return maze


def run_experiment(maze, strategy_class, maze_name, repeats=5):
    times = []
    path_lens = []

    for _ in range(repeats):
        solver = MazeSolver(maze)
        solver.setStrategy(strategy_class())
        path, stats = solver.solve()
        times.append(stats.time_ms)
        path_lens.append(len(path))

    raw = strategy_class.__name__
    strat_name = "A" if raw == "AStarStrategy" else raw.replace("Strategy", "")
    return {
        "лабиринт": maze_name,
        "стратегия": strat_name,
        "время_ср": sum(times) / repeats,
        "длина_пути_ср": sum(path_lens) / repeats,
        "путь_найден": any(l > 0 for l in path_lens),
    }


def main():
    mazes = []

    small = generate_with_walls(10, 10, 0.2)
    save_maze(small, "maze_small.txt")
    mazes.append(("маленький 10x10", small))

    medium = generate_with_walls(50, 50, 0.3)
    save_maze(medium, "maze_medium.txt")
    mazes.append(("средний 50x50", medium))

    large = generate_with_walls(100, 100, 0.3)
    save_maze(large, "maze_large.txt")
    mazes.append(("большой 100x100", large))

    empty = generate_empty(50, 50)
    save_maze(empty, "maze_empty.txt")
    mazes.append(("пустой 50x50", empty))

    no_exit = generate_no_exit(20, 20)
    save_maze(no_exit, "maze_no_exit.txt")
    mazes.append(("без выхода 20x20", no_exit))

    strategies = [BFSStrategy, DFSStrategy, AStarStrategy]
    results = []

    for maze_name, maze in mazes:
        print(maze_name)
        for strat in strategies:
            res = run_experiment(maze, strat, maze_name)
            results.append(res)
            print(f"  {strat.__name__}: {res['время_ср']:.2f} мс")

    csv_path = os.path.join(BASE, "resultslab.csv")
    with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(
            f,
            fieldnames=["лабиринт", "стратегия", "время_ср", "длина_пути_ср", "путь_найден"],
            delimiter=";",
        )
        writer.writeheader()
        for row in results:
            row_ru = row.copy()
            row_ru["путь_найден"] = "да" if row["путь_найден"] else "нет"
            writer.writerow(row_ru)

    try:
        import matplotlib.pyplot as plt

        plt.rcParams["font.sans-serif"] = ["Segoe UI", "Arial", "Tahoma", "DejaVu Sans"]
        plt.rcParams["axes.unicode_minus"] = False

        labyrinths = []
        for r in results:
            if r["лабиринт"] not in labyrinths:
                labyrinths.append(r["лабиринт"])

        fig, axes = plt.subplots(1, len(labyrinths), figsize=(4 * len(labyrinths), 4))
        if len(labyrinths) == 1:
            axes = [axes]

        for idx, lab in enumerate(labyrinths):
            times = []
            for s in ["BFS", "DFS", "A"]:
                for r in results:
                    if r["лабиринт"] == lab and r["стратегия"] == s:
                        times.append(r["время_ср"])
                        break
            axes[idx].bar(["BFS", "DFS", "A"], times, color=["#1a5632", "#0e5fb4", "#e67e22"])
            axes[idx].set_title(lab)
            axes[idx].set_ylabel("мс")

        plt.tight_layout()
        plt.savefig(os.path.join(BASE, "maze_time_comparison.png"))
        plt.close()
    except ImportError:
        pass

    report_path = os.path.join(os.path.dirname(BASE), "report.md")
    with open(report_path, "w", encoding="utf-8-sig") as f:
        f.write("# Отчёт: поиск пути в лабиринте\n\n")
        f.write("Паттерны: Builder, Strategy, Observer\n\n")
        f.write("```mermaid\nclassDiagram\n")
        f.write("class MazeBuilder\nclass TextFileMazeBuilder\n")
        f.write("class PathFindingStrategy\nclass BFSStrategy\n")
        f.write("class DFSStrategy\nclass AStarStrategy\n")
        f.write("class MazeSolver\nclass Observer\nclass ConsoleView\n")
        f.write("MazeBuilder <|-- TextFileMazeBuilder\n")
        f.write("PathFindingStrategy <|-- BFSStrategy\n")
        f.write("PathFindingStrategy <|-- DFSStrategy\n")
        f.write("PathFindingStrategy <|-- AStarStrategy\n")
        f.write("Observer <|-- ConsoleView\n")
        f.write("MazeSolver --> PathFindingStrategy\n")
        f.write("```\n\n")
        f.write("| Лабиринт | Стратегия | Время (мс) | Длина пути | Найден |\n")
        f.write("| --- | --- | --- | --- | --- |\n")
        for r in results:
            found = "да" if r["путь_найден"] else "нет"
            f.write(
                f"| {r['лабиринт']} | {r['стратегия']} | {r['время_ср']:.2f} | "
                f"{r['длина_пути_ср']:.0f} | {found} |\n"
            )
        f.write("\n![График](data/maze_time_comparison.png)\n\n")
        f.write("## Выводы\n\n")
        f.write("- BFS и A* находят кратчайший путь.\n")
        f.write("- DFS путь может быть длиннее.\n")
        f.write("- На пустом лабиринте алгоритмы работают быстрее всего.\n")
        f.write("- Без выхода все стратегии возвращают пустой путь.\n")

    print("Готово:", report_path)


if __name__ == "__main__":
    main()
