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

import pytest

from codebase_rag import constants as cs
from codebase_rag.mcp.client import query_mcp_server
from codebase_rag.mcp.tools import MCPToolsRegistry

pytestmark = [pytest.mark.anyio]


@pytest.fixture(params=["asyncio"])
def anyio_backend(request: pytest.FixtureRequest) -> str:
    return str(request.param)


@pytest.fixture
def temp_project_root(tmp_path: Path) -> Path:
    sample_file = tmp_path / "app.py"
    sample_file.write_text("def main(): pass\n", encoding="utf-8")
    return tmp_path


@pytest.fixture
def mcp_registry(temp_project_root: Path) -> MCPToolsRegistry:
    mock_ingestor = MagicMock()
    mock_cypher_gen = MagicMock()

    registry = MCPToolsRegistry(
        project_root=str(temp_project_root),
        ingestor=mock_ingestor,
        cypher_gen=mock_cypher_gen,
    )
    return registry


class TestUpdateRepository:
    async def test_update_repository_success(
        self, mcp_registry: MCPToolsRegistry
    ) -> None:
        with patch("codebase_rag.mcp.tools.GraphUpdater") as mock_updater_cls:
            mock_updater = MagicMock()
            mock_updater_cls.return_value = mock_updater

            result = await mcp_registry.update_repository()

            mock_updater_cls.assert_called_once()
            mock_updater.run.assert_called_once()
            assert mcp_registry.project_root in result

    async def test_update_repository_error(
        self, mcp_registry: MCPToolsRegistry
    ) -> None:
        with patch("codebase_rag.mcp.tools.GraphUpdater") as mock_updater_cls:
            mock_updater_cls.side_effect = RuntimeError("parse error")

            result = await mcp_registry.update_repository()

            assert "Error" in result

    async def test_update_repository_registered(
        self, mcp_registry: MCPToolsRegistry
    ) -> None:
        assert cs.MCPToolName.UPDATE_REPOSITORY in mcp_registry._tools

    async def test_update_repository_no_wipe(
        self, mcp_registry: MCPToolsRegistry
    ) -> None:
        with patch("codebase_rag.mcp.tools.GraphUpdater") as mock_updater_cls:
            mock_updater = MagicMock()
            mock_updater_cls.return_value = mock_updater

            await mcp_registry.update_repository()

            mcp_registry.ingestor.delete_project.assert_not_called()
            mcp_registry.ingestor.clean_database.assert_not_called()


class TestSemanticSearchRegistration:
    def test_semantic_search_not_registered_without_deps(
        self, temp_project_root: Path
    ) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with patch(
            "codebase_rag.mcp.tools.has_semantic_dependencies",
            return_value=False,
        ):
            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

        assert cs.MCPToolName.SEMANTIC_SEARCH not in registry._tools
        assert registry._semantic_search_available is False

    def test_semantic_search_registered_with_deps(
        self, temp_project_root: Path
    ) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with (
            patch(
                "codebase_rag.mcp.tools.has_semantic_dependencies",
                return_value=True,
            ),
            patch(
                "codebase_rag.tools.semantic_search.create_semantic_search_tool"
            ) as mock_create,
        ):
            mock_tool = MagicMock()
            mock_create.return_value = mock_tool

            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

            assert cs.MCPToolName.SEMANTIC_SEARCH in registry._tools
            assert registry._semantic_search_available is True

    async def test_semantic_search_calls_tool(self, temp_project_root: Path) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with (
            patch(
                "codebase_rag.mcp.tools.has_semantic_dependencies",
                return_value=True,
            ),
            patch(
                "codebase_rag.tools.semantic_search.create_semantic_search_tool"
            ) as mock_create,
        ):
            mock_tool = MagicMock()
            mock_tool.function = AsyncMock(return_value="result1, result2")
            mock_create.return_value = mock_tool

            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

            result = await registry.semantic_search("find auth functions", top_k=3)

            mock_tool.function.assert_called_once_with(
                query="find auth functions", top_k=3
            )
            assert "result1" in result


class TestAskAgent:
    async def test_ask_agent_registered(self, mcp_registry: MCPToolsRegistry) -> None:
        assert cs.MCPToolName.ASK_AGENT in mcp_registry._tools

    async def test_ask_agent_success(self, mcp_registry: MCPToolsRegistry) -> None:
        mock_agent = MagicMock()
        mock_response = MagicMock()
        mock_response.output = "The auth module uses JWT tokens."
        mock_agent.run = AsyncMock(return_value=mock_response)
        mcp_registry.rag_agent = mock_agent

        result = await mcp_registry.ask_agent("How is auth implemented?")

        assert result["output"] == "The auth module uses JWT tokens."
        mock_agent.run.assert_called_once_with(
            "How is auth implemented?", message_history=[]
        )

    async def test_ask_agent_error(self, mcp_registry: MCPToolsRegistry) -> None:
        mock_agent = MagicMock()
        mock_agent.run = AsyncMock(side_effect=RuntimeError("LLM unavailable"))
        mcp_registry.rag_agent = mock_agent

        result = await mcp_registry.ask_agent("What does main do?")

        assert "error" in result


