import time
from collections import deque
import heapq
import csv
import os
import random
import matplotlib.pyplot as plt


# ============================================================
# ЭТАП 1. МОДЕЛЬ ЛАБИРИНТА
# ============================================================

class Cell:
    def __init__(self, x, y, is_wall=False, is_start=False, is_exit=False):
        self.x = x
        self.y = y
        self.is_wall = is_wall
        self.is_start = is_start
        self.is_exit = is_exit
        self.weight = 1  # Вес клетки (нужен для Дейкстры)

    # Можно ли пройти через клетку
    def isPassable(self):
        return not self.is_wall

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

    # Хеш по координатам — чтобы класть клетки в set и dict
    def __hash__(self):
        return hash((self.x, self.y))

    # Сравнение двух клеток (нужно для set и dict)
    def __eq__(self, other):
        return isinstance(other, Cell) and self.x == other.x and self.y == other.y


class Maze:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.cells = []  # Двумерный список: cells[y][x]
        self.start = None
        self.exit = None

    # Получить клетку по координатам, если она в границах лабиринта
    def getCell(self, x, y):
        if 0 <= x < self.width and 0 <= y < self.height:
            return self.cells[y][x]
        return None

    # Получить всех соседей клетки (вверх, вниз, влево, вправо), кроме стен
    def getNeighbors(self, cell):
        neighbors = []
        for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]:  # Четыре направления
            nx = cell.x + dx
            ny = cell.y + dy
            neighbor = self.getCell(nx, ny)
            if neighbor and neighbor.isPassable():
                neighbors.append(neighbor)
        return neighbors

    # То же самое, но возвращает пары (сосед, вес) — для Дейкстры
    def getWeightedNeighbors(self, cell):
        return [(n, n.weight) for n in self.getNeighbors(cell)]


# ============================================================
# ЭТАП 2. ЗАГРУЗКА ЛАБИРИНТА ИЗ ФАЙЛА
# ============================================================

class MazeBuilder:
    def buildFromFile(self, filename):
        raise NotImplementedError


class TextFileMazeBuilder(MazeBuilder):
    def buildFromFile(self, filename):
        # Читаем файл, убираем переносы строк
        with open(filename, 'r', encoding='utf-8') as f:
            lines = [line.rstrip('\n') for line in f]

        height = len(lines)
        width = max(len(line) for line in lines)  # Берём самую длинную строку
        maze = Maze(width, height)

        # Разбираем каждый символ в клетку
        for y, line in enumerate(lines):
            row = []
            for x, char in enumerate(line):
                if char == '#':
                    cell = Cell(x, y, is_wall=True)  # Стена
                elif char == 'S':
                    cell = Cell(x, y, is_start=True)
                    maze.start = cell  # Запомнили старт
                elif char == 'E':
                    cell = Cell(x, y, is_exit=True)
                    maze.exit = cell  # Запомнили выход
                else:
                    cell = Cell(x, y)  # Пустая клетка
                row.append(cell)

            # Если строка короче ширины — добиваем стенами
            while len(row) < width:
                row.append(Cell(len(row), y, is_wall=True))
            maze.cells.append(row)

        # Проверяем, что старт и выход есть
        if maze.start is None or maze.exit is None:
            raise ValueError("В лабиринте нет S или E")
        return maze


# ============================================================
# ВОССТАНОВЛЕНИЕ ПУТИ ПО СЛОВАРЮ РОДИТЕЛЕЙ
# ============================================================

def reconstruct_path(parents, end_cell):
    path = []
    current = end_cell
    # Идём от выхода к старту по цепочке parents
    while current is not None:
        path.append(current)
        current = parents[current]
    path.reverse()  # Разворачиваем — получаем путь от старта к выходу
    return path


# ============================================================
# ЭТАП 3. АЛГОРИТМЫ ПОИСКА ПУТИ
# ============================================================

class PathFindingStrategy:
    @property
    def name(self):
        return "Unknown"

    def findPath(self, maze, start, exit):
        raise NotImplementedError


