# Copyright 2025 Softwell S.r.l.
# Licensed under the Apache License, Version 2.0

"""Tests for McpApplication - MCP Streamable HTTP transport.

Tests the index handler by setting a mock request in the ContextVar,
simulating what the Dispatcher does at runtime.
"""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest
from genro_routes import Router, RoutingClass, route  # type: ignore[import-untyped]

from genro_asgi.applications.mcp_application import McpApplication
from genro_asgi.request import set_current_request
from genro_asgi.response import Response


# ---------------------------------------------------------------------------
# Fixtures: minimal router for tests
# ---------------------------------------------------------------------------


class _SampleApi(RoutingClass):
    """Minimal API for testing McpApplication."""

    def __init__(self) -> None:
        self.main = Router(self, name="main")

    @route()
    def greet(self, name: str = "world") -> str:
        """Return a greeting."""
        return f"Hello, {name}!"

    @route()
    def echo(self, message: str) -> str:
        """Echo a message."""
        return message


class _NumericApi(RoutingClass):
    """API with numeric parameters for type mapping tests."""

    def __init__(self) -> None:
        self.main = Router(self, name="main")

    @route()
    def add(self, a: int = 0, b: int = 0) -> str:
        """Add two integers."""
        return str(a + b)

    @route()
    def scale(self, value: float = 1.0, factor: float = 2.0) -> str:
        """Scale a float value."""
        return str(value * factor)

    @route()
    def toggle(self, flag: bool = False) -> str:
        """Toggle a boolean."""
        return str(flag)


@pytest.fixture()
def sample_router() -> Router:
    api = _SampleApi()
    return api.main


@pytest.fixture()
def mcp_app(sample_router: Router) -> McpApplication:
    app = McpApplication()
    app.set_router(sample_router)
    return app


@pytest.fixture()
def numeric_router() -> Router:
    api = _NumericApi()
    return api.main


@pytest.fixture()
def numeric_mcp_app(numeric_router: Router) -> McpApplication:
    app = McpApplication()
    app.set_router(numeric_router)
    return app


# ---------------------------------------------------------------------------
# Helper: mock request and call index
# ---------------------------------------------------------------------------


def _mock_request(data: dict | str | None, auth_tags: list | None = None) -> MagicMock:
    """Build a mock request with data and response, set it in ContextVar."""
    request = MagicMock()
    request.data = data
    request.auth_tags = auth_tags or []
    request.response = Response(request=request)
    return request


def _call(
    app: McpApplication, body: dict, auth_tags: list | None = None,
) -> tuple[dict | None, MagicMock]:
    """Call the index handler with a mock request. Returns (result, request)."""
    request = _mock_request(body, auth_tags)
    set_current_request(request)
    try:
        result = app.index()
    finally:
        set_current_request(None)
    return result, request


# ---------------------------------------------------------------------------
# Tests: initialize
# ---------------------------------------------------------------------------


