import numpy as np
import abc
from collections import deque
import heapq
import time
import os
import keyboard
import csv

#Классы клетки и лабиринта

class Cell:
    
    def __init__(self, coords, isWall = False, isStart = False,
                 isExit = False):
        self.coords = coords
        self.isWall = isWall
        self.isStart = isStart
        self.isExit = isExit
    
    def isPassable(self):
        if self.isWall:
            return False
        return True

class Maze:
    
    def __init__(self, cells, width, height, st, ex):
        self.cells = cells
        self.width = width
        self.height = height
        self.st = st
        self.ex = ex
    
    def getCell(self,x,y):
        try:
            return self.cells[x][y]
        except:
            return None

    def getNeighbors(self,cell):
        x,y = cell.coords
        res = []
        for i,j in (x,y+1),(x,y-1),(x-1,y),(x+1,y):
            cellij = self.getCell(i,j)
            if i <= self.width-1 and j <= self.height-1 and 0 <= i and 0 <= j and cellij is not None:
                if cellij.isPassable():
                    res.append(cellij)
                else:
                    res.append(None)
            else:
                res.append(None)
        
        return res

#Тестирование классов клетки и лабиринта

# cell1 = Cell((1,2), isExit = True, isWall = True, isStart = False)

# print(cell1.isPassable())
# print(cell1.isStart)
# print(cell1.coords)

# width, height = 3,3

# cells = np.full((width,height), None, dtype=object)

# for x in range(width):
#     for y in range(height):
#         if x != 0 and x != width-1 and y != 0 and y != height-1:
#             cells[x][y] = Cell((x,y), isWall = False)
#         else:
#             cells[x][y] = Cell((x,y), isWall = True)

# print(cells)

# maze1 = Maze(cells, width, height, cells[0], cells[-1])

# for column in cells:
#     for cell in column:
#         print(cell.coords)
#         print(maze1.getNeighbors(cell))

# print('\n')

#Интерфейс постройки лабиринта

class MazeBuilder(abc.ABC):
    
    @abc.abstractmethod
    def buildFromFile(filename): pass

#Наследуем от него класс постройки из текстового файла

class TextFileMazeBuilder(MazeBuilder):
    
    def buildFromFile(filename):
        with open(filename, "r") as file:
            
            rows = file.read().splitlines()
            
            #print(rows)
            
            width = 0
            height = 0
            for row in rows:
                height += 1
                if len(row) > width:
                    width = len(row)
                    
            #print(width, height)
            
            st = (0,0)
            ex = (width,height)
            
            cells = np.full((width,height), None, dtype=object)
            
            flagst = False
            flagex = False
            for y in range(height):
                for x in range(width):
                    isWall = False
                    isStart = False
                    isExit = False
                    
                    if rows[-(y+1)][x] == '#':
                        isWall = True
                    elif rows[-(y+1)][x] == 'S':
                        isStart = True
                        st = (x,y)
                        flagst = True
                        #print('Старт в',x,y)
                    elif rows[-(y+1)][x] == 'E':
                        isExit = True
                        ex = (x,y)
                        flagex = True
                        #print('Выход в',x,y)
                    elif rows[-(y+1)][x] != ' ':
                        raise ValueError("Неверный формат лабиринта! Пожалуйста, используйте только символы #,S,E и пробелы")
                    
                    cells[x][y] = Cell((x,y), isWall, isStart, isExit)
            
            if flagst and flagex:
            
                return Maze(cells, width, height, cells[st[0]][st[1]], cells[ex[0]][ex[1]])
            
            raise ValueError('В лабаиринте должны быть вход и выход (S и E)')


# builder = TextFileMazeBuilder

# maze = builder.buildFromFile('maze1.txt')

# print(maze)


#Интерфейс поиска пути

class PathFindingStrategy(abc.ABC):
    
    @abc.abstractmethod
    def findPath(self, maze, st, ex): pass

#Поиск в глубину

class DFS(PathFindingStrategy):       
    
    def findPath(self,maze,st,ex):
        
        stack = [st]
        
        self.visited = {st.coords} #по координатам надёжнее, а то вдруг адрес изменится
        
        pathmap = {}
        
        while stack:
            cell = stack.pop()
            
            if cell.coords == ex.coords:
                
                #маршрут выстраивается в обратном порядке и разворачивается
                path = []
                while cell.coords != st.coords:
                    path.append(cell)
                    cell = pathmap[cell.coords]
                path.append(st)
                path = path[::-1]
                return path
        
            for n in maze.getNeighbors(cell):
                if n != None and n.coords not in self.visited:
                    self.visited.add(n.coords)
                    pathmap[n.coords] = cell
                    stack.append(n)
        
        return None

# path = DFS().findPath(maze,maze.st,maze.ex)

# print('путь поиском в глубину:')
# for cell in path:
#     print(cell.coords)


