"""Tests for the rekko-mcp proxy server.

Verifies tool registration and that every tool calls the correct API route.
No real HTTP calls — _request is mocked to capture method + path.
"""
from __future__ import annotations

import json
import re
from unittest.mock import AsyncMock, patch

import httpx
import pytest

from rekko_mcp.server import mcp


# ---------------------------------------------------------------------------
# Tool registration
# ---------------------------------------------------------------------------

EXPECTED_TOOLS = sorted([
    "market.data.list",
    "market.data.get",
    "market.data.search",
    "market.data.history",
    "market.data.resolution",
    "market.data.execution",
    "market.data.screen",
    "research.pipe.start",
    "research.pipe.status",
    "research.pipe.get",
    "research.pipe.list",
    "research.signal.signal",
    "research.signal.portfolio",
    "research.signal.calibration",
    "research.signal.consensus",
    "market.arb.get",
    "market.arb.live",
    "market.arb.correlation",
    "analytics.performance",
    "analytics.report",
    "trade.hooks.create",
    "trade.hooks.list",
    "trade.hooks.delete",
    "help.quickstart",
])


@pytest.mark.asyncio
async def test_all_tools_registered():
    """All expected tools are registered on the MCP server."""
    tools = await mcp.list_tools()
    tool_names = sorted(t.name for t in tools)
    assert tool_names == EXPECTED_TOOLS, f"Tool mismatch.\nExpected: {EXPECTED_TOOLS}\nGot: {tool_names}"


@pytest.mark.asyncio
async def test_tool_count():
    """Exact tool count matches expected."""
    tools = await mcp.list_tools()
    assert len(tools) == 24, f"Expected 24 tools, got {len(tools)}"


@pytest.mark.asyncio
async def test_no_extra_tools():
    """No unexpected tools are registered."""
    tools = await mcp.list_tools()
    tool_names = {t.name for t in tools}
    expected = set(EXPECTED_TOOLS)
    extra = tool_names - expected
    assert not extra, f"Unexpected tools: {extra}"


@pytest.mark.asyncio
async def test_every_tool_has_description():
    """Every registered tool has a non-empty description."""
    tools = await mcp.list_tools()
    for tool in tools:
        assert tool.description, f"Tool {tool.name!r} has no description"
        assert len(tool.description) > 10, f"Tool {tool.name!r} description too short"


# ---------------------------------------------------------------------------
# Route correctness — verify every tool calls the right API path
# ---------------------------------------------------------------------------

# Map of: tool_name -> (expected_method, expected_path_pattern)
# Path patterns use {param} for variable segments.
EXPECTED_ROUTES = {
    "market.data.list": ("GET", "/v1/markets"),
    "market.data.get": ("GET", "/v1/markets/{platform}/{market_id}"),
    "market.data.search": ("GET", "/v1/markets"),
    "market.data.history": ("GET", "/v1/markets/{platform}/{market_id}/history"),
    "market.data.resolution": ("GET", "/v1/markets/{platform}/{market_id}/resolution"),
    "market.data.execution": ("GET", "/v1/markets/{platform}/{market_id}/execution"),
    "market.data.screen": ("POST", "/v1/screen"),
    "research.pipe.start": ("POST", "/v1/markets/{platform}/{market_id}/analyze"),
    "research.pipe.status": ("GET", "/v1/markets/{platform}/{market_id}/analyze/{analysis_id}/status"),
    "research.pipe.get": ("GET", "/v1/markets/{platform}/{market_id}/analysis"),
    "research.pipe.list": ("GET", "/v1/analyses"),
    "research.signal.signal": ("POST", "/v1/signals"),
    "research.signal.portfolio": ("POST", "/v1/signals/portfolio"),
    "research.signal.calibration": ("GET", "/v1/calibration"),
    "research.signal.consensus": ("GET", "/v1/markets/{platform}/{market_id}/consensus"),
    "market.arb.get": ("GET", "/v1/arbitrage"),
    "market.arb.live": ("GET", "/v1/arbitrage/live"),
    "market.arb.correlation": ("POST", "/v1/correlation"),
    "analytics.performance": ("GET", "/v1/performance"),
    "analytics.report": ("POST", "/v1/trades/report"),
    "trade.hooks.create": ("POST", "/v1/webhooks"),
    "trade.hooks.list": ("GET", "/v1/webhooks"),
    "trade.hooks.delete": ("DELETE", "/v1/webhooks/{webhook_id}"),
}