class TestInitialize:
    def test_default_name_version(self) -> None:
        app = McpApplication()
        assert app.mcp_name == "genro-mcp"
        assert app.mcp_version == "1.0.0"

    def test_custom_name_version_kwargs(self) -> None:
        app = McpApplication(mcp_name="my-server", mcp_version="2.0.0")
        assert app.mcp_name == "my-server"
        assert app.mcp_version == "2.0.0"

    def test_initialize_response(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
        assert result["jsonrpc"] == "2.0"
        assert result["id"] == 1
        r = result["result"]
        assert r["protocolVersion"] == "2024-11-05"
        assert "tools" in r["capabilities"]
        assert r["serverInfo"]["name"] == "genro-mcp"

    def test_initialize_preserves_id(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {"jsonrpc": "2.0", "id": "req-42", "method": "initialize", "params": {}})
        assert result["id"] == "req-42"


# ---------------------------------------------------------------------------
# Tests: notifications (no id → 202)
# ---------------------------------------------------------------------------


class TestNotifications:
    def test_notification_returns_202(self, mcp_app: McpApplication) -> None:
        result, request = _call(mcp_app, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
        assert result is None
        assert request.response.status_code == 202

    def test_notification_no_elaboration(self, mcp_app: McpApplication) -> None:
        # Even unknown methods without id return None with 202
        result, request = _call(mcp_app, {"jsonrpc": "2.0", "method": "unknown/method"})
        assert result is None
        assert request.response.status_code == 202


# ---------------------------------------------------------------------------
# Tests: tools/list
# ---------------------------------------------------------------------------


class TestToolsList:
    def test_tools_list_returns_tools(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        tools = result["result"]["tools"]
        assert isinstance(tools, list)
        assert len(tools) >= 1
        names = [t["name"] for t in tools]
        assert "greet" in names

    def test_tools_list_schema(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        tools = {t["name"]: t for t in result["result"]["tools"]}
        assert "greet" in tools
        schema = tools["greet"]["inputSchema"]
        assert schema["type"] == "object"
        assert "name" in schema["properties"]

    def test_tools_list_no_router(self) -> None:
        app = McpApplication()
        result, _ = _call(app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        assert result["result"]["tools"] == []


# ---------------------------------------------------------------------------
# Tests: tools/call
# ---------------------------------------------------------------------------


class TestToolsCall:
    def test_call_greet_default(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {
            "jsonrpc": "2.0", "id": 1, "method": "tools/call",
            "params": {"name": "greet", "arguments": {}},
        })
        content = result["result"]["content"]
        assert content[0]["type"] == "text"
        assert "Hello" in content[0]["text"]

    def test_call_greet_with_name(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {
            "jsonrpc": "2.0", "id": 2, "method": "tools/call",
            "params": {"name": "greet", "arguments": {"name": "Alice"}},
        })
        content = result["result"]["content"][0]["text"]
        assert "Alice" in content

    def test_call_unknown_tool(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {
            "jsonrpc": "2.0", "id": 3, "method": "tools/call",
            "params": {"name": "nonexistent", "arguments": {}},
        })
        assert "error" in result
        assert result["error"]["code"] == -32601


# ---------------------------------------------------------------------------
# Tests: error handling
# ---------------------------------------------------------------------------


class TestErrors:
    def test_invalid_data_not_dict(self, mcp_app: McpApplication) -> None:
        # request.data is a string (e.g., malformed JSON parsed as string)
        request = _mock_request("not-a-dict")
        set_current_request(request)
        try:
            result = mcp_app.index()
        finally:
            set_current_request(None)
        assert result["error"]["code"] == -32600  # invalid request

    def test_invalid_data_none(self, mcp_app: McpApplication) -> None:
        # request.data is None (empty body)
        request = _mock_request(None)
        set_current_request(request)
        try:
            result = mcp_app.index()
        finally:
            set_current_request(None)
        assert result["error"]["code"] == -32600

    def test_method_not_found(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {"jsonrpc": "2.0", "id": 9, "method": "unknown/method", "params": {}})
        assert "error" in result
        assert result["error"]["code"] == -32601

    def test_error_preserves_id(self, mcp_app: McpApplication) -> None:
        result, _ = _call(mcp_app, {"jsonrpc": "2.0", "id": "abc", "method": "bad/method", "params": {}})
        assert result["id"] == "abc"
        assert "error" in result


# ---------------------------------------------------------------------------
# Tests: auth
# ---------------------------------------------------------------------------


class TestAuth:
    def test_auth_tags_from_request(self, mcp_app: McpApplication) -> None:
        result, _ = _call(
            mcp_app,
            {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
            auth_tags=["read", "write"],
        )
        assert "result" in result


# ---------------------------------------------------------------------------
# Tests: annotation_to_json_type
# ---------------------------------------------------------------------------


class TestAnnotationToJsonType:
    def test_int(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type(int) == "integer"

    def test_float(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type(float) == "number"

    def test_bool(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type(bool) == "boolean"

    def test_str(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type(str) == "string"

    def test_unknown(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type(list) == "string"

    def test_optional_int(self) -> None:
        from typing import Optional
        app = McpApplication()
        assert app._annotation_to_json_type(Optional[int]) == "integer"

    def test_string_int(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type("int") == "integer"

    def test_string_float(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type("float") == "number"

    def test_string_bool(self) -> None:
        app = McpApplication()
        assert app._annotation_to_json_type("bool") == "boolean"


# ---------------------------------------------------------------------------
# Tests: numeric type mapping in tools/list (PEP 563 scenario)
# ---------------------------------------------------------------------------


class TestNumericTypeMapping:
    def test_int_param_mapped_to_integer(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        tools = {t["name"]: t for t in result["result"]["tools"]}
        schema = tools["add"]["inputSchema"]
        assert schema["properties"]["a"]["type"] == "integer"
        assert schema["properties"]["b"]["type"] == "integer"

    def test_float_param_mapped_to_number(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        tools = {t["name"]: t for t in result["result"]["tools"]}
        schema = tools["scale"]["inputSchema"]
        assert schema["properties"]["value"]["type"] == "number"
        assert schema["properties"]["factor"]["type"] == "number"

    def test_bool_param_mapped_to_boolean(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        tools = {t["name"]: t for t in result["result"]["tools"]}
        schema = tools["toggle"]["inputSchema"]
        assert schema["properties"]["flag"]["type"] == "boolean"


# ---------------------------------------------------------------------------
# Tests: tools/call with numeric arguments
# ---------------------------------------------------------------------------


class TestNumericToolsCall:
    def test_call_add_with_integers(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {
            "jsonrpc": "2.0", "id": 1, "method": "tools/call",
            "params": {"name": "add", "arguments": {"a": 3, "b": 4}},
        })
        content = result["result"]["content"][0]["text"]
        assert content == "7"

    def test_call_add_with_string_coercion(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {
            "jsonrpc": "2.0", "id": 2, "method": "tools/call",
            "params": {"name": "add", "arguments": {"a": "3", "b": "4"}},
        })
        content = result["result"]["content"][0]["text"]
        assert content == "7"

    def test_call_scale_with_floats(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {
            "jsonrpc": "2.0", "id": 3, "method": "tools/call",
            "params": {"name": "scale", "arguments": {"value": 2.5, "factor": 3.0}},
        })
        content = result["result"]["content"][0]["text"]
        assert content == "7.5"

    def test_call_toggle_with_bool(self, numeric_mcp_app: McpApplication) -> None:
        result, _ = _call(numeric_mcp_app, {
            "jsonrpc": "2.0", "id": 4, "method": "tools/call",
            "params": {"name": "toggle", "arguments": {"flag": True}},
        })
        content = result["result"]["content"][0]["text"]
        assert content == "True"
