import sys
from collections import deque
import heapq
import time
import os



class Tile:
    def __init__(self, column, row):
        self._col = column
        self._row = row
        self._blocked = False
        self._is_start = False
        self._is_exit = False
    
    @property
    def col(self):
        return self._col
    
    @property
    def row(self):
        return self._row
    
    @property
    def blocked(self):
        return self._blocked
    
    @blocked.setter
    def blocked(self, value):
        self._blocked = 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 passable(self):
        return not self._blocked


class Labyrinth:
    def __init__(self, width, height):
        self._width = width
        self._height = height
        self._grid = [[Tile(x, y) for x in range(width)] for y in range(height)]
        self._start_tile = None
        self._exit_tile = None
    
    @property
    def width(self):
        return self._width
    
    @property
    def height(self):
        return self._height
    
    @property
    def start_tile(self):
        return self._start_tile
    
    @property
    def exit_tile(self):
        return self._exit_tile
    
    def get_tile(self, x, y):
        if 0 <= x < self._width and 0 <= y < self._height:
            return self._grid[y][x]
        return None
    
    def set_tile_type(self, x, y, kind):
        tile = self.get_tile(x, y)
        if tile is None:
            return
        
        if kind == 'wall':
            tile.blocked = True
        elif kind == 'start':
            if self._start_tile:
                self._start_tile.is_start = False
            tile.is_start = True
            tile.blocked = False
            self._start_tile = tile
        elif kind == 'exit':
            if self._exit_tile:
                self._exit_tile.is_exit = False
            tile.is_exit = True
            tile.blocked = False
            self._exit_tile = tile
        elif kind == 'path':
            tile.blocked = False
    
    def neighbors_of(self, tile):
        result = []
        directions = [(0, -1), (0, 1), (-1, 0), (1, 0)]
        for dx, dy in directions:
            nx, ny = tile.col + dx, tile.row + dy
            nb = self.get_tile(nx, ny)
            if nb and nb.passable():
                result.append(nb)
        return result


class LabyrinthLoader:
    def load(self, filepath):
        raise NotImplementedError


class TextFileLoader(LabyrinthLoader):
    def load(self, filepath):
        with open(filepath, 'r') as f:
            lines = [line.rstrip('\n') for line in f.readlines()]
        h = len(lines)
        w = max(len(line) for line in lines) if h > 0 else 0
        
        start_count = 0
        exit_count = 0
        lab = Labyrinth(w, h)
        
        for row, line in enumerate(lines):
            for col, ch in enumerate(line):
                if ch == "#":
                    lab.set_tile_type(col, row, "wall")
                elif ch == "S":
                    lab.set_tile_type(col, row, "start")
                    start_count += 1
                elif ch == "E":
                    lab.set_tile_type(col, row, "exit")
                    exit_count += 1
                else:
                    lab.set_tile_type(col, row, "path")
        
        if start_count != 1 or exit_count != 1:
            raise ValueError(f"Maze must have exactly one 'S' and one 'E'. Found: S={start_count}, E={exit_count}")
        return lab


class SearchAlgorithm:
    def find_route(self, maze, start, goal):
        raise NotImplementedError
    
    def _reconstruct(self, came_from, start, goal):
        path = []
        cur = goal
        while cur is not None:
            path.append(cur)
            cur = came_from.get(cur)
        path.reverse()
        return path
    
    def visited_cells(self):
        return getattr(self, '_visited', 0)


class BreadthFirstSearch(SearchAlgorithm):
    def find_route(self, maze, start, goal):
        q = deque()
        q.append(start)
        parent = {start: None}
        seen = {start}
        
        while q:
            current = q.popleft()
            if current == goal:
                self._visited = len(seen)
                return self._reconstruct(parent, start, goal)
            for nb in maze.neighbors_of(current):
                if nb not in seen:
                    seen.add(nb)
                    parent[nb] = current
                    q.append(nb)
        self._visited = len(seen)
        return []


class DepthFirstSearch(SearchAlgorithm):
    def find_route(self, maze, start, goal):
        stack = [start]
        parent = {start: None}
        seen = {start}
        
        while stack:
            current = stack.pop()
            if current == goal:
                self._visited = len(seen)
                return self._reconstruct(parent, start, goal)
            for nb in maze.neighbors_of(current):
                if nb not in seen:
                    seen.add(nb)
                    parent[nb] = current
                    stack.append(nb)
        self._visited = len(seen)
        return []


