"""Tests for PyAgent.get_testables."""

from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from py_agent.api.py_agent import PyAgent
from py_agent.models.testable import Location, Position, Testable

# ── Helpers ──────────────────────────────────────────────────────────────────


def _make_location(line: int = 1, column: int = 0, index: int = 0) -> Location:
    return Location(line=line, column=column, index=index)


def _make_position(
    start_line: int = 1,
    end_line: int = 2,
) -> Position:
    return Position(
        start=_make_location(line=start_line),
        end=_make_location(line=end_line),
    )


def _make_testable(name: str = "my_func", file_path: str = "src/foo.py") -> Testable:
    return Testable(
        type="function",
        name=name,
        position=_make_position(),
        file_path=file_path,
    )


def _make_extractor_result(*testables: Testable) -> MagicMock:
    """Return a mock DTO whose .testables list matches the given Testable objects."""
    dto = MagicMock()
    dto.testables = list(testables)
    return dto


# ── Tests ─────────────────────────────────────────────────────────────────────


class TestGetTestablesNoMatches:
    """glob returns no files → empty list."""

    @pytest.mark.asyncio
    async def test_returns_empty_list_when_no_files_matched(self) -> None:
        agent = PyAgent()
        with patch("py_agent.api.py_agent.glob.glob", return_value=[]):
            result = await agent.get_testables("src/**/*.py", project_path="/proj")
        assert result == []


class TestGetTestablesProjectRoot:
    """project_path variants are handled correctly."""

    @pytest.mark.asyncio
    async def test_uses_cwd_when_project_path_is_none(self) -> None:
        agent = PyAgent()
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=[]),
            patch("py_agent.api.py_agent.os.getcwd", return_value="/fake/cwd") as mock_cwd,
        ):
            await agent.get_testables("*.py", project_path=None)
        mock_cwd.assert_called_once()

    @pytest.mark.asyncio
    async def test_accepts_string_project_path(self) -> None:
        agent = PyAgent()
        t = _make_testable()
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["foo.py"]),
            patch("py_agent.api.py_agent.os.path.abspath", return_value="/proj/foo.py"),
            patch("py_agent.api.py_agent.os.path.relpath", return_value="foo.py"),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                return_value=_make_extractor_result(t),
            ),
        ):
            result = await agent.get_testables("*.py", project_path="/proj")
        assert result == [t]

    @pytest.mark.asyncio
    async def test_accepts_path_object_as_project_path(self) -> None:
        agent = PyAgent()
        t = _make_testable()
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["bar.py"]),
            patch("py_agent.api.py_agent.os.path.abspath", return_value="/proj/bar.py"),
            patch("py_agent.api.py_agent.os.path.relpath", return_value="bar.py"),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                return_value=_make_extractor_result(t),
            ),
        ):
            result = await agent.get_testables("*.py", project_path=Path("/proj"))
        assert result == [t]


