"""Coverage tests for mcp/client.py.

Focuses on:
- MCPClient._validate_server_config error paths
- MCPClient._initialize_with_retry timeout/error handling
- MCPClient.list_tools / call_tool when not connected
- MCPClient.call_tool result parsing (error flag, content types)
- MCPClientManager.call_tool server not connected
- MCPClientManager.list_all_tools error handling
- MCPClientManager.get_connected_servers
- run_async helper (sync and async context)
- ALLOWED_COMMANDS constant
"""

import asyncio
from unittest.mock import AsyncMock, MagicMock

import pytest

MODULE = "local_deep_research.mcp.client"


# ---------------------------------------------------------------------------
# Skip if MCP not installed
# ---------------------------------------------------------------------------


def _mcp_available():
    try:
        import mcp  # noqa: F401

        return True
    except ImportError:
        return False


pytestmark = pytest.mark.skipif(
    not _mcp_available(),
    reason="mcp package not installed",
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _valid_config(command="python", name="test-server"):
    return {"command": command, "args": ["server.py"], "name": name}


def _get_client_class():
    from local_deep_research.mcp.client import MCPClient

    return MCPClient


def _get_error_class():
    from local_deep_research.mcp.client import MCPClientError

    return MCPClientError


def _get_manager_class():
    from local_deep_research.mcp.client import MCPClientManager

    return MCPClientManager


# ---------------------------------------------------------------------------
# _validate_server_config
# ---------------------------------------------------------------------------


class TestValidateServerConfig:
    def test_missing_command_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(MCPClientError, match="missing 'command'"):
            MCPClient({})

    def test_empty_command_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(MCPClientError, match="missing 'command'"):
            MCPClient({"command": "  "})

    def test_command_with_spaces_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(
            MCPClientError, match="single executable name without spaces"
        ):
            MCPClient({"command": "python script.py"})

    def test_disallowed_command_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(MCPClientError, match="not in allowed list"):
            MCPClient({"command": "bash"})

    def test_args_not_list_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(MCPClientError, match="'args' must be a list"):
            MCPClient({"command": "python", "args": "script.py"})

    def test_args_with_non_strings_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(
            MCPClientError, match="'args' must contain only strings"
        ):
            MCPClient({"command": "python", "args": [123]})

    def test_env_not_dict_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(MCPClientError, match="'env' must be a dictionary"):
            MCPClient({"command": "python", "env": ["KEY=VAL"]})

    def test_env_with_non_string_values_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(MCPClientError, match="only string keys and values"):
            MCPClient({"command": "python", "env": {"KEY": 42}})

    def test_valid_config_accepted(self):
        MCPClient = _get_client_class()
        client = MCPClient(_valid_config())
        assert client.name == "test-server"
        assert client.timeout == 60.0

    def test_path_based_command_accepted(self):
        """Full path to an allowed command like /usr/bin/node is accepted."""
        MCPClient = _get_client_class()
        client = MCPClient({"command": "/usr/bin/node", "args": []})
        assert client is not None

    def test_command_with_tab_raises(self):
        MCPClientError = _get_error_class()
        MCPClient = _get_client_class()
        with pytest.raises(
            MCPClientError, match="single executable name without spaces"
        ):
            MCPClient({"command": "python\tscript"})


# ---------------------------------------------------------------------------
# MCPClient when not connected
# ---------------------------------------------------------------------------


class TestMCPClientNotConnected:
    def test_list_tools_not_connected(self):
        MCPClient = _get_client_class()
        MCPClientError = _get_error_class()
        client = MCPClient(_valid_config())
        client._connected = False
        with pytest.raises(MCPClientError, match="Not connected"):
            asyncio.run(client.list_tools())

    def test_call_tool_not_connected(self):
        MCPClient = _get_client_class()
        MCPClientError = _get_error_class()
        client = MCPClient(_valid_config())
        client._connected = False
        with pytest.raises(MCPClientError, match="Not connected"):
            asyncio.run(client.call_tool("some_tool", {}))


# ---------------------------------------------------------------------------
# MCPClient.call_tool result parsing
# ---------------------------------------------------------------------------


class TestCallToolResultParsing:
    def _run(self, coro):
        return asyncio.run(coro)

    def _make_connected_client(self):
        MCPClient = _get_client_class()
        client = MCPClient(_valid_config())
        client._connected = True
        client._session = MagicMock()
        return client

    def test_error_result_returns_error_status(self):
        client = self._make_connected_client()
        mock_result = MagicMock()
        mock_result.isError = True
        mock_result.content = "Something went wrong"
        client._session.call_tool = AsyncMock(return_value=mock_result)
        result = self._run(client.call_tool("bad_tool"))
        assert result["status"] == "error"

    def test_success_result_with_text_content(self):
        client = self._make_connected_client()
        item = MagicMock()
        item.text = "result text"
        del item.data  # Ensure no .data attr
        mock_result = MagicMock()
        mock_result.isError = False
        mock_result.content = [item]
        client._session.call_tool = AsyncMock(return_value=mock_result)
        result = self._run(client.call_tool("good_tool"))
        assert result["status"] == "success"
        assert "result text" in result["content"]

    def test_success_result_with_data_content(self):
        client = self._make_connected_client()
        item = MagicMock(spec=["data"])
        item.data = b"\x00\x01"
        mock_result = MagicMock()
        mock_result.isError = False
        mock_result.content = [item]
        client._session.call_tool = AsyncMock(return_value=mock_result)
        result = self._run(client.call_tool("data_tool"))
        assert result["status"] == "success"

    def test_success_result_with_none_content(self):
        client = self._make_connected_client()
        mock_result = MagicMock()
        mock_result.isError = False
        mock_result.content = None
        client._session.call_tool = AsyncMock(return_value=mock_result)
        result = self._run(client.call_tool("empty_tool"))
        assert result["status"] == "success"
        assert result["content"] == ""

    def test_timeout_raises_mcp_client_error(self):
        from local_deep_research.mcp.client import MCPClientError

        client = self._make_connected_client()
        client._session.call_tool = AsyncMock(
            side_effect=asyncio.TimeoutError("timed out")
        )
        with pytest.raises(MCPClientError, match="timed out"):
            self._run(client.call_tool("slow_tool"))

    def test_other_exception_raises_mcp_client_error(self):
        from local_deep_research.mcp.client import MCPClientError

        client = self._make_connected_client()
        client._session.call_tool = AsyncMock(
            side_effect=RuntimeError("unexpected")
        )
        with pytest.raises(MCPClientError, match="Tool call failed"):
            self._run(client.call_tool("fail_tool"))


# ---------------------------------------------------------------------------
# MCPClient.list_tools
# ---------------------------------------------------------------------------


class TestListTools:
    def _run(self, coro):
        return asyncio.run(coro)

    def _make_connected_client(self):
        MCPClient = _get_client_class()
        client = MCPClient(_valid_config())
        client._connected = True
        client._session = MagicMock()
        return client

    def test_list_tools_returns_tool_dicts(self):
        client = self._make_connected_client()
        tool = MagicMock()
        tool.name = "search"
        tool.description = "Search the web"
        tool.inputSchema = {"type": "object"}
        mock_result = MagicMock()
        mock_result.tools = [tool]
        client._session.list_tools = AsyncMock(return_value=mock_result)
        result = self._run(client.list_tools())
        assert len(result) == 1
        assert result[0]["name"] == "search"
        assert result[0]["description"] == "Search the web"

    def test_timeout_raises_mcp_client_error(self):
        from local_deep_research.mcp.client import MCPClientError

        client = self._make_connected_client()
        client._session.list_tools = AsyncMock(
            side_effect=asyncio.TimeoutError()
        )
        with pytest.raises(MCPClientError, match="Timeout listing tools"):
            self._run(client.list_tools())

    def test_tool_without_input_schema(self):
        client = self._make_connected_client()
        tool = MagicMock(spec=["name", "description"])
        tool.name = "no_schema"
        tool.description = None
        mock_result = MagicMock()
        mock_result.tools = [tool]
        client._session.list_tools = AsyncMock(return_value=mock_result)
        result = self._run(client.list_tools())
        assert result[0]["input_schema"] == {}
        assert result[0]["description"] == ""


# ---------------------------------------------------------------------------
# MCPClientManager
# ---------------------------------------------------------------------------


class TestMCPClientManager:
    def test_call_tool_unknown_server_raises(self):
        from local_deep_research.mcp.client import MCPClientError

        MCPClientManager = _get_manager_class()
        mgr = MCPClientManager([])
        with pytest.raises(MCPClientError, match="not connected"):
            asyncio.run(mgr.call_tool("unknown_server", "tool_name"))

    def test_get_connected_servers_empty(self):
        MCPClientManager = _get_manager_class()
        mgr = MCPClientManager([])
        assert mgr.get_connected_servers() == []

    def test_get_connected_servers_with_clients(self):
        MCPClientManager = _get_manager_class()
        mgr = MCPClientManager([])
        mgr._clients = {"server1": MagicMock(), "server2": MagicMock()}
        servers = mgr.get_connected_servers()
        assert set(servers) == {"server1", "server2"}

    def test_list_all_tools_handles_errors(self):
        MCPClientManager = _get_manager_class()
        from local_deep_research.mcp.client import MCPClientError

        mgr = MCPClientManager([])
        failing_client = MagicMock()
        failing_client.list_tools = AsyncMock(
            side_effect=MCPClientError("list failed")
        )
        mgr._clients = {"fail_server": failing_client}

        result = asyncio.run(mgr.list_all_tools())
        assert result["fail_server"] == []


# ---------------------------------------------------------------------------
# run_async helper
# ---------------------------------------------------------------------------


class TestRunAsync:
    def test_run_async_no_event_loop(self):
        from local_deep_research.mcp.client import run_async

        async def simple_coro():
            return 42

        # Should work fine outside event loop
        result = run_async(simple_coro())
        assert result == 42

    def test_run_async_timeout_raises(self):
        from local_deep_research.mcp.client import MCPClientError, run_async

        async def slow_coro():
            await asyncio.sleep(999)

        with pytest.raises((MCPClientError, Exception)):
            run_async(slow_coro(), timeout=0.001)


# ---------------------------------------------------------------------------
# ALLOWED_COMMANDS constant
# ---------------------------------------------------------------------------


class TestAllowedCommands:
    def test_expected_commands_present(self):
        from local_deep_research.mcp.client import ALLOWED_COMMANDS

        assert "node" in ALLOWED_COMMANDS
        assert "python" in ALLOWED_COMMANDS
        assert "python3" in ALLOWED_COMMANDS
        assert "uvx" in ALLOWED_COMMANDS

    def test_is_set_type(self):
        from local_deep_research.mcp.client import ALLOWED_COMMANDS

        assert isinstance(ALLOWED_COMMANDS, set)