# ============================================================
# BFS — обход в ширину (очередь)
# ============================================================
class BFSStrategy(PathFindingStrategy):
    @property
    def name(self):
        return "BFS"

    def findPath(self, maze, start, exit):
        queue = deque([start])  # Очередь: кто первый зашёл — первый вышел
        visited = {start}
        parents = {start: None}  # Откуда пришли в клетку
        visited_count = 1

        while queue:
            current = queue.popleft()  # Берём из начала очереди
            if current == exit:
                path = reconstruct_path(parents, exit)
                return path, visited_count

            for neighbor in maze.getNeighbors(current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    parents[neighbor] = current
                    visited_count += 1
                    queue.append(neighbor)  # Кладём в конец очереди
        return [], visited_count


# ============================================================
# DFS — обход в глубину (стек)
# ============================================================
class DFSStrategy(PathFindingStrategy):
    @property
    def name(self):
        return "DFS"

    def findPath(self, maze, start, exit):
        stack = [start]  # Стек: кто последний зашёл — первый вышел
        visited = {start}
        parents = {start: None}
        visited_count = 1

        while stack:
            current = stack.pop()  # Берём с вершины стека
            if current == exit:
                path = reconstruct_path(parents, exit)
                return path, visited_count

            for neighbor in maze.getNeighbors(current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    parents[neighbor] = current
                    visited_count += 1
                    stack.append(neighbor)  # Кладём на вершину стека
        return [], visited_count


# ============================================================
# A* — поиск с подсказкой (эвристикой)
# ============================================================
class AStarStrategy(PathFindingStrategy):
    @property
    def name(self):
        return "A*"

    # Подсказка: примерное расстояние до выхода (по прямой)
    def heuristic(self, a, b):
        return abs(a.x - b.x) + abs(a.y - b.y)

    def findPath(self, maze, start, exit):
        counter = 0  # Чтобы различать клетки с одинаковым приоритетом
        open_set = []  # Куча: всегда берём самую перспективную клетку
        heapq.heappush(open_set, (0, counter, start))
        parents = {start: None}
        g_score = {start: 0}  # Пройденное расстояние от старта
        visited = set()
        visited_count = 0

        while open_set:
            _, _, current = heapq.heappop(open_set)  # Достаём клетку с лучшей оценкой
            if current in visited:
                continue
            visited.add(current)
            visited_count += 1

            if current == exit:
                path = reconstruct_path(parents, exit)
                return path, visited_count

            for neighbor in maze.getNeighbors(current):
                tentative_g = g_score[current] + 1  # Расстояние до соседа через текущую
                if neighbor not in g_score or tentative_g < g_score[neighbor]:
                    g_score[neighbor] = tentative_g
                    parents[neighbor] = current
                    # Оценка клетки = пройденный путь + подсказка до выхода
                    f_score = tentative_g + self.heuristic(neighbor, exit)
                    counter += 1
                    heapq.heappush(open_set, (f_score, counter, neighbor))
        return [], visited_count


# ============================================================
# ДЕЙКСТРА — поиск с учётом весов клеток
# ============================================================
class DijkstraStrategy(PathFindingStrategy):
    @property
    def name(self):
        return "Dijkstra"

    def findPath(self, maze, start, exit):
        counter = 0
        queue = []  # Куча: всегда берём клетку с кратчайшим путём от старта
        heapq.heappush(queue, (0, counter, start))
        distances = {start: 0}  # Кратчайшее известное расстояние до каждой клетки
        parents = {start: None}
        visited = set()
        visited_count = 0

        while queue:
            dist, _, current = heapq.heappop(queue)  # Достаём ближайшую клетку
            if current in visited:
                continue
            visited.add(current)
            visited_count += 1

            if current == exit:
                path = reconstruct_path(parents, exit)
                return path, visited_count

            # Здесь используем вес клеток, а не просто +1
            for neighbor, weight in maze.getWeightedNeighbors(current):
                new_dist = dist + weight
                if neighbor not in distances or new_dist < distances[neighbor]:
                    distances[neighbor] = new_dist
                    parents[neighbor] = current
                    counter += 1
                    heapq.heappush(queue, (new_dist, counter, neighbor))
        return [], visited_count


# ============================================================
# ЭТАП 4. РЕШАТЕЛЬ И СТАТИСТИКА
# ============================================================

class SearchStats:
    def __init__(self, strategy_name, time_ms, visited_cells, path_length, path_found):
        self.strategy_name = strategy_name
        self.time_ms = time_ms  # Время в миллисекундах
        self.visited_cells = visited_cells  # Сколько клеток посетили
        self.path_length = path_length  # Длина найденного пути
        self.path_found = path_found  # Нашли путь или нет


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

    # Сменить алгоритм поиска
    def setStrategy(self, strategy):
        self.strategy = strategy

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

        # Засекаем время и запускаем алгоритм
        start_time = time.perf_counter()
        path, visited = self.strategy.findPath(self.maze, self.maze.start, self.maze.exit)
        end_time = time.perf_counter()
        elapsed_ms = (end_time - start_time) * 1000

        return SearchStats(
            self.strategy.name,
            elapsed_ms,
            visited,
            len(path),
            len(path) > 0
        ), path


# ============================================================
# ВЫВОД ЛАБИРИНТА В КОНСОЛЬ
# ============================================================

def render(maze, path=None):
    path_set = set(path) if path else set()  # Для быстрой проверки "клетка на пути?"

    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 in path_set:
                line += "."  # Точка — клетка пути
            elif cell.is_wall:
                line += "#"
            else:
                line += " "
        print(line)
    print()


# ============================================================
# ПУТИ ДЛЯ СОХРАНЕНИЯ ФАЙЛОВ
# ============================================================

OUTPUT_DIR = os.path.join("docs", "data")
PREFIX = "_2lab"
os.makedirs(OUTPUT_DIR, exist_ok=True)  # Создаём папку, если её нет


def get_path(filename):
    name, ext = os.path.splitext(filename)
    return os.path.join(OUTPUT_DIR, f"{name}{PREFIX}{ext}")


# ============================================================
# СОЗДАНИЕ ЛАБИРИНТА ИЗ СПИСКА СТРОК
# ============================================================

def create_test_maze(filename, lines):
    with open(filename, 'w', encoding='utf-8') as f:
        for line in lines:
            f.write(line + '\n')
    return filename


# ============================================================
# ГЕНЕРАЦИЯ ЛАБИРИНТОВ
# ============================================================

# Случайный лабиринт с гарантированным путём
def generate_maze(width, height, wall_density=0.3):
    grid = [[' ' for _ in range(width)] for _ in range(height)]

    # Ставим стены по краям
    for x in range(width):
        grid[0][x] = '#'
        grid[height - 1][x] = '#'
    for y in range(height):
        grid[y][0] = '#'
        grid[y][width - 1] = '#'

    # Прокладываем гарантированную дорожку от (1,1) до (width-2, height-2)
    x, y = 1, 1
    path_cells = {(x, y)}
    while x < width - 2 or y < height - 2:
        if x < width - 2 and random.random() > 0.3:
            x += 1
        elif y < height - 2:
            y += 1
        else:
            x += 1
        path_cells.add((x, y))

    # Случайно расставляем стены, но не на дорожке
    for yy in range(1, height - 1):
        for xx in range(1, width - 1):
            if (xx, yy) not in path_cells:
                if random.random() < wall_density:
                    grid[yy][xx] = '#'

    # Ставим старт и выход по углам
    grid[1][1] = 'S'
    grid[height - 2][width - 2] = 'E'
    return [''.join(row) for row in grid]


# Пустой лабиринт без стен
def generate_empty_maze(size):
    lines = [" " * size for _ in range(size)]
    lines[0] = "S" + " " * (size - 1)
    lines[size - 1] = " " * (size - 1) + "E"
    return lines


# Лабиринт, где выход замурован со всех сторон
def generate_no_exit_maze(size):
    lines = generate_maze(size, size, wall_density=0.2)
    for y, line in enumerate(lines):
        if 'E' in line:
            x = line.index('E')
            # Окружаем выход стенами
            for dy, dx in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
                ny, nx = y + dy, x + dx
                if 0 <= ny < size and 0 <= nx < size:
                    if lines[ny][nx] == ' ':
                        lines[ny] = lines[ny][:nx] + '#' + lines[ny][nx + 1:]
    return lines


# ============================================================
# ЗАПУСК ЭКСПЕРИМЕНТОВ
# ============================================================

def run_experiments():
    # Набор лабиринтов для тестов
    mazes = {
        "small": [
            "##########",
            "#S       #",
            "# ###### #",
            "# #    # #",
            "# # ## # #",
            "# # ## # #",
            "# #    # #",
            "# ###### #",
            "#       E#",
            "##########"
        ],
        "medium": generate_maze(50, 50, 0.35),
        "large": generate_maze(100, 100, 0.4),
        "empty": generate_empty_maze(20),
        "no_exit": generate_no_exit_maze(15)
    }

    # Список алгоритмов
    strategies = [
        BFSStrategy(),
        DFSStrategy(),
        AStarStrategy(),
        DijkstraStrategy()
    ]

    results = []

    print("=" * 60)
    print("ЭКСПЕРИМЕНТЫ")
    print("=" * 60)

    for maze_name, lines in mazes.items():
        filename = get_path(f"{maze_name}.txt")
        create_test_maze(filename, lines)
        maze = TextFileMazeBuilder().buildFromFile(filename)

        print(f"\nЛабиринт: {maze_name}")
        print("-" * 60)

        for strategy in strategies:
            times = []
            visited_values = []
            final_path_len = 0

            # Запускаем 5 раз и считаем среднее время
            for _ in range(5):
                solver = MazeSolver(maze)
                solver.setStrategy(strategy)
                stats, path = solver.solve()
                times.append(stats.time_ms)
                visited_values.append(stats.visited_cells)
                final_path_len = stats.path_length

            avg_time = sum(times) / len(times)
            avg_visited = sum(visited_values) / len(visited_values)

            results.append({
                "maze": maze_name,
                "strategy": strategy.name,
                "time_ms": round(avg_time, 4),
                "visited": int(avg_visited),
                "path_length": final_path_len
            })

            status = "найден" if final_path_len > 0 else "не найден"
            print(f"{strategy.name:<10} | {avg_time:>8.4f} мс | {int(avg_visited):>5} клеток | путь {status}")

    # Сохраняем всё в CSV
    csv_path = get_path("results.csv")
    with open(csv_path, "w", newline="", encoding='utf-8') as f:
        writer = csv.DictWriter(f, fieldnames=["maze", "strategy", "time_ms", "visited", "path_length"])
        writer.writeheader()
        writer.writerows(results)

    print(f"\nCSV сохранён: {csv_path}")
    return results


# ============================================================
# ПОСТРОЕНИЕ ГРАФИКА
# ============================================================

def build_charts(results):
    mazes = list(dict.fromkeys(r["maze"] for r in results))  # Список лабиринтов без повторов
    strategies = list(dict.fromkeys(r["strategy"] for r in results))  # Список стратегий без повторов

    fig, ax = plt.subplots(figsize=(12, 6))
    x = range(len(mazes))
    width = 0.2  # Ширина одного столбика

    # Цвета для каждого алгоритма
    colors = {'BFS': '#3498db', 'DFS': '#e74c3c', 'A*': '#2ecc71', 'Dijkstra': '#f39c12'}

    for i, strategy in enumerate(strategies):
        # Берём время этой стратегии для всех лабиринтов
        times = [r["time_ms"] for r in results if r["strategy"] == strategy]
        # Рисуем столбики рядом друг с другом
        ax.bar([j + i * width for j in x], times, width, label=strategy, color=colors.get(strategy, 'gray'))

    ax.set_xlabel("Лабиринт")
    ax.set_ylabel("Время (мс)")
    ax.set_title("Сравнение алгоритмов")
    ax.set_xticks([j + width * 1.5 for j in x])  # Подписи по центру группы
    ax.set_xticklabels(mazes)
    ax.legend()
    ax.grid(axis='y', alpha=0.3)

    plt.tight_layout()
    chart_path = get_path("chart_time.png")
    plt.savefig(chart_path, dpi=150, bbox_inches='tight')
    print(f"График сохранён: {chart_path}")
    plt.show()


# ============================================================
# ГЛАВНАЯ ФУНКЦИЯ
# ============================================================

def main():
    results = run_experiments()
    build_charts(results)


if __name__ == "__main__":
    main()