class BFS(PathFindingStrategy):
    
    def findPath(self,maze,st,ex):
        
        queue = deque([st])
        
        self.visited = {st.coords} #по координатам надёжнее, а то вдруг адрес изменится
        
        pathmap = {}
        
        while queue:
            cell = queue.popleft()
            
            if cell.coords == ex.coords:
                
                
                path = []
                while cell.coords != st.coords:
                    path.append(cell)
                    cell = pathmap[cell.coords]
                path.append(st)
                path = path[::-1]
                return path
        
            for n in maze.getNeighbors(cell):
                if n != None and n.coords not in self.visited:
                    self.visited.add(n.coords)
                    pathmap[n.coords] = cell
                    queue.append(n)
        
        return None

# path = BFS().findPath(maze,maze.st,maze.ex)

# print('путь поиском в ширину:')
# for cell in path:
#     print(cell.coords)

class Astar(PathFindingStrategy):
    
    def findPath(self,maze,st,ex):
        
        c = 0
        hp_queue = [(0,c,st)]
        
        self.g_score = {st.coords: 0}
        
        pathmap = {}
        
        hp_queue_coords = {st.coords} #нам важна скорость
        
        while hp_queue:
            
            cell = heapq.heappop(hp_queue)[2]
            hp_queue_coords.remove(cell.coords)
            
            if cell.coords == ex.coords:
                
                path = []
                while cell.coords != st.coords:
                    path.append(cell)
                    cell = pathmap[cell.coords]
                path.append(st)
                path = path[::-1]
                self.visited = set(self.g_score.keys()) #экий костыль
                return path
            
            for n in maze.getNeighbors(cell):
                new_g_score = self.g_score[cell.coords] + 1
                
                if n is not None and new_g_score < self.g_score.get(n.coords, float('inf')):
                    pathmap[n.coords] = cell
                    self.g_score[n.coords] = new_g_score
                    
                    h_score = abs(n.coords[0]-ex.coords[0]) + abs(n.coords[1]-ex.coords[1])
                    
                    #f = g + h
                    #h - манхэттенское расстояние
                    full_score = new_g_score + h_score
                    
                    if n.coords not in hp_queue_coords:
                        c += 1
                        heapq.heappush(hp_queue, (full_score, c, n))
                        hp_queue_coords.add(n.coords)
                        
        self.visited = set(self.g_score.keys()) #экий костыль 2: возвращение ситхов
        return None

# path = Astar().findPath(maze,maze.st,maze.ex)

# print('путь с A*:')
# for cell in path:
#     print(cell.coords)

#Класс статистики поиска пути и класс оркестратор

class SearchStats():
    
    def __init__(self, timeMs, visitedCells, pathLength):
        self.timeMs = timeMs
        self.visitedCells = visitedCells
        self.pathLength = pathLength

class MazeSolver():
    
    def __init__(self, maze, strategy):
        self.maze = maze
        self.strategy = strategy
        self.observers = [ConsoleView(maze)]
        
        for observer in self.observers:
            observer.update(MazeEvent('maze_loaded',maze,maze.st.coords))
    
    def setStrategy(self,strategy):
        self.strategy = strategy
    
    def solve(self):
        
        start = time.perf_counter()
        path = self.strategy.findPath(self.maze,self.maze.st,self.maze.ex)
        end = time.perf_counter()
        
        elapsed = end - start
        
        visitedCells = len(self.strategy.visited)
        
        if path is not None:
            pathLength = len(path)

            for observer in self.observers:
                observer.update(MazeEvent('path_found',self.maze,path[-1].coords,path))
        
        else:
            pathLength = 0
            for observer in self.observers:
                observer.update(MazeEvent('path_found',self.maze,None,path))
        
        return SearchStats(elapsed*1000, visitedCells, pathLength)

# MS = MazeSolver(maze, DFS())
# Stats = MS.solve()
# print(Stats.timeMs)
# print(Stats.visitedCells)
# print(Stats.pathLength)

# MS = MazeSolver(maze, BFS())
# Stats = MS.solve()
# print(Stats.timeMs)
# print(Stats.visitedCells)
# print(Stats.pathLength)

# MS = MazeSolver(maze, Astar())
# Stats = MS.solve()
# print(Stats.timeMs)
# print(Stats.visitedCells)
# print(Stats.pathLength)


#Класс для событий

class MazeEvent():
    
    def __init__(self,event_type, maze, player_position = None, path = []):
        if player_position is None:
            player_position = maze.st.coords
        self.event_type = event_type
        self.maze = maze
        self.player_position = player_position
        self.path = path

#Интерфейс наблюдатель

class Observer(abc.ABC):
    
    @abc.abstractmethod
    def update(self, event):
        
        if not isinstance(event, (str, MazeEvent)):
            raise TypeError('Только строки и объекты события')
        
        elif isinstance(event, MazeEvent) and event.event_type not in ('path_found','move','maze_loaded'):
            raise TypeError('Только события "path_found","move","maze_loaded"')

#Класс консольного просмотра

