"""
Integration tests for MCP client-server communication.

These tests verify the MCP client functionality through a combination of:
1. Mock-based tests for fast, deterministic testing of client logic
2. Subprocess tests (marked with `integration`) for end-to-end verification

To run the subprocess tests locally:
    pytest tests/mcp/test_mcp_client_server_integration.py -m integration
"""

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

import pytest

# Skip all tests if MCP is not available
try:
    import mcp  # noqa: F401

    MCP_AVAILABLE = True
except ImportError:
    MCP_AVAILABLE = False

pytestmark = pytest.mark.skipif(
    not MCP_AVAILABLE, reason="MCP package not installed"
)

# Path to the echo server for testing
ECHO_SERVER_PATH = Path(__file__).parent / "echo_server.py"


class TestMCPClientWithMockedSession:
    """Tests for MCPClient using mocked session for fast, reliable testing."""

    @pytest.fixture
    def mock_tool_result(self):
        """Create a mock tool result."""

        def _make_result(text_content, is_error=False):
            mock_item = MagicMock()
            mock_item.text = text_content
            mock_result = MagicMock()
            mock_result.isError = is_error
            mock_result.content = [mock_item]
            return mock_result

        return _make_result

    @pytest.fixture
    def mock_list_tools_result(self):
        """Create a mock list tools result."""
        mock_tool1 = MagicMock()
        mock_tool1.name = "echo"
        mock_tool1.description = "Echo a message"
        mock_tool1.inputSchema = {
            "type": "object",
            "properties": {"message": {"type": "string"}},
        }

        mock_tool2 = MagicMock()
        mock_tool2.name = "add_numbers"
        mock_tool2.description = "Add two numbers"
        mock_tool2.inputSchema = {
            "type": "object",
            "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
        }

        mock_result = MagicMock()
        mock_result.tools = [mock_tool1, mock_tool2]
        return mock_result

    @pytest.mark.asyncio
    async def test_list_tools_with_mock(self, mock_list_tools_result):
        """Test list_tools returns properly formatted tool list."""
        from local_deep_research.mcp.client import MCPClient

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {"name": "mock-server", "command": "python"}
            client = MCPClient(config)

            # Set up mocked connected state
            client._connected = True
            client._session = MagicMock()
            client._session.list_tools = AsyncMock(
                return_value=mock_list_tools_result
            )

            tools = await client.list_tools()

            assert len(tools) == 2
            assert tools[0]["name"] == "echo"
            assert tools[0]["description"] == "Echo a message"
            assert tools[1]["name"] == "add_numbers"

    @pytest.mark.asyncio
    async def test_call_tool_success_with_mock(self, mock_tool_result):
        """Test call_tool returns success result."""
        from local_deep_research.mcp.client import MCPClient

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {"name": "mock-server", "command": "python"}
            client = MCPClient(config)

            client._connected = True
            client._session = MagicMock()
            client._session.call_tool = AsyncMock(
                return_value=mock_tool_result(
                    '{"status": "success", "message": "Hello, World!"}'
                )
            )

            result = await client.call_tool(
                "echo", {"message": "Hello, World!"}
            )

            assert result["status"] == "success"
            assert "Hello, World!" in result["content"]

    @pytest.mark.asyncio
    async def test_call_tool_error_result_with_mock(self, mock_tool_result):
        """Test call_tool handles error results from server."""
        from local_deep_research.mcp.client import MCPClient

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {"name": "mock-server", "command": "python"}
            client = MCPClient(config)

            client._connected = True
            client._session = MagicMock()
            client._session.call_tool = AsyncMock(
                return_value=mock_tool_result(
                    "Tool execution failed", is_error=True
                )
            )

            result = await client.call_tool("failing_tool", {})

            assert result["status"] == "error"

    @pytest.mark.asyncio
    async def test_call_tool_timeout_with_mock(self):
        """Test call_tool raises error on timeout."""
        from local_deep_research.mcp.client import MCPClient, MCPClientError

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {"name": "mock-server", "command": "python"}
            client = MCPClient(config, timeout=0.1)

            client._connected = True
            client._session = MagicMock()

            async def slow_call(*args, **kwargs):
                await asyncio.sleep(1.0)

            client._session.call_tool = slow_call

            with pytest.raises(MCPClientError) as exc_info:
                await client.call_tool("slow_tool", {})

            assert "timed out" in str(exc_info.value).lower()

    @pytest.mark.asyncio
    async def test_multiple_sequential_calls_with_mock(self, mock_tool_result):
        """Test multiple sequential tool calls work correctly."""
        from local_deep_research.mcp.client import MCPClient

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {"name": "mock-server", "command": "python"}
            client = MCPClient(config)

            client._connected = True
            client._session = MagicMock()

            call_count = 0

            async def mock_call_tool(name, args):
                nonlocal call_count
                call_count += 1
                return mock_tool_result(f"Result {call_count}")

            client._session.call_tool = mock_call_tool

            result1 = await client.call_tool("tool1", {})
            result2 = await client.call_tool("tool2", {})
            result3 = await client.call_tool("tool3", {})

            assert "Result 1" in result1["content"]
            assert "Result 2" in result2["content"]
            assert "Result 3" in result3["content"]
            assert call_count == 3