# Live API routes from OpenAPI spec (source of truth)
LIVE_API_ROUTES = {
    ("GET", "/v1/analyses"),
    ("GET", "/v1/arbitrage"),
    ("GET", "/v1/arbitrage/live"),
    ("GET", "/v1/calibration"),
    ("POST", "/v1/correlation"),
    ("GET", "/v1/markets"),
    ("GET", "/v1/markets/{platform}/{market_id}"),
    ("GET", "/v1/markets/{platform}/{market_id}/analysis"),
    ("POST", "/v1/markets/{platform}/{market_id}/analyze"),
    ("GET", "/v1/markets/{platform}/{market_id}/analyze/{analysis_id}/status"),
    ("GET", "/v1/markets/{platform}/{market_id}/consensus"),
    ("GET", "/v1/markets/{platform}/{market_id}/execution"),
    ("GET", "/v1/markets/{platform}/{market_id}/history"),
    ("GET", "/v1/markets/{platform}/{market_id}/resolution"),
    ("GET", "/v1/performance"),
    ("POST", "/v1/screen"),
    ("POST", "/v1/signals"),
    ("POST", "/v1/signals/portfolio"),
    ("POST", "/v1/trades/report"),
    ("GET", "/v1/webhooks"),
    ("POST", "/v1/webhooks"),
    ("DELETE", "/v1/webhooks/{webhook_id}"),
}


def _normalize_path(path: str) -> str:
    """Replace concrete path segments with {param} placeholders for comparison."""
    # Known test values used in the tool call kwargs below
    replacements = [
        ("kalshi", "{platform}"),
        ("KXTEST-123", "{market_id}"),
        ("rk-abc123", "{analysis_id}"),
        ("wh-test-id", "{webhook_id}"),
    ]
    for val, placeholder in replacements:
        path = path.replace(val, placeholder)
    return path


# Test kwargs for each tool to trigger a call with valid params
TOOL_CALL_KWARGS = {
    "market.data.list": {},
    "market.data.get": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "market.data.search": {"query": "test"},
    "market.data.history": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "market.data.resolution": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "market.data.execution": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "market.data.screen": {},
    "research.pipe.start": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "research.pipe.status": {"platform": "kalshi", "market_id": "KXTEST-123", "analysis_id": "rk-abc123"},
    "research.pipe.get": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "research.pipe.list": {},
    "research.signal.signal": {"market_query": "test market"},
    "research.signal.portfolio": {"market_query": "test market"},
    "research.signal.calibration": {},
    "research.signal.consensus": {"platform": "kalshi", "market_id": "KXTEST-123"},
    "market.arb.get": {},
    "market.arb.live": {},
    "market.arb.correlation": {"market_ids": ["A", "B"]},
    "analytics.performance": {},
    "analytics.report": {"market_id": "KXTEST-123", "platform": "kalshi", "side": "yes", "size_usd": 10.0, "price": 0.65},
    "trade.hooks.create": {"url": "https://example.com/hook", "events": ["whale_alert"]},
    "trade.hooks.list": {},
    "trade.hooks.delete": {"webhook_id": "wh-test-id"},
}


@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", [t for t in EXPECTED_ROUTES])
async def test_tool_calls_correct_route(tool_name: str):
    """Each proxy tool calls the correct HTTP method and API path."""
    captured = {}

    async def mock_request(method: str, path: str, **kwargs):
        captured["method"] = method
        captured["path"] = path
        return '{"ok": true}'

    with patch("rekko_mcp.server._request", side_effect=mock_request):
        # Find the tool function by name
        tools = await mcp.list_tools()
        tool = next((t for t in tools if t.name == tool_name), None)
        assert tool is not None, f"Tool {tool_name} not found"

        # Call the underlying Python function directly
        import rekko_mcp.server as mod
        # Map tool names to Python function names
        func_map = {
            "market.data.list": mod.list_markets,
            "market.data.get": mod.get_market,
            "market.data.search": mod.search_markets,
            "market.data.history": mod.get_market_history,
            "market.data.resolution": mod.get_resolution,
            "market.data.execution": mod.get_execution_guidance,
            "market.data.screen": mod.screen_markets,
            "research.pipe.start": mod.analyze_market,
            "research.pipe.status": mod.check_analysis_status,
            "research.pipe.get": mod.get_analysis,
            "research.pipe.list": mod.list_analyses,
            "research.signal.signal": mod.get_strategy,
            "research.signal.portfolio": mod.get_portfolio_strategy,
            "research.signal.calibration": mod.get_calibration,
            "research.signal.consensus": mod.get_consensus,
            "market.arb.get": mod.get_arbitrage,
            "market.arb.live": mod.get_arbitrage_live,
            "market.arb.correlation": mod.get_correlation,
            "analytics.performance": mod.get_performance,
            "analytics.report": mod.report_trade,
            "trade.hooks.create": mod.create_webhook,
            "trade.hooks.list": mod.list_webhooks,
            "trade.hooks.delete": mod.delete_webhook,
        }

        fn = func_map[tool_name]
        kwargs = TOOL_CALL_KWARGS[tool_name]
        await fn(**kwargs)

    expected_method, expected_path = EXPECTED_ROUTES[tool_name]
    actual_path = _normalize_path(captured["path"])

    assert captured["method"] == expected_method, (
        f"{tool_name}: expected {expected_method}, got {captured['method']}"
    )
    assert actual_path == expected_path, (
        f"{tool_name}: expected {expected_path}, got {actual_path} (raw: {captured['path']})"
    )


