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

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

from __future__ import annotations

import json

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

from genro_asgi.applications.mcp_application import McpApplication


# ---------------------------------------------------------------------------
# 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


# ---------------------------------------------------------------------------
# Helpers: build fake ASGI triples
# ---------------------------------------------------------------------------


def _make_scope(auth_tags: list | None = None) -> dict:
    scope: dict = {"type": "http", "method": "POST", "path": "/"}
    if auth_tags is not None:
        scope["auth"] = {"tags": auth_tags}
    return scope


def _make_receive(body: bytes):
    async def receive():
        return {"body": body, "more_body": False}
    return receive


class _Sender:
    """Collects ASGI send messages."""

    def __init__(self) -> None:
        self.messages: list[dict] = []

    async def __call__(self, message: dict) -> None:
        self.messages.append(message)

    @property
    def status(self) -> int:
        return self.messages[0]["status"]

    @property
    def body(self) -> bytes:
        return self.messages[1]["body"]

    @property
    def json(self) -> dict:
        return json.loads(self.body)


async def _call(app: McpApplication, body: dict, auth_tags: list | None = None) -> _Sender:
    scope = _make_scope(auth_tags)
    receive = _make_receive(json.dumps(body).encode())
    sender = _Sender()
    await app(scope, receive, sender)
    return sender


# ---------------------------------------------------------------------------
# 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"

    @pytest.mark.anyio
    async def test_initialize_response(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
        assert sender.status == 200
        result = sender.json["result"]
        assert result["protocolVersion"] == "2024-11-05"
        assert "tools" in result["capabilities"]
        assert result["serverInfo"]["name"] == "genro-mcp"

    @pytest.mark.anyio
    async def test_initialize_preserves_id(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "id": "req-42", "method": "initialize", "params": {}})
        assert sender.json["id"] == "req-42"


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


class TestNotifications:
    @pytest.mark.anyio
    async def test_notification_returns_202(self, mcp_app: McpApplication) -> None:
        # No "id" field → notification
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
        assert sender.status == 202
        assert sender.body == b""

    @pytest.mark.anyio
    async def test_notification_no_elaboration(self, mcp_app: McpApplication) -> None:
        # Even unknown methods without id return 202
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "method": "unknown/method"})
        assert sender.status == 202


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


class TestToolsList:
    @pytest.mark.anyio
    async def test_tools_list_returns_tools(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        assert sender.status == 200
        tools = sender.json["result"]["tools"]
        assert isinstance(tools, list)
        assert len(tools) >= 1
        names = [t["name"] for t in tools]
        assert "greet" in names

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

    @pytest.mark.anyio
    async def test_tools_list_no_router(self) -> None:
        app = McpApplication()
        # No router set → empty tools list
        sender = await _call(app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        assert sender.json["result"]["tools"] == []


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


class TestToolsCall:
    @pytest.mark.anyio
    async def test_call_greet_default(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {
            "jsonrpc": "2.0", "id": 1, "method": "tools/call",
            "params": {"name": "greet", "arguments": {}},
        })
        assert sender.status == 200
        content = sender.json["result"]["content"]
        assert content[0]["type"] == "text"
        assert "Hello" in content[0]["text"]

    @pytest.mark.anyio
    async def test_call_greet_with_name(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {
            "jsonrpc": "2.0", "id": 2, "method": "tools/call",
            "params": {"name": "greet", "arguments": {"name": "Alice"}},
        })
        content = sender.json["result"]["content"][0]["text"]
        assert "Alice" in content

    @pytest.mark.anyio
    async def test_call_unknown_tool(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {
            "jsonrpc": "2.0", "id": 3, "method": "tools/call",
            "params": {"name": "nonexistent", "arguments": {}},
        })
        assert "error" in sender.json
        assert sender.json["error"]["code"] == -32601  # method not found


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


class TestErrors:
    @pytest.mark.anyio
    async def test_invalid_json(self, mcp_app: McpApplication) -> None:
        scope = _make_scope()
        receive = _make_receive(b"not-valid-json{{{")
        sender = _Sender()
        await mcp_app(scope, receive, sender)
        assert sender.json["error"]["code"] == -32700  # parse error

    @pytest.mark.anyio
    async def test_method_not_found(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "id": 9, "method": "unknown/method", "params": {}})
        assert "error" in sender.json
        assert sender.json["error"]["code"] == -32601

    @pytest.mark.anyio
    async def test_non_http_scope_ignored(self, mcp_app: McpApplication) -> None:
        scope = {"type": "websocket"}
        sender = _Sender()
        await mcp_app(scope, _make_receive(b""), sender)
        # No response sent for non-http scope
        assert sender.messages == []

    @pytest.mark.anyio
    async def test_error_preserves_id(self, mcp_app: McpApplication) -> None:
        sender = await _call(mcp_app, {"jsonrpc": "2.0", "id": "abc", "method": "bad/method", "params": {}})
        assert sender.json["id"] == "abc"
        assert "error" in sender.json


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


class TestAuth:
    @pytest.mark.anyio
    async def test_auth_tags_from_scope(self, mcp_app: McpApplication) -> None:
        # With auth tags set on scope, tools/list should work normally
        sender = await _call(
            mcp_app,
            {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
            auth_tags=["read", "write"],
        )
        assert sender.status == 200

    @pytest.mark.anyio
    async def test_auth_tags_as_comma_string(self, mcp_app: McpApplication) -> None:
        # Auth tags can come as comma-separated string too
        scope = _make_scope()
        scope["auth"] = {"tags": "read,write"}
        receive = _make_receive(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).encode())
        sender = _Sender()
        await mcp_app(scope, receive, sender)
        assert sender.status == 200


# ---------------------------------------------------------------------------
# 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:
    @pytest.mark.anyio
    async def test_int_param_mapped_to_integer(self, numeric_mcp_app: McpApplication) -> None:
        sender = await _call(numeric_mcp_app, {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}})
        tools = {t["name"]: t for t in sender.json["result"]["tools"]}
        schema = tools["add"]["inputSchema"]
        assert schema["properties"]["a"]["type"] == "integer"
        assert schema["properties"]["b"]["type"] == "integer"

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

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


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


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

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

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

    @pytest.mark.anyio
    async def test_call_toggle_with_bool(self, numeric_mcp_app: McpApplication) -> None:
        sender = await _call(numeric_mcp_app, {
            "jsonrpc": "2.0", "id": 4, "method": "tools/call",
            "params": {"name": "toggle", "arguments": {"flag": True}},
        })
        content = sender.json["result"]["content"][0]["text"]
        assert content == "True"
