import logging
from contextlib import contextmanager
from typing import Any
from unittest.mock import patch

import pytest

from mcp.server.lowlevel import Server
from mcp.shared.memory import (
    create_connected_server_and_client_session as client_session,
)
from mcp.types import Tool


@contextmanager
def bypass_server_output_validation():
    """
    Context manager that bypasses server-side output validation.
    This simulates a malicious or non-compliant server that doesn't validate
    its outputs, allowing us to test client-side validation.
    """
    import jsonschema

    # Save the original validate function
    original_validate = jsonschema.validate

    # Create a mock that tracks which module is calling it
    def selective_mock(instance: Any = None, schema: Any = None, *args: Any, **kwargs: Any) -> None:
        import inspect

        # Check the call stack to see where this is being called from
        for frame_info in inspect.stack():
            # If called from the server module, skip validation
            # TODO: fix this as it's a rather gross workaround and will eventually break
            # Normalize path separators for cross-platform compatibility
            normalized_path = frame_info.filename.replace("\\", "/")
            if "mcp/server/lowlevel/server.py" in normalized_path:
                return None
        # Otherwise, use the real validation (for client-side)
        return original_validate(instance=instance, schema=schema, *args, **kwargs)

    with patch("jsonschema.validate", selective_mock):
        yield


class TestClientOutputSchemaValidation:
    """Test client-side validation of structured output from tools"""

    @pytest.mark.anyio
    async def test_tool_structured_output_client_side_validation_basemodel(self):
        """Test that client validates structured content against schema for BaseModel outputs"""
        # Create a malicious low-level server that returns invalid structured content
        server = Server("test-server")

        # Define the expected schema for our tool
        output_schema = {
            "type": "object",
            "properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
            "required": ["name", "age"],
            "title": "UserOutput",
        }

        @server.list_tools()
        async def list_tools():
            return [
                Tool(
                    name="get_user",
                    description="Get user data",
                    inputSchema={"type": "object"},
                    outputSchema=output_schema,
                )
            ]

        @server.call_tool()
        async def call_tool(name: str, arguments: dict[str, Any]):
            # Return invalid structured content - age is string instead of integer
            # The low-level server will wrap this in CallToolResult
            return {"name": "John", "age": "invalid"}  # Invalid: age should be int

        # Test that client validates the structured content
        with bypass_server_output_validation():
            async with client_session(server) as client:
                # The client validates structured content and should raise an error
                with pytest.raises(RuntimeError) as exc_info:
                    await client.call_tool("get_user", {})
                # Verify it's a validation error
                assert "Invalid structured content returned by tool get_user" in str(exc_info.value)

    @pytest.mark.anyio
    async def test_tool_structured_output_client_side_validation_primitive(self):
        """Test that client validates structured content for primitive outputs"""
        server = Server("test-server")

        # Primitive types are wrapped in {"result": value}
        output_schema = {
            "type": "object",
            "properties": {"result": {"type": "integer", "title": "Result"}},
            "required": ["result"],
            "title": "calculate_Output",
        }

        @server.list_tools()
        async def list_tools():
            return [
                Tool(
                    name="calculate",
                    description="Calculate something",
                    inputSchema={"type": "object"},
                    outputSchema=output_schema,
                )
            ]

        @server.call_tool()
        async def call_tool(name: str, arguments: dict[str, Any]):
            # Return invalid structured content - result is string instead of integer
            return {"result": "not_a_number"}  # Invalid: should be int

        with bypass_server_output_validation():
            async with client_session(server) as client:
                # The client validates structured content and should raise an error
                with pytest.raises(RuntimeError) as exc_info:
                    await client.call_tool("calculate", {})
                assert "Invalid structured content returned by tool calculate" in str(exc_info.value)

    @pytest.mark.anyio
    async def test_tool_structured_output_client_side_validation_dict_typed(self):
        """Test that client validates dict[str, T] structured content"""
        server = Server("test-server")

        # dict[str, int] schema
        output_schema = {"type": "object", "additionalProperties": {"type": "integer"}, "title": "get_scores_Output"}

        @server.list_tools()
        async def list_tools():
            return [
                Tool(
                    name="get_scores",
                    description="Get scores",
                    inputSchema={"type": "object"},
                    outputSchema=output_schema,
                )
            ]

        @server.call_tool()
        async def call_tool(name: str, arguments: dict[str, Any]):
            # Return invalid structured content - values should be integers
            return {"alice": "100", "bob": "85"}  # Invalid: values should be int

        with bypass_server_output_validation():
            async with client_session(server) as client:
                # The client validates structured content and should raise an error
                with pytest.raises(RuntimeError) as exc_info:
                    await client.call_tool("get_scores", {})
                assert "Invalid structured content returned by tool get_scores" in str(exc_info.value)

    @pytest.mark.anyio
    async def test_tool_structured_output_client_side_validation_missing_required(self):
        """Test that client validates missing required fields"""
        server = Server("test-server")

        output_schema = {
            "type": "object",
            "properties": {"name": {"type": "string"}, "age": {"type": "integer"}, "email": {"type": "string"}},
            "required": ["name", "age", "email"],  # All fields required
            "title": "PersonOutput",
        }

        @server.list_tools()
        async def list_tools():
            return [
                Tool(
                    name="get_person",
                    description="Get person data",
                    inputSchema={"type": "object"},
                    outputSchema=output_schema,
                )
            ]

        @server.call_tool()
        async def call_tool(name: str, arguments: dict[str, Any]):
            # Return structured content missing required field 'email'
            return {"name": "John", "age": 30}  # Missing required 'email'

        with bypass_server_output_validation():
            async with client_session(server) as client:
                # The client validates structured content and should raise an error
                with pytest.raises(RuntimeError) as exc_info:
                    await client.call_tool("get_person", {})
                assert "Invalid structured content returned by tool get_person" in str(exc_info.value)

    @pytest.mark.anyio
    async def test_tool_not_listed_warning(self, caplog: pytest.LogCaptureFixture):
        """Test that client logs warning when tool is not in list_tools but has outputSchema"""
        server = Server("test-server")

        @server.list_tools()
        async def list_tools() -> list[Tool]:
            # Return empty list - tool is not listed
            return []

        @server.call_tool()
        async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
            # Server still responds to the tool call with structured content
            return {"result": 42}

        # Set logging level to capture warnings
        caplog.set_level(logging.WARNING)

        with bypass_server_output_validation():
            async with client_session(server) as client:
                # Call a tool that wasn't listed
                result = await client.call_tool("mystery_tool", {})
                assert result.structuredContent == {"result": 42}
                assert result.isError is False

                # Check that warning was logged
                assert "Tool mystery_tool not listed" in caplog.text