class TestToolDescriptions:
    def test_update_repository_in_tool_map(self) -> None:
        from codebase_rag.tools.tool_descriptions import MCP_TOOLS

        assert cs.MCPToolName.UPDATE_REPOSITORY in MCP_TOOLS

    def test_semantic_search_in_tool_map(self) -> None:
        from codebase_rag.tools.tool_descriptions import MCP_TOOLS

        assert cs.MCPToolName.SEMANTIC_SEARCH in MCP_TOOLS

    def test_ask_agent_in_tool_map(self) -> None:
        from codebase_rag.tools.tool_descriptions import MCP_TOOLS

        assert cs.MCPToolName.ASK_AGENT in MCP_TOOLS

    def test_index_repository_warns_about_project_clear(self) -> None:
        from codebase_rag.tools.tool_descriptions import MCP_INDEX_REPOSITORY

        assert "current project" in MCP_INDEX_REPOSITORY
        assert "entire database" not in MCP_INDEX_REPOSITORY


class TestRagAgentProperty:
    def test_rag_agent_setter_allows_mock(self, mcp_registry: MCPToolsRegistry) -> None:
        mock_agent = MagicMock()
        mcp_registry.rag_agent = mock_agent
        assert mcp_registry.rag_agent is mock_agent

    def test_rag_agent_lazy_init(self, temp_project_root: Path) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with patch(
            "codebase_rag.mcp.tools.has_semantic_dependencies",
            return_value=False,
        ):
            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

        assert registry._rag_agent is None

        with patch("codebase_rag.mcp.tools.create_rag_orchestrator") as mock_create:
            mock_agent = MagicMock()
            mock_create.return_value = (mock_agent, "system prompt")

            agent = registry.rag_agent

            mock_create.assert_called_once()
            assert agent is mock_agent

    def test_rag_agent_includes_function_source_tool(
        self, temp_project_root: Path
    ) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with patch(
            "codebase_rag.mcp.tools.has_semantic_dependencies",
            return_value=False,
        ):
            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

        with (
            patch("codebase_rag.mcp.tools.create_rag_orchestrator") as mock_create,
            patch(
                "codebase_rag.tools.semantic_search.create_get_function_source_tool"
            ) as mock_fst,
        ):
            mock_tool = MagicMock()
            mock_fst.return_value = mock_tool
            mock_create.return_value = (MagicMock(), "system prompt")

            registry.rag_agent

            tools_arg = mock_create.call_args[1]["tools"]
            assert mock_tool in tools_arg

    def test_rag_agent_includes_semantic_search_when_available(
        self, temp_project_root: Path
    ) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with (
            patch(
                "codebase_rag.mcp.tools.has_semantic_dependencies",
                return_value=True,
            ),
            patch(
                "codebase_rag.tools.semantic_search.create_semantic_search_tool"
            ) as mock_ss,
        ):
            mock_ss_tool = MagicMock()
            mock_ss.return_value = mock_ss_tool

            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

        with (
            patch("codebase_rag.mcp.tools.create_rag_orchestrator") as mock_create,
            patch("codebase_rag.tools.semantic_search.create_get_function_source_tool"),
        ):
            mock_create.return_value = (MagicMock(), "system prompt")
            registry.rag_agent

            tools_arg = mock_create.call_args[1]["tools"]
            assert mock_ss_tool in tools_arg

    def test_rag_agent_caches_after_first_access(self, temp_project_root: Path) -> None:
        mock_ingestor = MagicMock()
        mock_cypher_gen = MagicMock()

        with patch(
            "codebase_rag.mcp.tools.has_semantic_dependencies",
            return_value=False,
        ):
            registry = MCPToolsRegistry(
                project_root=str(temp_project_root),
                ingestor=mock_ingestor,
                cypher_gen=mock_cypher_gen,
            )

        with (
            patch("codebase_rag.mcp.tools.create_rag_orchestrator") as mock_create,
            patch("codebase_rag.tools.semantic_search.create_get_function_source_tool"),
        ):
            mock_create.return_value = (MagicMock(), "system prompt")

            agent1 = registry.rag_agent
            agent2 = registry.rag_agent

            mock_create.assert_called_once()
            assert agent1 is agent2