class ConsoleView(Observer):
    
    def __init__(self, maze, player_position = (0,0), path = []):
        
        self.maze = maze
        self.player_position = player_position
        self.path = path
        
    def update(self, event):
        
        super().update(event) #проверка через сам интерфейс
        
        if isinstance(event, str):
            print('')
            print(event+'\n')
            self.render(self.maze, self.player_position, self.path)
            
        else:
            print('')
            print(event.event_type+'\n')
            if event.player_position is not None:
                self.player_position = event.player_position
            if event.path is not None and event.path:
                self.path = event.path
            self.render(event.maze, self.player_position, self.path)
        
    def render(self, maze, player_position, path):
        
        os.system('cls' if os.name == 'nt' else 'clear')
        
        #из-за системы координат надо всё опять транспонировать
        
        res = []
        for row in maze.cells.T[::-1]:
            subres = []
            for cell in row:
                if cell.isWall:
                    subres += '#'
                elif cell.isStart:
                    subres += 'S'
                elif cell.isExit:
                    subres += 'E'
                else:
                    subres += ' '
            res.append(subres)
        
        for cell in path:
            x,y = cell.coords
            if res[-(y+1)][x] != 'S':
                res[-(y+1)][x] = '*'
        
        res[-(player_position[1]+1)][player_position[0]] = 'X'
        
        for row in res:
            print(''.join(row))
                
# builder = TextFileMazeBuilder

# maze = builder.buildFromFile('maze1.txt')

# print(maze)

# CV = ConsoleView(maze, (0,0))
# CV.update('Что-то случилось')

# ME = MazeEvent('maze_loaded', maze, (0,0))

# CV.update(ME)

# CV.update('Что-то случилось')

#Интерфейс для команд

class Command(abc.ABC):
    
    @abc.abstractmethod
    def execute(self): pass
    
    @abc.abstractmethod
    def undo(self): pass

#Класс команды движения

class MoveCommand(Command):
    
    def __init__(self):
        self.previousCell = (0,0)
    
    def execute(self,player,direction):
        
        self.previousCell = player.currentCell
        
        resCell = (self.previousCell[0]+direction.dir[0],self.previousCell[1]+direction.dir[1])
        
        player.moveTo(resCell)
    
    def undo(self,player):
        
        player.moveTo(self.previousCell)
        
#Класс игрока

class Player():
    
    #Он хранит не текущую клетку, а только её координаты. Поскольку
    #нам надо перемещать игрока динамически, а команда для перемещения
    #не принимает лабиринт в качестве аргумента, следующую клетку мы
    #как объект получить не можем, а можем получить только её координаты.
    
    def __init__(self, currentCell):
        
        self.currentCell = currentCell
    
    def moveTo(self, cell):
        
        self.currentCell = cell

#Класс направление

class Direction():
    
    def __init__(self, x,y):
        self.dir = (x,y)

#Тест системы перемещения клавиатурой :D

builder = TextFileMazeBuilder

maze = builder.buildFromFile('maze1.txt')

MS = MazeSolver(maze, DFS())

MS.solve()

MC = MoveCommand()

CV = MS.observers[0]

player1 = Player(CV.player_position)

instruct = '\nПеремещайтесь на W/A/S/D. Для отмены используйте ctrl+Z. Для выхода из режима перемещения команда X.\n'

def move(player, direction):
    
    resCoords = (player.currentCell[0]+direction.dir[0], player.currentCell[1]+direction.dir[1])
    resCell = maze.getCell(resCoords[0], resCoords[1])
    if resCell == None or resCell.isWall: 
        return
    MC.execute(player, direction)
    CV.update(MazeEvent('move', maze, player.currentCell))
    print(instruct)

def undo(player):
    
    MC.undo(player)
    CV.update(MazeEvent('move', maze, player.currentCell))

print(instruct)

keyboard.add_hotkey('w', move, args=[player1, Direction(0,1)])  

keyboard.add_hotkey('s', move, args=[player1, Direction(0,-1)])  

keyboard.add_hotkey('a', move, args=[player1, Direction(-1,0)])  

keyboard.add_hotkey('d', move, args=[player1, Direction(1,0)])  

keyboard.add_hotkey('ctrl+z', undo, args=[player1])
    
keyboard.wait('x')
keyboard.unhook_all()



#Эксперимент

res = []

strategyList = [BFS(),DFS(),Astar()]
sNamesList = ['BFS','DFS','Astar']
labNamesList = ['10x10','50x50','100x100','empty','no exit']
for strategy in range(3):
    for i in range(1,6):
        
        subres1 = []
        subres2 = []
        subres3 = []
        
        maze_name = 'expmaze' + str(i) + '.txt'
        
        maze = TextFileMazeBuilder.buildFromFile(maze_name)
        
        MS = MazeSolver(maze, strategyList[strategy])
        
        for j in range(10):
            Stats = MS.solve()
            subres1.append(Stats.timeMs)
            subres2.append(Stats.visitedCells)
            subres3.append(Stats.pathLength)
        
        res.append([labNamesList[i-1],sNamesList[strategy],sum(subres1)/10., sum(subres2)/10., sum(subres3)/10.])

print(res)

with open("results.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerows(res)