class TestGetTestablesHappyPath:
    """Extractor succeeds and testables are returned."""

    @pytest.mark.asyncio
    async def test_single_file_single_testable(self) -> None:
        agent = PyAgent()
        t = _make_testable(name="parse")
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["src/parse.py"]),
            patch("py_agent.api.py_agent.os.path.abspath", return_value="/proj/src/parse.py"),
            patch("py_agent.api.py_agent.os.path.relpath", return_value="src/parse.py"),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                return_value=_make_extractor_result(t),
            ),
        ):
            result = await agent.get_testables("src/**/*.py", project_path="/proj")
        assert result == [t]

    @pytest.mark.asyncio
    async def test_single_file_multiple_testables(self) -> None:
        agent = PyAgent()
        t1 = _make_testable(name="alpha")
        t2 = _make_testable(name="beta")
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["src/ab.py"]),
            patch("py_agent.api.py_agent.os.path.abspath", return_value="/proj/src/ab.py"),
            patch("py_agent.api.py_agent.os.path.relpath", return_value="src/ab.py"),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                return_value=_make_extractor_result(t1, t2),
            ),
        ):
            result = await agent.get_testables("src/**/*.py", project_path="/proj")
        assert result == [t1, t2]

    @pytest.mark.asyncio
    async def test_multiple_files_aggregated(self) -> None:
        agent = PyAgent()
        t1 = _make_testable(name="func_a", file_path="a.py")
        t2 = _make_testable(name="func_b", file_path="b.py")
        dto_a = _make_extractor_result(t1)
        dto_b = _make_extractor_result(t2)
        extractor_side_effects = [dto_a, dto_b]

        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["a.py", "b.py"]),
            patch(
                "py_agent.api.py_agent.os.path.abspath",
                side_effect=["/proj/a.py", "/proj/b.py"],
            ),
            patch(
                "py_agent.api.py_agent.os.path.relpath",
                side_effect=["a.py", "b.py"],
            ),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                side_effect=extractor_side_effects,
            ),
        ):
            result = await agent.get_testables("*.py", project_path="/proj")
        assert result == [t1, t2]


class TestGetTestablesErrorHandling:
    """Extractor exceptions are swallowed; other files still processed."""

    @pytest.mark.asyncio
    async def test_extractor_exception_is_swallowed(self) -> None:
        agent = PyAgent()
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["bad.py"]),
            patch("py_agent.api.py_agent.os.path.abspath", return_value="/proj/bad.py"),
            patch("py_agent.api.py_agent.os.path.relpath", return_value="bad.py"),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                side_effect=RuntimeError("parse error"),
            ),
        ):
            result = await agent.get_testables("*.py", project_path="/proj")
        assert result == []

    @pytest.mark.asyncio
    async def test_exception_in_one_file_does_not_affect_others(self) -> None:
        agent = PyAgent()
        t_good = _make_testable(name="good_func", file_path="good.py")
        dto_good = _make_extractor_result(t_good)

        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["bad.py", "good.py"]),
            patch(
                "py_agent.api.py_agent.os.path.abspath",
                side_effect=["/proj/bad.py", "/proj/good.py"],
            ),
            patch(
                "py_agent.api.py_agent.os.path.relpath",
                side_effect=["bad.py", "good.py"],
            ),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                side_effect=[RuntimeError("boom"), dto_good],
            ),
        ):
            result = await agent.get_testables("*.py", project_path="/proj")
        assert result == [t_good]

    @pytest.mark.asyncio
    async def test_relpath_value_error_falls_back_to_abs_path(self) -> None:
        """On Windows, relpath raises ValueError for cross-drive paths."""
        agent = PyAgent()
        t = _make_testable(name="cross_drive")
        with (
            patch("py_agent.api.py_agent.glob.glob", return_value=["D:\\src\\foo.py"]),
            patch(
                "py_agent.api.py_agent.os.path.abspath",
                return_value="D:\\src\\foo.py",
            ),
            patch(
                "py_agent.api.py_agent.os.path.relpath",
                side_effect=ValueError("path is on a different drive"),
            ),
            patch(
                "py_agent.api.py_agent.TestablesExtractor.get_testables",
                return_value=_make_extractor_result(t),
            ) as mock_extractor,
        ):
            result = await agent.get_testables("*.py", project_path="C:\\proj")
        # abs_path is passed as rel_path fallback
        mock_extractor.assert_called_once_with("D:\\src\\foo.py", "D:\\src\\foo.py", "C:\\proj")
        assert result == [t]


class TestGetTestablesRecursiveGlob:
    """glob is always called with recursive=True."""

    @pytest.mark.asyncio
    async def test_glob_called_with_recursive_true(self) -> None:
        agent = PyAgent()
        with patch("py_agent.api.py_agent.glob.glob", return_value=[]) as mock_glob:
            await agent.get_testables("src/**/*.py", project_path="/proj")
        mock_glob.assert_called_once_with("src/**/*.py", recursive=True)