class TestMainSingleQuery:
    def test_main_single_query_prints_output(
        self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
    ) -> None:
        from codebase_rag.main import main_single_query

        mock_response = MagicMock()
        mock_response.output = "The answer is 42."

        with (
            patch("codebase_rag.main.connect_memgraph") as mock_conn,
            patch("codebase_rag.main._initialize_services_and_agent") as mock_init,
            patch("codebase_rag.main.asyncio") as mock_asyncio,
            patch("codebase_rag.main._setup_common_initialization"),
        ):
            mock_agent = MagicMock()
            mock_init.return_value = (mock_agent, [], "system prompt")
            mock_asyncio.run.return_value = mock_response
            mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock())
            mock_conn.return_value.__exit__ = MagicMock(return_value=False)

            main_single_query(str(tmp_path), 1000, "What is the answer?")

            captured = capsys.readouterr()
            assert "The answer is 42." in captured.out

    def test_main_single_query_routes_logs_to_stderr(self, tmp_path: Path) -> None:
        from codebase_rag.main import main_single_query

        mock_response = MagicMock()
        mock_response.output = "result"

        with (
            patch("codebase_rag.main.connect_memgraph") as mock_conn,
            patch("codebase_rag.main._initialize_services_and_agent") as mock_init,
            patch("codebase_rag.main.asyncio") as mock_asyncio,
            patch("codebase_rag.main._setup_common_initialization"),
            patch("codebase_rag.main.logger") as mock_logger,
        ):
            mock_agent = MagicMock()
            mock_init.return_value = (mock_agent, [], "system prompt")
            mock_asyncio.run.return_value = mock_response
            mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock())
            mock_conn.return_value.__exit__ = MagicMock(return_value=False)

            main_single_query(str(tmp_path), 1000, "test")

            mock_logger.remove.assert_called_once()
            mock_logger.add.assert_called_once()
            add_args = mock_logger.add.call_args
            import sys

            assert add_args[0][0] is sys.stderr


class TestMCPClient:
    def test_query_mcp_server_is_callable(self) -> None:
        assert callable(query_mcp_server)

    def test_client_uses_constants(self) -> None:
        import inspect

        from codebase_rag.mcp import client

        source = inspect.getsource(client)
        assert "MCPToolName.ASK_AGENT" in source
        assert "MCPParamName.QUESTION" in source

    def test_query_with_errlog_is_async(self) -> None:
        import asyncio

        from codebase_rag.mcp.client import _query_with_errlog

        assert asyncio.iscoroutinefunction(_query_with_errlog)

    async def test_query_with_errlog_json_response(self) -> None:
        import io

        from codebase_rag.mcp.client import _query_with_errlog

        mock_content = MagicMock()
        mock_content.text = '{"output": "test answer"}'
        mock_result = MagicMock()
        mock_result.content = [mock_content]

        mock_session = AsyncMock()
        mock_session.initialize = AsyncMock()
        mock_session.call_tool = AsyncMock(return_value=mock_result)
        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
        mock_session.__aexit__ = AsyncMock(return_value=False)

        mock_transport = AsyncMock()
        mock_transport.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
        mock_transport.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("codebase_rag.mcp.client.stdio_client", return_value=mock_transport),
            patch("codebase_rag.mcp.client.ClientSession", return_value=mock_session),
        ):
            result = await _query_with_errlog("test question", io.StringIO())

        assert result == {"output": "test answer"}

    async def test_query_with_errlog_non_json_response(self) -> None:
        import io

        from codebase_rag.mcp.client import _query_with_errlog

        mock_content = MagicMock()
        mock_content.text = "plain text response"
        mock_result = MagicMock()
        mock_result.content = [mock_content]

        mock_session = AsyncMock()
        mock_session.initialize = AsyncMock()
        mock_session.call_tool = AsyncMock(return_value=mock_result)
        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
        mock_session.__aexit__ = AsyncMock(return_value=False)

        mock_transport = AsyncMock()
        mock_transport.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
        mock_transport.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("codebase_rag.mcp.client.stdio_client", return_value=mock_transport),
            patch("codebase_rag.mcp.client.ClientSession", return_value=mock_session),
        ):
            result = await _query_with_errlog("test", io.StringIO())

        assert result == {"output": "plain text response"}

    async def test_query_with_errlog_empty_response(self) -> None:
        import io

        from codebase_rag.mcp.client import _query_with_errlog

        mock_result = MagicMock()
        mock_result.content = []

        mock_session = AsyncMock()
        mock_session.initialize = AsyncMock()
        mock_session.call_tool = AsyncMock(return_value=mock_result)
        mock_session.__aenter__ = AsyncMock(return_value=mock_session)
        mock_session.__aexit__ = AsyncMock(return_value=False)

        mock_transport = AsyncMock()
        mock_transport.__aenter__ = AsyncMock(return_value=(MagicMock(), MagicMock()))
        mock_transport.__aexit__ = AsyncMock(return_value=False)

        with (
            patch("codebase_rag.mcp.client.stdio_client", return_value=mock_transport),
            patch("codebase_rag.mcp.client.ClientSession", return_value=mock_session),
        ):
            result = await _query_with_errlog("test", io.StringIO())

        assert result == {"output": "No response from server"}

    def test_query_mcp_server_opens_devnull(self) -> None:
        with (
            patch("codebase_rag.mcp.client.asyncio") as mock_asyncio,
            patch("builtins.open", MagicMock()) as mock_open,
        ):
            mock_asyncio.run.return_value = {"output": "result"}
            query_mcp_server("test")
            mock_open.assert_called_once()
