import sys
import csv
from collections import deque
import heapq
import time
import matplotlib.pyplot as plt
import numpy as np


class Cell:
    def __init__(self, x, y):
        self._x = x
        self._y = y
        self._is_wall = False
        self._is_start = False
        self._is_exit = False
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    @property
    def is_wall(self):
        return self._is_wall
    
    @is_wall.setter
    def is_wall(self, value):
        self._is_wall = value
    
    @property
    def is_start(self):
        return self._is_start
    
    @is_start.setter
    def is_start(self, value):
        self._is_start = value
    
    @property
    def is_exit(self):
        return self._is_exit
    
    @is_exit.setter
    def is_exit(self, value):
        self._is_exit = value
    
    def is_passable(self):
        return not self._is_wall


class Maze:
    def __init__(self, width, height):
        self._width = width
        self._height = height
        self._cells = [[Cell(x, y) for x in range(width)] for y in range(height)]
        self._start = None
        self._exit = None
    
    @property
    def width(self):
        return self._width
    
    @property
    def height(self):
        return self._height
    
    @property
    def start(self):
        return self._start
    
    @property
    def exit(self):
        return self._exit
    
    def get_cell(self, x, y):
        if 0 <= x < self._width and 0 <= y < self._height:
            return self._cells[y][x]
        return None
    
    def set_cell(self, x, y, cell_type):
        cell = self.get_cell(x, y)
        if cell is None:
            return
        
        if cell_type == 'wall':
            cell.is_wall = True
        elif cell_type == 'start':
            if self._start:
                self._start.is_start = False
            cell.is_start = True
            cell.is_wall = False
            self._start = cell
        elif cell_type == 'exit':
            if self._exit:
                self._exit.is_exit = False
            cell.is_exit = True
            cell.is_wall = False
            self._exit = cell
        elif cell_type == 'path':
            cell.is_wall = False
    
    def get_neighbors(self, cell):
        neighbors = []
        directions = [(0, -1), (0, 1), (-1, 0), (1, 0)]
        for dx, dy in directions:
            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:
    def build_from_file(self, filename):
        raise NotImplementedError


class TextFileMazeBuilder(MazeBuilder):
    def build_from_file(self, filename):
        with open(filename, 'r') as f:
            lines = [line.rstrip('\n') for line in f.readlines()]
        height = len(lines)
        width = max(len(line) for line in lines) if height > 0 else 0
        start_en = 0
        exit_en = 0
        maze = Maze(width, height)

        for y, line in enumerate(lines):
            for x, ch in enumerate(line):
                if ch == "#":
                    maze.set_cell(x, y, "wall")
                elif ch == "S":
                    maze.set_cell(x, y, "start")
                    start_en += 1
                elif ch == "E":
                    maze.set_cell(x, y, "exit")
                    exit_en += 1
                else:
                    maze.set_cell(x, y, 'path')
        if start_en != 1 or exit_en != 1:
            raise ValueError(f"Invalid maze: S={start_en}, E={exit_en}")
        return maze


class PathFindingStrategy:
    def find_path(self, maze, start, exit_cell):
        raise NotImplementedError
    
    def _reconstruct_path(self, came_from, start, exit_cell):
        path = []
        current = exit_cell
        while current is not None:
            path.append(current)
            current = came_from.get(current)
        path.reverse()
        return path
    
    def get_visited_count(self):
        return getattr(self, '_visited_count', 0)