# =============================================================================
# Subprocess-based integration tests - marked for opt-in execution
# These tests spawn actual MCP servers and test real communication
# =============================================================================


@pytest.mark.integration
class TestRealMCPClientServerCommunication:
    """Tests for actual MCP client-server communication via subprocess.

    These tests are slower and require subprocess spawning. Run with:
        pytest -m integration
    """

    @pytest.fixture
    def echo_server_config(self):
        """Configuration for the echo test server."""
        return {
            "name": "echo-test",
            "command": sys.executable,
            "args": [str(ECHO_SERVER_PATH)],
        }

    @pytest.mark.asyncio
    async def test_connect_to_echo_server(self, echo_server_config):
        """Test connecting to the echo server subprocess."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            assert connected._connected is True

    @pytest.mark.asyncio
    async def test_list_tools_from_echo_server(self, echo_server_config):
        """Test listing tools from the running echo server."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            tools = await connected.list_tools()

            # Verify we got the expected tools
            tool_names = [t["name"] for t in tools]
            assert "echo" in tool_names
            assert "add_numbers" in tool_names
            assert "get_info" in tool_names

    @pytest.mark.asyncio
    async def test_call_echo_tool(self, echo_server_config):
        """Test calling the echo tool on the real server."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            result = await connected.call_tool(
                "echo", {"message": "Hello, MCP!"}
            )

            assert result["status"] == "success"
            # The content should contain the echoed message
            assert "Hello, MCP!" in result["content"]

    @pytest.mark.asyncio
    async def test_call_add_numbers_tool(self, echo_server_config):
        """Test calling the add_numbers tool."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            result = await connected.call_tool("add_numbers", {"a": 5, "b": 3})

            assert result["status"] == "success"
            # Result should contain 8
            assert "8" in result["content"]

    @pytest.mark.asyncio
    async def test_call_get_info_tool(self, echo_server_config):
        """Test calling the get_info tool."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            result = await connected.call_tool("get_info", {})

            assert result["status"] == "success"
            assert "echo-test-server" in result["content"]

    @pytest.mark.asyncio
    async def test_multiple_tool_calls_sequential(self, echo_server_config):
        """Test multiple sequential tool calls on one connection."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            # Call multiple tools sequentially
            result1 = await connected.call_tool("echo", {"message": "First"})
            result2 = await connected.call_tool(
                "add_numbers", {"a": 10, "b": 20}
            )
            result3 = await connected.call_tool("echo", {"message": "Third"})

            assert result1["status"] == "success"
            assert "First" in result1["content"]
            assert result2["status"] == "success"
            assert "30" in result2["content"]
            assert result3["status"] == "success"
            assert "Third" in result3["content"]

    @pytest.mark.asyncio
    async def test_connection_context_manager_cleanup(self, echo_server_config):
        """Test that connection is properly cleaned up after context exit."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            assert connected._connected is True

        # After context exit, should be disconnected
        assert client._connected is False
        assert client._session is None


@pytest.mark.integration
class TestMCPClientServerErrorHandling:
    """Tests for error handling in real client-server communication."""

    @pytest.fixture
    def echo_server_config(self):
        """Configuration for the echo test server."""
        return {
            "name": "echo-test",
            "command": sys.executable,
            "args": [str(ECHO_SERVER_PATH)],
        }

    @pytest.mark.asyncio
    async def test_tool_error_handling(self, echo_server_config):
        """Test handling of errors from tools."""
        from local_deep_research.mcp.client import MCPClient

        client = MCPClient(echo_server_config, timeout=60.0)

        async with client.connect() as connected:
            # Request the tool to fail intentionally
            result = await connected.call_tool(
                "fail_on_demand",
                {"should_fail": True, "error_message": "Test failure"},
            )

            # The MCP SDK returns error results, not exceptions for tool errors
            # Check if the result indicates an error
            assert (
                result["status"] == "error"
                or "error" in result.get("content", "").lower()
            )

    @pytest.mark.asyncio
    async def test_connection_to_invalid_command(self):
        """Test connection failure for invalid command."""
        from local_deep_research.mcp.client import MCPClient, MCPClientError

        config = {
            "name": "invalid",
            "command": "python",
            "args": ["/nonexistent/path/to/server.py"],
        }

        client = MCPClient(config, timeout=10.0)

        with pytest.raises(MCPClientError):
            async with client.connect():
                pass  # Should not reach here


class TestMCPClientValidation:
    """Tests for client configuration validation with real commands."""

    def test_allowed_commands_includes_python(self):
        """Test that python is in allowed commands."""
        from local_deep_research.mcp.client import ALLOWED_COMMANDS

        assert "python" in ALLOWED_COMMANDS
        assert "python3" in ALLOWED_COMMANDS

    def test_disallowed_command_raises_error(self):
        """Test that disallowed commands raise MCPClientError."""
        from local_deep_research.mcp.client import MCPClient, MCPClientError

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {
                "name": "malicious",
                "command": "rm",  # Not in allowed list
                "args": ["-rf", "/"],
            }

            with pytest.raises(MCPClientError) as exc_info:
                MCPClient(config)

            assert "not in allowed list" in str(exc_info.value)

    def test_missing_command_raises_error(self):
        """Test that missing command raises MCPClientError."""
        from local_deep_research.mcp.client import MCPClient, MCPClientError

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {
                "name": "no-command",
                # Missing 'command' key
            }

            with pytest.raises(MCPClientError) as exc_info:
                MCPClient(config)

            assert "missing" in str(exc_info.value).lower()

    def test_invalid_args_type_raises_error(self):
        """Test that non-list args raises MCPClientError."""
        from local_deep_research.mcp.client import MCPClient, MCPClientError

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {
                "name": "bad-args",
                "command": "python",
                "args": "not-a-list",  # Should be a list
            }

            with pytest.raises(MCPClientError) as exc_info:
                MCPClient(config)

            assert "must be a list" in str(exc_info.value)

    def test_invalid_env_type_raises_error(self):
        """Test that non-dict env raises MCPClientError."""
        from local_deep_research.mcp.client import MCPClient, MCPClientError

        with patch("local_deep_research.mcp.client.MCP_AVAILABLE", True):
            config = {
                "name": "bad-env",
                "command": "python",
                "env": ["not", "a", "dict"],  # Should be a dict
            }

            with pytest.raises(MCPClientError) as exc_info:
                MCPClient(config)

            assert "must be a dictionary" in str(exc_info.value)
