# Copyright (c) Microsoft. All rights reserved.

import logging
import re
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from mcp import ClientSession, ListToolsResult, StdioServerParameters, Tool, types

from semantic_kernel.connectors.mcp import MCPSsePlugin, MCPStdioPlugin, MCPStreamableHttpPlugin, MCPWebsocketPlugin
from semantic_kernel.exceptions import KernelPluginInvalidConfigurationError

if TYPE_CHECKING:
    from semantic_kernel import Kernel


@pytest.fixture
def list_tool_calls_with_slash() -> ListToolsResult:
    return ListToolsResult(
        tools=[
            Tool(
                name="nasa/get-astronomy-picture",
                description="func with slash",
                inputSchema={"properties": {}, "required": []},
            ),
            Tool(
                name="weird\\name with spaces",
                description="func with backslash and spaces",
                inputSchema={"properties": {}, "required": []},
            ),
        ]
    )


@pytest.fixture
def list_tool_calls() -> ListToolsResult:
    return ListToolsResult(
        tools=[
            Tool(
                name="func1",
                description="func1",
                inputSchema={
                    "properties": {
                        "name": {"type": "string"},
                    },
                    "required": ["name"],
                },
            ),
            Tool(
                name="func2",
                description="func2",
                inputSchema={},
            ),
        ]
    )


@pytest.mark.parametrize(
    "plugin_class,plugin_args",
    [
        (MCPSsePlugin, {"url": "http://localhost:8080/sse"}),
        (MCPStreamableHttpPlugin, {"url": "http://localhost:8080/mcp"}),
    ],
)
async def test_mcp_plugin_session_not_initialize(plugin_class, plugin_args):
    # Test if Client can insert it's own Session
    mock_session = AsyncMock(spec=ClientSession)
    mock_session._request_id = 0
    mock_session.initialize = AsyncMock()
    async with plugin_class(name="test", session=mock_session, **plugin_args) as plugin:
        assert plugin.session is mock_session
        assert mock_session.initialize.called


@pytest.mark.parametrize(
    "plugin_class,plugin_args",
    [
        (MCPSsePlugin, {"url": "http://localhost:8080/sse"}),
        (MCPStreamableHttpPlugin, {"url": "http://localhost:8080/mcp"}),
    ],
)
async def test_mcp_plugin_session_initialized(plugin_class, plugin_args):
    # Test if Client can insert it's own initialized Session
    mock_session = AsyncMock(spec=ClientSession)
    mock_session._request_id = 1
    mock_session.initialize = AsyncMock()
    async with plugin_class(name="test", session=mock_session, **plugin_args) as plugin:
        assert plugin.session is mock_session
        assert not mock_session.initialize.called


async def test_mcp_sampling_denied_by_consent_callback():
    sampling_consent_callback = AsyncMock(return_value=False)
    plugin = MCPSsePlugin(
        name="TestMCPPlugin",
        url="http://localhost:8080/sse",
        sampling_consent_callback=sampling_consent_callback,
    )
    params = types.CreateMessageRequestParams(
        messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
        systemPrompt="server instructions",
        maxTokens=100,
    )

    result = await plugin.sampling_callback(MagicMock(), params)

    sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params)
    assert isinstance(result, types.ErrorData)
    assert result.message == "Sampling denied by policy."


async def test_mcp_sampling_consent_callback_error_denies_request(caplog):
    sampling_consent_callback = AsyncMock(side_effect=RuntimeError("policy failure"))
    plugin = MCPSsePlugin(
        name="TestMCPPlugin",
        url="http://localhost:8080/sse",
        sampling_consent_callback=sampling_consent_callback,
    )
    params = types.CreateMessageRequestParams(
        messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
        systemPrompt="server instructions",
        maxTokens=100,
    )

    with caplog.at_level(logging.ERROR, logger="semantic_kernel.connectors.mcp"):
        result = await plugin.sampling_callback(MagicMock(), params)

    sampling_consent_callback.assert_awaited_once_with("TestMCPPlugin", params)
    assert isinstance(result, types.ErrorData)
    assert result.message == "Sampling denied by policy."
    assert "MCP sampling consent callback failed" in caplog.text


async def test_mcp_sampling_without_consent_callback_denies_by_default(caplog):
    plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
    params = types.CreateMessageRequestParams(
        messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
        systemPrompt="server instructions",
        maxTokens=100,
    )

    with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
        result = await plugin.sampling_callback(MagicMock(), params)

    assert isinstance(result, types.ErrorData)
    assert result.message == "Sampling denied: no consent callback configured."
    assert "denied because no sampling consent callback was configured" in caplog.text