class BFSStrategy(PathFindingStrategy):
    def find_path(self, maze, start, exit_cell):
        queue = deque()
        queue.append(start)
        came_from = {start: None}
        visited = {start}
        
        while queue:
            current = queue.popleft()
            if current == exit_cell:
                self._visited_count = len(visited)
                return self._reconstruct_path(came_from, start, exit_cell)
            for neighbor in maze.get_neighbors(current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    came_from[neighbor] = current
                    queue.append(neighbor)
        self._visited_count = len(visited)
        return []


class DFSStrategy(PathFindingStrategy):
    def find_path(self, maze, start, exit_cell):
        stack = [start]
        came_from = {start: None}
        visited = {start}
        
        while stack:
            current = stack.pop()
            if current == exit_cell:
                self._visited_count = len(visited)
                return self._reconstruct_path(came_from, start, exit_cell)
            for neighbor in maze.get_neighbors(current):
                if neighbor not in visited:
                    visited.add(neighbor)
                    came_from[neighbor] = current
                    stack.append(neighbor)
        self._visited_count = len(visited)
        return []


class AStarStrategy(PathFindingStrategy):
    def _heuristic(self, cell, exit_cell):
        return abs(cell.x - exit_cell.x) + abs(cell.y - exit_cell.y)
    
    def find_path(self, maze, start, exit_cell):
        heap = []
        counter = 0
        start_f = self._heuristic(start, exit_cell)
        heapq.heappush(heap, (start_f, counter, start))
        counter += 1
        
        came_from = {}
        g_score = {start: 0}
        f_score = {start: start_f}
        visited = set()
        
        while heap:
            current_f, _, current = heapq.heappop(heap)
            visited.add(current)
            
            if current == exit_cell:
                self._visited_count = len(visited)
                return self._reconstruct_path(came_from, start, exit_cell)
            if current_f > f_score.get(current, float('inf')):
                continue
            for neighbor in maze.get_neighbors(current):
                tentative_g = g_score[current] + 1
                if tentative_g < g_score.get(neighbor, float('inf')):
                    came_from[neighbor] = current
                    g_score[neighbor] = tentative_g
                    new_f = tentative_g + self._heuristic(neighbor, exit_cell)
                    f_score[neighbor] = new_f
                    heapq.heappush(heap, (new_f, counter, neighbor))
                    counter += 1
        self._visited_count = len(visited)
        return []


class MazeSolver:
    def __init__(self, maze):
        self._maze = maze
        self._strategy = None
    
    def set_strategy(self, strategy):
        self._strategy = strategy
    
    def solve(self):
        if self._strategy is None:
            return None
        
        start_time = time.perf_counter()
        path = self._strategy.find_path(self._maze, self._maze.start, self._maze.exit)
        end_time = time.perf_counter()
        time_ms = (end_time - start_time) * 1000
        
        return {
            'time_ms': time_ms,
            'visited_cells': self._strategy.get_visited_count(),
            'path_length': len(path)
        }


def run_experiment(maze_file, strategy, runs=5):
    builder = TextFileMazeBuilder()
    maze = builder.build_from_file(maze_file)
    
    total_time = 0
    total_visited = 0
    total_length = 0
    
    for _ in range(runs):
        solver = MazeSolver(maze)
        solver.set_strategy(strategy)
        stats = solver.solve()
        if stats:
            total_time += stats['time_ms']
            total_visited += stats['visited_cells']
            total_length += stats['path_length']
    
    return {
        'time_ms': total_time / runs,
        'visited_cells': total_visited / runs,
        'path_length': total_length / runs
    }


def generate_plots(results):
    mazes = list(set([r['maze'] for r in results]))
    strategies = ['BFS', 'DFS', 'AStar']
    
    fig, axes = plt.subplots(1, 3, figsize=(15, 5))
    
    x = np.arange(len(mazes))
    width = 0.25
    
    for i, strat in enumerate(strategies):
        times = []
        for maze in mazes:
            val = next((r['time_ms'] for r in results if r['maze'] == maze and r['strategy'] == strat), 0)
            times.append(val)
        axes[0].bar(x + i*width, times, width, label=strat)
    
    axes[0].set_xlabel('Maze')
    axes[0].set_ylabel('Time (ms)')
    axes[0].set_title('Execution Time Comparison')
    axes[0].set_xticks(x + width)
    axes[0].set_xticklabels(mazes, rotation=45, ha='right')
    axes[0].legend()
    axes[0].grid(True, alpha=0.3)
    
    for i, strat in enumerate(strategies):
        visited = []
        for maze in mazes:
            val = next((r['visited_cells'] for r in results if r['maze'] == maze and r['strategy'] == strat), 0)
            visited.append(val)
        axes[1].bar(x + i*width, visited, width, label=strat)
    
    axes[1].set_xlabel('Maze')
    axes[1].set_ylabel('Visited Cells')
    axes[1].set_title('Visited Cells Comparison')
    axes[1].set_xticks(x + width)
    axes[1].set_xticklabels(mazes, rotation=45, ha='right')
    axes[1].legend()
    axes[1].grid(True, alpha=0.3)
    
    for i, strat in enumerate(strategies):
        lengths = []
        for maze in mazes:
            val = next((r['path_length'] for r in results if r['maze'] == maze and r['strategy'] == strat), 0)
            lengths.append(val)
        axes[2].bar(x + i*width, lengths, width, label=strat)
    
    axes[2].set_xlabel('Maze')
    axes[2].set_ylabel('Path Length')
    axes[2].set_title('Path Length Comparison')
    axes[2].set_xticks(x + width)
    axes[2].set_xticklabels(mazes, rotation=45, ha='right')
    axes[2].legend()
    axes[2].grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.savefig('performance_comparison_2-nd-exercise.png', dpi=150, bbox_inches='tight')
    plt.show()


if __name__ == "__main__":
    mazes = [
        ("maze1.txt", "Small 10x6"),
        ("maze10x10.txt", "Medium 10x10"),
        ("maze20x20.txt", "Large 20x20"),
        ("maze_empty.txt", "Empty 15x15"),
        ("maze_no_exit.txt", "No exit 10x10")
    ]
    
    strategies = [
        ("BFS", BFSStrategy()),
        ("DFS", DFSStrategy()),
        ("AStar", AStarStrategy())
    ]
    
    results = []
    
    for maze_file, maze_name in mazes:
        print(f"Testing {maze_name}...")
        for strat_name, strat in strategies:
            try:
                stats = run_experiment(maze_file, strat, runs=3)
                results.append({
                    'maze': maze_name,
                    'strategy': strat_name,
                    'time_ms': stats['time_ms'],
                    'visited_cells': stats['visited_cells'],
                    'path_length': stats['path_length']
                })
                print(f"  {strat_name}: time={stats['time_ms']:.3f}ms, visited={stats['visited_cells']:.0f}, length={stats['path_length']:.0f}")
            except Exception as e:
                print(f"  {strat_name}: ERROR - {e}")
                results.append({
                    'maze': maze_name,
                    'strategy': strat_name,
                    'time_ms': -1,
                    'visited_cells': -1,
                    'path_length': -1
                })
    
    valid_results = [r for r in results if r['time_ms'] >= 0]
    
    with open('experiment_results_2-nd-exercise.csv', 'w', newline='', encoding='utf-8') as f:
        writer = csv.DictWriter(f, fieldnames=['maze', 'strategy', 'time_ms', 'visited_cells', 'path_length'])
        writer.writeheader()
        writer.writerows(valid_results)
    
    if valid_results:
        generate_plots(valid_results)
    
    print("\nResults saved to experiment_results_2-nd-exercise.csv")
    print("Plot saved to performance_comparison_2-nd-exercise.png")