import pytest

from source.models.base import Maze, Cell
from source.settings import cell_mapping
from source.strategy import BFSStrategy, DFSStrategy, AStarStrategy


def make_open_maze(width: int = 5, height: int = 5) -> Maze:
    """Открытый лабиринт без внутренних стен, S в углу, E в противоположном."""
    maze = Maze(size=(width, height))
    maze[0, 0] = cell_mapping["start"]
    maze[height - 1, width - 1] = cell_mapping["exit"]
    return maze


def make_blocked_maze() -> Maze:
    """Лабиринт где S и E разделены сплошной стеной — пути нет."""
    maze = Maze(size=(5, 5))
    maze[0, 0] = cell_mapping["start"]
    maze[4, 4] = cell_mapping["exit"]
    for col in range(5):
        maze[2, col] = cell_mapping["wall"]
    return maze


def make_corridor_maze() -> Maze:
    """Узкий коридор 1×5: S → . → . → . → E."""
    maze = Maze(size=(5, 1))
    maze[0, 0] = cell_mapping["start"]
    maze[0, 4] = cell_mapping["exit"]
    return maze


STRATEGIES = [BFSStrategy, DFSStrategy, AStarStrategy]
STRATEGY_IDS = ["BFS", "DFS", "A*"]


# ---------------------------------------------------------------------------- #
#                        Общие тесты для всех стратегий                        #
# ---------------------------------------------------------------------------- #


class TestAllStrategies:
    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_returns_list(self, StrategyClass):
        """find_path всегда возвращает список."""
        maze = make_open_maze()
        result = StrategyClass().find_path(maze)
        assert isinstance(result, list)

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_path_starts_with_start(self, StrategyClass):
        """Первая клетка пути — старт."""
        maze = make_open_maze()
        path = StrategyClass().find_path(maze)
        assert path[0] is maze.start

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_path_ends_with_exit(self, StrategyClass):
        """Последняя клетка пути — выход."""
        maze = make_open_maze()
        path = StrategyClass().find_path(maze)
        assert path[-1] is maze.exit

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_path_cells_are_passable(self, StrategyClass):
        """Все клетки пути проходимы."""
        maze = make_open_maze()
        path = StrategyClass().find_path(maze)
        assert all(cell.is_possible() for cell in path)

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_path_cells_are_neighbors(self, StrategyClass):
        """Каждая следующая клетка пути — сосед предыдущей."""
        maze = make_open_maze()
        path = StrategyClass().find_path(maze)
        for a, b in zip(path, path[1:]):
            assert abs(a.x - b.x) + abs(a.y - b.y) == 1

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_no_path_returns_empty(self, StrategyClass):
        """Если пути нет — возвращает пустой список."""
        maze = make_blocked_maze()
        path = StrategyClass().find_path(maze)
        assert path == []

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_corridor_path_length(self, StrategyClass):
        """В коридоре 1×5 путь содержит ровно 5 клеток."""
        maze = make_corridor_maze()
        path = StrategyClass().find_path(maze)
        assert len(path) == 5

    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_maze_not_modified(self, StrategyClass):
        """Алгоритм не изменяет состояние лабиринта."""
        maze = make_open_maze()
        before = str(maze)
        StrategyClass().find_path(maze)
        assert str(maze) == before


# ---------------------------------------------------------------------------- #
#              Тесты специфичные для BFS и A* (оптимальность пути)             #
# ---------------------------------------------------------------------------- #


class TestOptimalStrategies:
    @pytest.mark.parametrize(
        "StrategyClass", [BFSStrategy, AStarStrategy], ids=["BFS", "A*"]
    )
    def test_shortest_path_in_corridor(self, StrategyClass):
        """BFS и A* находят кратчайший путь в коридоре."""
        maze = make_corridor_maze()
        path = StrategyClass().find_path(maze)
        assert len(path) == 5

    @pytest.mark.parametrize(
        "StrategyClass", [BFSStrategy, AStarStrategy], ids=["BFS", "A*"]
    )
    def test_bfs_and_astar_same_length(self, StrategyClass):
        """BFS и A* возвращают путь одинаковой длины на открытом лабиринте."""
        maze = make_open_maze(7, 7)
        bfs_len = len(BFSStrategy().find_path(maze))
        astar_len = len(AStarStrategy().find_path(maze))
        assert bfs_len == astar_len


# ---------------------------------------------------------------------------- #
#                     Тесты с явной передачей start / exit                     #
# ---------------------------------------------------------------------------- #


class TestExplicitStartExit:
    @pytest.mark.parametrize("StrategyClass", STRATEGIES, ids=STRATEGY_IDS)
    def test_explicit_start_and_exit(self, StrategyClass):
        """find_path работает с явно переданными start и exit."""
        maze = Maze(size=(5, 5))
        start = maze.get_cell(0, 0)
        exit = maze.get_cell(4, 4)
        maze[0, 0] = cell_mapping["start"]
        maze[4, 4] = cell_mapping["exit"]

        path = StrategyClass().find_path(maze, start=start, exit=exit)
        assert path[0] is start
        assert path[-1] is exit


class TestEdgeCases:
    def test_no_start_raises(self):
        """Если нет старта — ValueError."""
        maze = Maze(size=(5, 5))
        maze[4, 4] = cell_mapping["exit"]
        with pytest.raises(ValueError):
            BFSStrategy().find_path(maze)

    def test_no_exit_raises(self):
        """Если нет выхода — ValueError."""
        maze = Maze(size=(5, 5))
        maze[0, 0] = cell_mapping["start"]
        with pytest.raises(ValueError):
            BFSStrategy().find_path(maze)