def test_all_proxy_routes_exist_in_live_api():
    """Every route the proxy calls must exist in the live API OpenAPI spec."""
    for tool_name, (method, path) in EXPECTED_ROUTES.items():
        assert (method, path) in LIVE_API_ROUTES, (
            f"Tool {tool_name} calls {method} {path} which is NOT in the live API"
        )


def test_no_internal_tool_names():
    """No tool names reference internal concepts."""
    forbidden = {"shadow", "scrape", "scraper", "run", "runs"}
    for name in EXPECTED_TOOLS:
        parts = set(name.lower().replace(".", " ").replace("_", " ").split())
        overlap = parts & forbidden
        assert not overlap, f"Tool {name!r} contains forbidden term: {overlap}"


# ---------------------------------------------------------------------------
# Error handling — _request returns clean JSON for all error codes
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.parametrize("status_code,expected_error", [
    (401, "auth_required"),
    (403, "auth_required"),
    (402, "payment_required"),
    (404, "not_found"),
    (422, "validation_error"),
    (429, "rate_limited"),
    (500, "server_error"),
    (502, "server_error"),
    (503, "server_error"),
])
async def test_request_returns_json_on_http_errors(status_code: int, expected_error: str):
    """HTTP errors return structured JSON, not raw exceptions."""
    import httpx

    mock_response = httpx.Response(
        status_code=status_code,
        text='{"detail": "test error"}',
        request=httpx.Request("GET", "https://api.rekko.ai/v1/markets"),
    )

    with patch("rekko_mcp.server._get_client") as mock_client_fn:
        mock_client = AsyncMock()
        mock_client.request = AsyncMock(return_value=mock_response)
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client_fn.return_value = mock_client

        from rekko_mcp.server import _request
        result = await _request("GET", "/v1/markets")

    parsed = json.loads(result)
    assert "error" in parsed, f"Expected 'error' key in response for {status_code}"
    assert parsed["error"] == expected_error, (
        f"Expected error={expected_error!r} for {status_code}, got {parsed['error']!r}"
    )


@pytest.mark.asyncio
async def test_request_handles_connection_error():
    """Connection failures return clean JSON."""
    with patch("rekko_mcp.server._get_client") as mock_client_fn:
        mock_client = AsyncMock()
        mock_client.request = AsyncMock(side_effect=httpx.ConnectError("refused"))
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client_fn.return_value = mock_client

        from rekko_mcp.server import _request
        result = await _request("GET", "/v1/markets")

    parsed = json.loads(result)
    assert parsed["error"] == "connection_error"


@pytest.mark.asyncio
async def test_request_handles_timeout():
    """Timeouts return clean JSON."""
    with patch("rekko_mcp.server._get_client") as mock_client_fn:
        mock_client = AsyncMock()
        mock_client.request = AsyncMock(side_effect=httpx.ReadTimeout("timed out"))
        mock_client.__aenter__ = AsyncMock(return_value=mock_client)
        mock_client.__aexit__ = AsyncMock(return_value=False)
        mock_client_fn.return_value = mock_client

        from rekko_mcp.server import _request
        result = await _request("GET", "/v1/markets")

    parsed = json.loads(result)
    assert parsed["error"] == "timeout"


# ---------------------------------------------------------------------------
# Prompt validation — all tool references in prompts point to real tools
# ---------------------------------------------------------------------------

TOOL_REF_PATTERN = re.compile(r"Use ([a-z]+\.[a-z]+\.[a-z]+)")


@pytest.mark.asyncio
async def test_prompt_tool_references_are_valid():
    """Every tool name referenced in prompts exists as a registered tool."""
    tools = await mcp.list_tools()
    tool_names = {t.name for t in tools}

    prompts = await mcp.list_prompts()
    for prompt in prompts:
        # Get the prompt content by calling it with dummy args
        try:
            msg = await mcp.get_prompt(prompt.name, arguments={"market_question": "test", "min_spread": "3", "category": ""})
        except Exception:
            continue

        # Extract text content from prompt messages
        for m in msg.messages:
            text = m.content.text if hasattr(m.content, "text") else str(m.content)
            refs = TOOL_REF_PATTERN.findall(text)
            for ref in refs:
                assert ref in tool_names, (
                    f"Prompt {prompt.name!r} references tool {ref!r} which doesn't exist. "
                    f"Available: {sorted(tool_names)}"
                )