class AStarSearch(SearchAlgorithm):
    def _heuristic(self, tile, goal):
        return abs(tile.col - goal.col) + abs(tile.row - goal.row)
    
    def find_route(self, maze, start, goal):
        heap = []
        counter = 0
        start_f = self._heuristic(start, goal)
        heapq.heappush(heap, (start_f, counter, start))
        counter += 1
        
        parent = {}
        g = {start: 0}
        f = {start: start_f}
        closed = set()
        
        while heap:
            cur_f, _, cur = heapq.heappop(heap)
            closed.add(cur)
            
            if cur == goal:
                self._visited = len(closed)
                return self._reconstruct(parent, start, goal)
            
            if cur_f > f.get(cur, float('inf')):
                continue
            
            for nb in maze.neighbors_of(cur):
                tentative_g = g[cur] + 1
                if tentative_g < g.get(nb, float('inf')):
                    parent[nb] = cur
                    g[nb] = tentative_g
                    new_f = tentative_g + self._heuristic(nb, goal)
                    f[nb] = new_f
                    heapq.heappush(heap, (new_f, counter, nb))
                    counter += 1
        
        self._visited = len(closed)
        return []


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


class EventListener:
    def on_event(self, event_type, data):
        raise NotImplementedError


class TerminalView(EventListener):
    def __init__(self, player=None):
        self._current_path = None
        self._player = player
    
    def on_event(self, event_type, data):
        if event_type == "maze_loaded":
            self._display_maze(data)
        elif event_type == "path_found":
            self._current_path = data
            self._display_path(data)
        elif event_type == "player_moved":
            self._display_maze_with_player(data)
    
    def _display_maze(self, maze):
        os.system('cls' if os.name == 'nt' else 'clear')
        print("=" * (maze.width * 2 + 4))
        print("   LABYRINTH")
        print("=" * (maze.width * 2 + 4))
        
        for y in range(maze.height):
            print("  ", end='')
            for x in range(maze.width):
                cell = maze.get_tile(x, y)
                if cell == maze.start_tile:
                    print('S', end=' ')
                elif cell == maze.exit_tile:
                    print('E', end=' ')
                elif cell.blocked:
                    print('#', end=' ')
                else:
                    print('.', end=' ')
            print()
        print("=" * (maze.width * 2 + 4))
        print("  S - start  E - exit  # - wall  . - path")
    
    def _display_maze_with_player(self, maze):
        os.system('cls' if os.name == 'nt' else 'clear')
        print("=" * (maze.width * 2 + 4))
        print("   LABYRINTH (P = player)")
        print("=" * (maze.width * 2 + 4))
        
        for y in range(maze.height):
            print("  ", end='')
            for x in range(maze.width):
                cell = maze.get_tile(x, y)
                if self._player and cell == self._player.position:
                    print('P', end=' ')
                elif cell == maze.start_tile:
                    print('S', end=' ')
                elif cell == maze.exit_tile:
                    print('E', end=' ')
                elif cell.blocked:
                    print('#', end=' ')
                else:
                    print('.', end=' ')
            print()
        print("=" * (maze.width * 2 + 4))
        print(f"  Player at: ({self._player.position.col}, {self._player.position.row})")
        print("  S - start  E - exit  # - wall  . - path  P - player")
    
    def _display_path(self, path):
        if not path:
            print("\n  No route found!")
        else:
            print(f"\n  Path found! Length = {len(path)}")


class Player:
    def __init__(self, start_tile, labyrinth):
        self._pos = start_tile
        self._prev = None
        self._lab = labyrinth
    
    @property
    def position(self):
        return self._pos
    
    def move_to(self, new_tile):
        if new_tile and new_tile.passable():
            self._prev = self._pos
            self._pos = new_tile
            return True
        return False
    
    def undo(self):
        if self._prev:
            self._pos, self._prev = self._prev, None
            return True
        return False


class Command:
    def do(self):
        raise NotImplementedError
    
    def undo(self):
        raise NotImplementedError


