import heapq
import time
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import dataclass, field
from typing import List, Optional


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

    def is_passable(self):
        return not self.is_wall

    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 __repr__(self):
        return f"Cell({self.x},{self.y})"


class Maze:
    def __init__(self, cells, width, height, start, exit_cell):
        self.cells = cells
        self.width = width
        self.height = height
        self.start = start
        self.exit = exit_cell

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

    def get_neighbors(self, cell):
        result = []
        for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]:
            n = self.get_cell(cell.x + dx, cell.y + dy)
            if n and n.is_passable():
                result.append(n)
        return result

    def render(self, path=None):
        path_set = set(path) if path else set()
        lines = []
        for row in self.cells:
            line = ""
            for cell in row:
                if cell.is_start:
                    line += " S"
                elif cell.is_exit:
                    line += " E"
                elif cell.is_wall:
                    line += "##"
                elif cell in path_set:
                    line += " ."
                else:
                    line += "  "
            lines.append(line)
        return "\n".join(lines)


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


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

        height = len(lines)
        width = max(len(l) for l in lines)
        cells = []
        start = exit_cell = None

        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"
                c = Cell(x, y, is_wall, is_start, is_exit)
                if is_start:
                    start = c
                if is_exit:
                    exit_cell = c
                row.append(c)
            cells.append(row)

        if not start or not exit_cell:
            raise ValueError("Maze must have S and E")
        return Maze(cells, width, height, start, exit_cell)


@dataclass
class SearchStats:
    strategy: str
    time_ms: float
    visited: int
    path_length: int
    path: List[Cell] = field(default_factory=list)


class PathFindingStrategy(ABC):
    _visited = 0

    @property
    def name(self):
        return self.__class__.__name__

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

    @staticmethod
    def _build_path(parent, start, end):
        path, cur = [], end
        while cur:
            path.append(cur)
            cur = parent.get(cur)
        path.reverse()
        return path if path and path[0] == start else []


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

    def find_path(self, maze, start, end):
        queue = deque([start])
        parent = {start: None}
        visited = 0
        while queue:
            cur = queue.popleft()
            visited += 1
            if cur == end:
                self._visited = visited
                return self._build_path(parent, start, end)
            for nb in maze.get_neighbors(cur):
                if nb not in parent:
                    parent[nb] = cur
                    queue.append(nb)
        self._visited = visited
        return []


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

    def find_path(self, maze, start, end):
        stack = [start]
        parent = {start: None}
        visited = 0
        while stack:
            cur = stack.pop()
            visited += 1
            if cur == end:
                self._visited = visited
                return self._build_path(parent, start, end)
            for nb in maze.get_neighbors(cur):
                if nb not in parent:
                    parent[nb] = cur
                    stack.append(nb)
        self._visited = visited
        return []


class AStarStrategy(PathFindingStrategy):
    @property
    def name(self):
        return "A*"

    @staticmethod
    def _h(a, b):
        return abs(a.x - b.x) + abs(a.y - b.y)

    def find_path(self, maze, start, end):
        counter = 0
        heap = [(0, counter, start)]
        parent = {start: None}
        g = {start: 0}
        closed = set()
        visited = 0
        while heap:
            _, _, cur = heapq.heappop(heap)
            if cur in closed:
                continue
            closed.add(cur)
            visited += 1
            if cur == end:
                self._visited = visited
                return self._build_path(parent, start, end)
            for nb in maze.get_neighbors(cur):
                if nb in closed:
                    continue
                ng = g[cur] + 1
                if ng < g.get(nb, float("inf")):
                    g[nb] = ng
                    counter += 1
                    heapq.heappush(heap, (ng + self._h(nb, end), counter, nb))
                    parent[nb] = cur
        self._visited = visited
        return []


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

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

    def solve(self) -> SearchStats:
        t0 = time.perf_counter()
        path = self.strategy.find_path(self.maze, self.maze.start, self.maze.exit)
        t1 = time.perf_counter()
        return SearchStats(
            strategy=self.strategy.name,
            time_ms=(t1 - t0) * 1000,
            visited=self.strategy._visited,
            path_length=len(path),
            path=path,
        )