async def test_mcp_sampling_auto_approve_logs_warning(caplog):
    plugin = MCPSsePlugin(
        name="TestMCPPlugin",
        url="http://localhost:8080/sse",
        sampling_auto_approve=True,
    )
    params = types.CreateMessageRequestParams(
        messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hello"))],
        systemPrompt="server instructions",
        maxTokens=100,
    )

    with caplog.at_level(logging.WARNING, logger="semantic_kernel.connectors.mcp"):
        result = await plugin.sampling_callback(MagicMock(), params)

    # No kernel configured, so the request is approved but then fails for lack of a chat service.
    assert isinstance(result, types.ErrorData)
    assert "auto-approved because sampling_auto_approve is enabled" in caplog.text


async def test_mcp_tool_and_prompt_names_do_not_shadow_plugin_attributes():
    kernel = MagicMock()
    plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse", kernel=kernel)
    session = AsyncMock(spec=ClientSession)
    session.list_tools.return_value = ListToolsResult(
        tools=[
            Tool(name="kernel", description="reserved", inputSchema={}),
            Tool(name="safe_tool", description="safe", inputSchema={}),
        ]
    )
    session.list_prompts.return_value = types.ListPromptsResult(
        prompts=[
            types.Prompt(name="session", description="reserved", arguments=[]),
            types.Prompt(name="safe_prompt", description="safe", arguments=[]),
        ]
    )
    plugin.session = session

    await plugin.load_tools()

    assert plugin.kernel is kernel
    assert hasattr(plugin, "safe_tool")

    await plugin.load_prompts()

    assert plugin.session is session
    assert hasattr(plugin, "safe_prompt")


async def test_mcp_tool_and_prompt_names_can_reload_existing_mcp_functions():
    plugin = MCPSsePlugin(name="TestMCPPlugin", url="http://localhost:8080/sse")
    session = AsyncMock(spec=ClientSession)
    session.list_tools.side_effect = [
        ListToolsResult(tools=[Tool(name="safe_tool", description="first tool", inputSchema={})]),
        ListToolsResult(tools=[Tool(name="safe_tool", description="second tool", inputSchema={})]),
    ]
    session.list_prompts.side_effect = [
        types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="first prompt", arguments=[])]),
        types.ListPromptsResult(prompts=[types.Prompt(name="safe_prompt", description="second prompt", arguments=[])]),
    ]
    plugin.session = session

    await plugin.load_tools()
    first_tool = plugin.safe_tool
    await plugin.load_tools()

    assert plugin.safe_tool is not first_tool
    assert plugin.safe_tool.__kernel_function_description__ == "second tool"

    await plugin.load_prompts()
    first_prompt = plugin.safe_prompt
    await plugin.load_prompts()

    assert plugin.safe_prompt is not first_prompt
    assert plugin.safe_prompt.__kernel_function_description__ == "second prompt"


async def test_mcp_plugin_failed_get_session():
    with (
        patch("semantic_kernel.connectors.mcp.stdio_client") as mock_stdio_client,
    ):
        mock_read = MagicMock()
        mock_write = MagicMock()

        mock_generator = MagicMock()
        # Make the mock_stdio_client return an AsyncMock for the context manager
        mock_generator.__aenter__.side_effect = Exception("Connection failed")
        mock_generator.__aexit__.return_value = (mock_read, mock_write)

        # Make the mock_stdio_client return an AsyncMock for the context manager
        mock_stdio_client.return_value = mock_generator

        with pytest.raises(KernelPluginInvalidConfigurationError):
            async with MCPStdioPlugin(
                name="test",
                command="echo",
                args=["Hello"],
            ):
                pass