class MoveCommand(Command):
    def __init__(self, player, direction, labyrinth):
        self._player = player
        self._dx, self._dy = direction
        self._lab = labyrinth
        self._done = False
    
    def do(self):
        nx = self._player.position.col + self._dx
        ny = self._player.position.row + self._dy
        target = self._lab.get_tile(nx, ny)
        if target and target.passable():
            self._player.move_to(target)
            self._done = True
            return True
        return False
    
    def undo(self):
        if self._done:
            self._player.undo()
            self._done = False
            return True
        return False



class MazeSolver:
    """Controls the search process and notifies observers."""
    
    def __init__(self, labyrinth):
        self._lab = labyrinth
        self._algorithm = None
        self._listeners = []
    
    def add_listener(self, listener):
        self._listeners.append(listener)
    
    def notify(self, event, data):
        for lst in self._listeners:
            lst.on_event(event, data)
    
    def set_algorithm(self, algo):
        self._algorithm = algo
    
    def solve(self):
        if self._algorithm is None:
            return None
        
        start_time = time.perf_counter()
        route = self._algorithm.find_route(self._lab, self._lab.start_tile, self._lab.exit_tile)
        end_time = time.perf_counter()
        elapsed_ms = (end_time - start_time) * 1000
        
        self.notify("path_found", route)
        return SearchStats(elapsed_ms, self._algorithm.visited_cells(), len(route))


def run_experiment(maze_file, algorithm, repetitions=5):
    loader = TextFileLoader()
    maze = loader.load(maze_file)
    
    total_time = 0.0
    total_visited = 0
    total_length = 0
    
    for _ in range(repetitions):
        solver = MazeSolver(maze)
        solver.set_algorithm(algorithm)
        stats = solver.solve()
        if stats:
            total_time += stats.elapsed_ms
            total_visited += stats.visited_cells
            total_length += stats.path_length
    
    return {
        'time_ms': total_time / repetitions,
        'visited_cells': total_visited / repetitions,
        'path_length': total_length / repetitions
    }


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == 'experiment':
        print("Running experiments (use plots.py for full test suite)...")
        sys.exit(0)
    
    loader = TextFileLoader()
    maze = loader.load("maze1.txt")
    
    player = Player(maze.start_tile, maze)
    view = TerminalView(player)
    view.on_event("maze_loaded", maze)
    
    solver = MazeSolver(maze)
    solver.add_listener(view)
    
    print("\n  CONTROLS:")
    print("  H (left)  J (down)  K (up)  L (right)")
    print("  U - undo    Q - quit")
    print("\n  AUTO SEARCH:")
    print("  B - BFS    D - DFS    A - A*")
    print("\n" + "=" * 50)
    
    history = []
    
    while True:
        cmd = input("\n  Command > ").lower()
        
        if cmd == 'q':
            print("\n  Goodbye!")
            break
        elif cmd == 'b':
            solver.set_algorithm(BreadthFirstSearch())
            stats = solver.solve()
            print(f"\n  BFS: time={stats.elapsed_ms:.3f}ms, visited={stats.visited_cells}, length={stats.path_length}")
        elif cmd == 'd':
            solver.set_algorithm(DepthFirstSearch())
            stats = solver.solve()
            print(f"\n  DFS: time={stats.elapsed_ms:.3f}ms, visited={stats.visited_cells}, length={stats.path_length}")
        elif cmd == 'a':
            solver.set_algorithm(AStarSearch())
            stats = solver.solve()
            print(f"\n  A*: time={stats.elapsed_ms:.3f}ms, visited={stats.visited_cells}, length={stats.path_length}")
        elif cmd in ['h', 'j', 'k', 'l']:
            dir_map = {'h': (-1, 0), 'l': (1, 0), 'k': (0, -1), 'j': (0, 1)}
            move = MoveCommand(player, dir_map[cmd], maze)
            if move.do():
                history.append(move)
                view.on_event("player_moved", maze)
                if player.position == maze.exit_tile:
                    print("\n  *** YOU ESCAPED! ***")
                    print(f"  Total moves: {len(history)}")
                    break
            else:
                print("\n  Blocked by a wall!")
        elif cmd == 'u':
            if history:
                last = history.pop()
                last.undo()
                view.on_event("player_moved", maze)
                print("\n  Undo successful")
            else:
                print("\n  Nothing to undo")
        else:
            print("\n  Unknown command. Use h,j,k,l to move, u to undo, q to quit")
    
    print("\n  Game over. Thanks for playing!")