"""Tool for discovering and executing MCP (Model Context Protocol) server tools."""

import logging
from typing import Any
from urllib.parse import urlparse

from backend.blocks.mcp.block import MCPToolBlock
from backend.blocks.mcp.client import MCPClient, MCPClientError
from backend.blocks.mcp.helpers import (
    auto_lookup_mcp_credential,
    normalize_mcp_url,
    parse_mcp_content,
    server_host,
)
from backend.copilot.model import ChatSession
from backend.copilot.tools.utils import build_missing_credentials_from_field_info
from backend.util.request import HTTPClientError, validate_url_host

from .base import BaseTool
from .models import (
    ErrorResponse,
    MCPToolInfo,
    MCPToolOutputResponse,
    MCPToolsDiscoveredResponse,
    SetupInfo,
    SetupRequirementsResponse,
    ToolResponseBase,
    UserReadiness,
)

logger = logging.getLogger(__name__)

# HTTP status codes that indicate authentication is required
_AUTH_STATUS_CODES = {401, 403}


def _service_name(host: str) -> str:
    """Strip the 'mcp.' prefix from an MCP hostname: 'mcp.sentry.dev' → 'sentry.dev'"""
    return host[4:] if host.startswith("mcp.") else host


class RunMCPToolTool(BaseTool):
    """
    Tool for discovering and executing tools on any MCP server.

    Stage 1 — discovery: call with just server_url to get available tools.
    Stage 2 — execution: call with server_url + tool_name + tool_arguments.
    If the server requires OAuth credentials that the user hasn't connected yet,
    a SetupRequirementsResponse is returned so the frontend can render the
    same OAuth login UI as the graph builder.
    """

    @property
    def name(self) -> str:
        return "run_mcp_tool"

    @property
    def description(self) -> str:
        return (
            "Discover and execute MCP server tools. "
            "Call with server_url only to list tools, then with tool_name + tool_arguments to execute. "
            "Call get_mcp_guide first for server URLs and auth."
        )

    @property
    def parameters(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "server_url": {
                    "type": "string",
                    "description": "MCP server URL (Streamable HTTP endpoint).",
                },
                "tool_name": {
                    "type": "string",
                    "description": "Tool to execute. Omit to discover available tools.",
                },
                "tool_arguments": {
                    "type": "object",
                    "description": "Arguments matching the tool's input schema.",
                },
            },
            "required": ["server_url"],
        }

    @property
    def requires_auth(self) -> bool:
        return True

    async def _execute(
        self,
        user_id: str | None,
        session: ChatSession,
        **kwargs,
    ) -> ToolResponseBase:
        server_url: str = (kwargs.get("server_url") or "").strip()
        tool_name: str = (kwargs.get("tool_name") or "").strip()
        raw_tool_arguments = kwargs.get("tool_arguments")
        tool_arguments: dict[str, Any] = (
            raw_tool_arguments if isinstance(raw_tool_arguments, dict) else {}
        )
        session_id = session.session_id

        if raw_tool_arguments is not None and not isinstance(raw_tool_arguments, dict):
            return ErrorResponse(
                message="tool_arguments must be a JSON object.",
                session_id=session_id,
            )

        if not server_url:
            return ErrorResponse(
                message="Please provide a server_url for the MCP server.",
                session_id=session_id,
            )

        _parsed = urlparse(server_url)
        if _parsed.username or _parsed.password:
            return ErrorResponse(
                message=(
                    "Do not include credentials in server_url. "
                    "Use the MCP credential setup flow instead."
                ),
                session_id=session_id,
            )
        if _parsed.query or _parsed.fragment:
            return ErrorResponse(
                message=(
                    "Do not include query parameters or fragments in server_url. "
                    "Use the MCP credential setup flow instead."
                ),
                session_id=session_id,
            )

        if not user_id:
            return ErrorResponse(
                message="Authentication required.",
                session_id=session_id,
            )

        # Validate URL to prevent SSRF — blocks loopback and private IP ranges
        try:
            await validate_url_host(server_url)
        except ValueError as e:
            msg = str(e)
            if "Unable to resolve" in msg or "No IP addresses" in msg:
                user_msg = (
                    f"Hostname not found: {server_host(server_url)}. "
                    "Please check the URL — the domain may not exist."
                )
            else:
                user_msg = f"Blocked server URL: {msg}"
            return ErrorResponse(message=user_msg, session_id=session_id)

        # Fast DB lookup — no network call.
        # Normalize for matching because stored credentials use normalized URLs.
        creds = await auto_lookup_mcp_credential(user_id, normalize_mcp_url(server_url))
        auth_token = creds.access_token.get_secret_value() if creds else None

        client = MCPClient(server_url, auth_token=auth_token)

        try:
            await client.initialize()

            if not tool_name:
                # Stage 1: Discover available tools
                return await self._discover_tools(client, server_url, session_id)
            else:
                # Stage 2: Execute the selected tool
                return await self._execute_tool(
                    client, server_url, tool_name, tool_arguments, session_id
                )

        except HTTPClientError as e:
            if e.status_code in _AUTH_STATUS_CODES and not creds:
                # Server requires auth and user has no stored credentials
                return self._build_setup_requirements(server_url, session_id)
            host = server_host(server_url)
            logger.warning("MCP HTTP error for %s: status=%s", host, e.status_code)
            return ErrorResponse(
                message=(f"MCP request to {host} failed with HTTP {e.status_code}."),
                session_id=session_id,
                error=f"HTTP {e.status_code}: {str(e)[:300]}",
            )

        except MCPClientError as e:
            logger.warning("MCP client error for %s: %s", server_host(server_url), e)
            return ErrorResponse(
                message=str(e),
                session_id=session_id,
            )

        except Exception:
            logger.error(
                "Unexpected error calling MCP server %s",
                server_host(server_url),
                exc_info=True,
            )
            return ErrorResponse(
                message="An unexpected error occurred connecting to the MCP server. Please try again.",
                session_id=session_id,
            )

    async def _discover_tools(
        self,
        client: MCPClient,
        server_url: str,
        session_id: str,
    ) -> MCPToolsDiscoveredResponse:
        """List available tools from an already-initialized MCPClient.

        Called when the agent invokes run_mcp_tool with only server_url (no
        tool_name). Returns MCPToolsDiscoveredResponse so the agent can
        inspect tool schemas and choose one to execute in a follow-up call.
        """
        tools = await client.list_tools()
        tool_infos = [
            MCPToolInfo(
                name=t.name,
                description=t.description,
                input_schema=t.input_schema,
            )
            for t in tools
        ]
        host = server_host(server_url)
        return MCPToolsDiscoveredResponse(
            message=(
                f"Discovered {len(tool_infos)} tool(s) on {host}. "
                "Call run_mcp_tool again with tool_name and tool_arguments to execute one."
            ),
            server_url=server_url,
            tools=tool_infos,
            session_id=session_id,
        )

    async def _execute_tool(
        self,
        client: MCPClient,
        server_url: str,
        tool_name: str,
        tool_arguments: dict[str, Any],
        session_id: str,
    ) -> MCPToolOutputResponse | ErrorResponse:
        """Execute a specific tool on an already-initialized MCPClient.

        Parses the MCP content response into a plain Python value:
        - text items: parsed as JSON when possible, kept as str otherwise
        - image items: kept as {type, data, mimeType} dict for frontend rendering
        - resource items: unwrapped to their resource payload dict
        Single-item responses are unwrapped from the list; multiple items are
        returned as a list; empty content returns None.
        """
        result = await client.call_tool(tool_name, tool_arguments)

        if result.is_error:
            error_text = " ".join(
                item.get("text", "")
                for item in result.content
                if item.get("type") == "text"
            )
            return ErrorResponse(
                message=f"MCP tool '{tool_name}' returned an error: {error_text or 'Unknown error'}",
                session_id=session_id,
            )

        result_value = parse_mcp_content(result.content)

        return MCPToolOutputResponse(
            message=f"MCP tool '{tool_name}' executed successfully.",
            server_url=server_url,
            tool_name=tool_name,
            result=result_value,
            success=True,
            session_id=session_id,
        )

    def _build_setup_requirements(
        self,
        server_url: str,
        session_id: str,
    ) -> SetupRequirementsResponse | ErrorResponse:
        """Build a SetupRequirementsResponse for a missing MCP server credential."""
        mcp_block = MCPToolBlock()
        credentials_fields_info = mcp_block.input_schema.get_credentials_fields_info()

        # Apply the server_url discriminator value so the frontend's CredentialsGroupedView
        # can match the credential to the correct OAuth provider/server.
        for field_info in credentials_fields_info.values():
            if field_info.discriminator == "server_url":
                field_info.discriminator_values.add(server_url)

        missing_creds_dict = build_missing_credentials_from_field_info(
            credentials_fields_info, matched_keys=set()
        )

        if not missing_creds_dict:
            logger.error(
                "No credential requirements found for MCP server %s — "
                "MCPToolBlock may not have credentials configured",
                server_host(server_url),
            )
            return ErrorResponse(
                message=(
                    f"Unable to connect to {_service_name(server_host(server_url))} "
                    "— no credentials configured."
                ),
                session_id=session_id,
            )

        missing_creds_list = list(missing_creds_dict.values())

        host = server_host(server_url)
        service = _service_name(host)
        return SetupRequirementsResponse(
            message=(f"To continue, sign in to {service} and approve access."),
            session_id=session_id,
            setup_info=SetupInfo(
                agent_id=server_url,
                agent_name=service,
                user_readiness=UserReadiness(
                    has_all_credentials=False,
                    missing_credentials=missing_creds_dict,
                    ready_to_run=False,
                ),
                requirements={
                    "credentials": missing_creds_list,
                    "inputs": [],
                    "execution_modes": ["immediate"],
                },
            ),
            graph_id=None,
            graph_version=None,
        )