@patch("semantic_kernel.connectors.mcp.stdio_client")
@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_with_kwargs_stdio(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
    mock_read = MagicMock()
    mock_write = MagicMock()

    mock_generator = MagicMock()
    # Make the mock_stdio_client return an AsyncMock for the context manager
    mock_generator.__aenter__.return_value = (mock_read, mock_write)
    mock_generator.__aexit__.return_value = (mock_read, mock_write)

    # Make the mock_stdio_client return an AsyncMock for the context manager
    mock_client.return_value = mock_generator
    mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
    async with MCPStdioPlugin(
        name="TestMCPPlugin",
        description="Test MCP Plugin",
        command="uv",
        args=["--directory", "path", "run", "file.py"],
    ) as plugin:
        mock_client.assert_called_once_with(
            server=StdioServerParameters(command="uv", args=["--directory", "path", "run", "file.py"])
        )
        loaded_plugin = kernel.add_plugin(plugin)
        assert loaded_plugin is not None
        assert loaded_plugin.name == "TestMCPPlugin"
        assert loaded_plugin.description == "Test MCP Plugin"
        assert loaded_plugin.functions.get("func1") is not None
        assert loaded_plugin.functions["func1"].parameters[0].name == "name"
        assert loaded_plugin.functions["func1"].parameters[0].is_required
        assert loaded_plugin.functions.get("func2") is not None
        assert len(loaded_plugin.functions["func2"].parameters) == 0


@patch("semantic_kernel.connectors.mcp.websocket_client")
@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_with_kwargs_websocket(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
    mock_read = MagicMock()
    mock_write = MagicMock()

    mock_generator = MagicMock()
    # Make the mock_stdio_client return an AsyncMock for the context manager
    mock_generator.__aenter__.return_value = (mock_read, mock_write)
    mock_generator.__aexit__.return_value = (mock_read, mock_write)

    # Make the mock_stdio_client return an AsyncMock for the context manager
    mock_client.return_value = mock_generator
    mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
    async with MCPWebsocketPlugin(
        name="TestMCPPlugin",
        description="Test MCP Plugin",
        url="http://localhost:8080/websocket",
    ) as plugin:
        mock_client.assert_called_once_with(url="http://localhost:8080/websocket")
        loaded_plugin = kernel.add_plugin(plugin)
        assert loaded_plugin is not None
        assert loaded_plugin.name == "TestMCPPlugin"
        assert loaded_plugin.description == "Test MCP Plugin"
        assert loaded_plugin.functions.get("func1") is not None
        assert loaded_plugin.functions["func1"].parameters[0].name == "name"
        assert loaded_plugin.functions["func1"].parameters[0].is_required
        assert loaded_plugin.functions.get("func2") is not None
        assert len(loaded_plugin.functions["func2"].parameters) == 0


@patch("semantic_kernel.connectors.mcp.sse_client")
@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_with_kwargs_sse(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
    mock_read = MagicMock()
    mock_write = MagicMock()

    mock_generator = MagicMock()
    # Make the mock_stdio_client return an AsyncMock for the context manager
    mock_generator.__aenter__.return_value = (mock_read, mock_write)
    mock_generator.__aexit__.return_value = (mock_read, mock_write)

    # Make the mock_stdio_client return an AsyncMock for the context manager
    mock_client.return_value = mock_generator
    mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
    async with MCPSsePlugin(
        name="TestMCPPlugin",
        description="Test MCP Plugin",
        url="http://localhost:8080/sse",
    ) as plugin:
        mock_client.assert_called_once_with(url="http://localhost:8080/sse")
        loaded_plugin = kernel.add_plugin(plugin)
        assert loaded_plugin is not None
        assert loaded_plugin.name == "TestMCPPlugin"
        assert loaded_plugin.description == "Test MCP Plugin"
        assert loaded_plugin.functions.get("func1") is not None
        assert loaded_plugin.functions["func1"].parameters[0].name == "name"
        assert loaded_plugin.functions["func1"].parameters[0].is_required
        assert loaded_plugin.functions.get("func2") is not None
        assert len(loaded_plugin.functions["func2"].parameters) == 0


@patch("semantic_kernel.connectors.mcp.streamablehttp_client")
@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_with_kwargs_streamablehttp(mock_session, mock_client, list_tool_calls, kernel: "Kernel"):
    mock_read = MagicMock()
    mock_write = MagicMock()
    mock_callback = MagicMock()

    mock_generator = MagicMock()
    # Make the mock_streamablehttp_client return an AsyncMock for the context manager
    mock_generator.__aenter__.return_value = (mock_read, mock_write, mock_callback)
    mock_generator.__aexit__.return_value = (mock_read, mock_write, mock_callback)

    # Make the mock_streamablehttp_client return an AsyncMock for the context manager
    mock_client.return_value = mock_generator
    mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls
    async with MCPStreamableHttpPlugin(
        name="TestMCPPlugin",
        description="Test MCP Plugin",
        url="http://localhost:8080/mcp",
    ) as plugin:
        mock_client.assert_called_once_with(url="http://localhost:8080/mcp")
        loaded_plugin = kernel.add_plugin(plugin)
        assert loaded_plugin is not None
        assert loaded_plugin.name == "TestMCPPlugin"
        assert loaded_plugin.description == "Test MCP Plugin"
        assert loaded_plugin.functions.get("func1") is not None
        assert loaded_plugin.functions["func1"].parameters[0].name == "name"
        assert loaded_plugin.functions["func1"].parameters[0].is_required
        assert loaded_plugin.functions.get("func2") is not None
        assert len(loaded_plugin.functions["func2"].parameters) == 0


async def test_kernel_as_mcp_server(kernel: "Kernel", decorated_native_function, custom_plugin_class):
    kernel.add_plugin(custom_plugin_class, "test")
    kernel.add_functions("test", [decorated_native_function])
    server = kernel.as_mcp_server()
    assert server is not None
    assert types.PingRequest in server.request_handlers
    assert types.ListToolsRequest in server.request_handlers
    assert types.CallToolRequest in server.request_handlers
    assert server.name == "Semantic Kernel MCP Server"


@patch("semantic_kernel.connectors.mcp.sse_client")
@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_mcp_tool_name_normalization(mock_session, mock_client, list_tool_calls_with_slash, kernel: "Kernel"):
    """Test that MCP tool names with illegal characters are normalized."""
    mock_read = MagicMock()
    mock_write = MagicMock()
    mock_generator = MagicMock()
    mock_generator.__aenter__.return_value = (mock_read, mock_write)
    mock_generator.__aexit__.return_value = (mock_read, mock_write)
    mock_client.return_value = mock_generator
    mock_session.return_value.__aenter__.return_value.list_tools.return_value = list_tool_calls_with_slash

    async with MCPSsePlugin(
        name="TestMCPPlugin",
        description="Test MCP Plugin",
        url="http://localhost:8080/sse",
    ) as plugin:
        loaded_plugin = kernel.add_plugin(plugin)
        # The normalized names:
        assert "nasa-get-astronomy-picture" in loaded_plugin.functions
        assert "weird-name-with-spaces" in loaded_plugin.functions
        # They should not exist with their original (invalid) names:
        assert "nasa/get-astronomy-picture" not in loaded_plugin.functions
        assert "weird\\name with spaces" not in loaded_plugin.functions

        normalized_names = list(loaded_plugin.functions.keys())
        for name in normalized_names:
            assert re.match(r"^[A-Za-z0-9_.-]+$", name)


@patch("semantic_kernel.connectors.mcp.ClientSession")
async def test_mcp_normalization_function(mock_session, list_tool_calls_with_slash):
    """Unit test for the normalize_mcp_name function (should exist in codebase)."""
    from semantic_kernel.connectors.mcp import _normalize_mcp_name

    assert _normalize_mcp_name("nasa/get-astronomy-picture") == "nasa-get-astronomy-picture"
    assert _normalize_mcp_name("weird\\name with spaces") == "weird-name-with-spaces"
    assert _normalize_mcp_name("simple_name") == "simple_name"
    assert _normalize_mcp_name("Name-With.Dots_And-Hyphens") == "Name-With.Dots_And-Hyphens"


async def test_excluded_function_cannot_be_called(kernel: "Kernel"):
    """Test that excluded functions are rejected at call time, not just hidden from listing."""
    from semantic_kernel.connectors.mcp import create_mcp_server_from_kernel
    from semantic_kernel.functions.kernel_function_decorator import kernel_function

    side_effect_called = False

    @kernel_function(name="public_echo")
    def public_echo(message: str) -> str:
        return f"echo: {message}"

    @kernel_function(name="secret_admin")
    def secret_admin(target: str) -> str:
        nonlocal side_effect_called
        side_effect_called = True
        return f"privileged action on {target}"

    kernel.add_function(plugin_name="tools", function=public_echo)
    kernel.add_function(plugin_name="tools", function=secret_admin)

    server = create_mcp_server_from_kernel(kernel, excluded_functions=["secret_admin"])

    # Verify the server was created with handlers
    assert types.ListToolsRequest in server.request_handlers
    assert types.CallToolRequest in server.request_handlers

    # Mock _get_cached_tool_definition to bypass SDK request context requirements
    # (normally set by a real MCP session transport)
    async def _fake_get_cached_tool_definition(tool_name):
        return None

    server._get_cached_tool_definition = _fake_get_cached_tool_definition

    # Build a proper CallToolRequest as the MCP SDK would send
    call_tool_request = types.CallToolRequest(
        method="tools/call",
        params=types.CallToolRequestParams(name="secret_admin", arguments={}),
    )

    # The internal handler wraps our _call_tool; invoke via the registered handler
    handler = server.request_handlers[types.CallToolRequest]
    result = await handler(call_tool_request)

    # The call must fail (isError=True) with the correct error message
    assert result.root.isError is True, "Calling an excluded function should return an error"
    assert any("Unknown tool" in c.text for c in result.root.content if hasattr(c, "text")), (
        f"Expected 'Unknown tool' error, got: {result.root.content}"
    )
    assert not side_effect_called, "Excluded function's side effect should not have fired"
