"""MCP Apps extension (`io.modelcontextprotocol/ui`).

MCP Apps lets a tool carry a reference to an interactive UI: the tool's
`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served
with the `text/html;profile=mcp-app` MIME type) that the host renders in a
sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps
and the ext-apps spec for the wire format, and SEP-2133 for the extension framework.

This is a self-contained, additive `Extension`: it contributes tools and
resources and advertises the capability, but does not intercept any core method.
A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`.

    apps = Apps()

    @apps.tool(resource_uri="ui://clock/app.html", description="Current time")
    def get_time(ctx: Context) -> str:
        return datetime.now(timezone.utc).isoformat()

    apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)

    mcp = MCPServer("clock", extensions=[apps])

Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should
still return meaningful text for clients that did not negotiate Apps. Use
`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK
keeps Apps in-core under `mcp.server.apps` rather than a separate package; the
TypeScript and C# SDKs ship it as a standalone package.)
"""

from __future__ import annotations

from collections.abc import Callable, Sequence
from typing import Any, Literal, TypeVar

from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, ResourceBinding, ToolBinding
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.resources import Resource, TextResource

EXTENSION_ID = "io.modelcontextprotocol/ui"
"""The MCP Apps extension identifier (the shipped TS/C# constant)."""

APP_MIME_TYPE = "text/html;profile=mcp-app"
"""MIME type for a `ui://` app resource."""

Visibility = Literal["model", "app"]
"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`)."""

_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])


class ResourcePermissions(BaseModel):
    """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`)."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    camera: dict[str, Any] | None = None
    microphone: dict[str, Any] | None = None
    geolocation: dict[str, Any] | None = None
    clipboard_write: dict[str, Any] | None = None


class ResourceCsp(BaseModel):
    """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`)."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

    connect_domains: list[str] | None = None
    resource_domains: list[str] | None = None
    frame_domains: list[str] | None = None
    base_uri_domains: list[str] | None = None


class Apps(Extension):
    """The MCP Apps extension: bind tools to `ui://` UI resources.

    Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML
    with `add_html_resource(...)`, then pass the instance to
    `MCPServer(extensions=[apps])`.
    """

    identifier = EXTENSION_ID

    def __init__(self) -> None:
        self._tools: list[tuple[ToolBinding, str]] = []  # (binding, bound resource_uri)
        self._resources: list[ResourceBinding] = []

    def tool(
        self,
        *,
        resource_uri: str,
        visibility: Sequence[Visibility] | None = None,
        meta: dict[str, Any] | None = None,
        **tool_kwargs: Any,
    ) -> Callable[[_CallableT], _CallableT]:
        """Decorator registering a tool bound to a `ui://` resource.

        Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the
        tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title,
        description, annotations, ...); pass `meta=` to merge extra `_meta` keys
        alongside the `ui` entry.

        Args:
            resource_uri: The `ui://` URI of the UI resource this tool renders.
            visibility: Where the tool is surfaced (`["model", "app"]`).
            meta: Additional `_meta` keys to merge with the `ui` entry.

        Raises:
            ValueError: If `resource_uri` does not use the `ui://` scheme, or
                `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`).
        """
        _require_ui_scheme(resource_uri)
        if meta and "ui" in meta:
            raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key")
        ui: dict[str, Any] = {"resourceUri": resource_uri}
        if visibility is not None:
            ui["visibility"] = list(visibility)

        def decorator(fn: _CallableT) -> _CallableT:
            binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs)
            self._tools.append((binding, resource_uri))
            return fn

        return decorator

    def add_html_resource(
        self,
        uri: str,
        html: str,
        *,
        name: str | None = None,
        title: str | None = None,
        description: str | None = None,
        csp: ResourceCsp | None = None,
        permissions: ResourcePermissions | None = None,
        domain: str | None = None,
        prefers_border: bool | None = None,
    ) -> None:
        """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`.

        `csp`, `permissions`, `domain`, and `prefers_border` populate the
        resource's `_meta.ui` per the ext-apps spec.

        Args:
            uri: The `ui://` URI; a tool references it via `resource_uri`.
            html: The HTML document the host renders.

        Raises:
            ValueError: If `uri` does not use the `ui://` scheme.
        """
        ui: dict[str, Any] = {}
        if csp is not None:
            ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True)
        if permissions is not None:
            ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True)
        if domain is not None:
            ui["domain"] = domain
        if prefers_border is not None:
            ui["prefersBorder"] = prefers_border
        self.add_resource(
            TextResource(
                uri=uri,
                name=name or uri,
                title=title,
                description=description,
                mime_type=APP_MIME_TYPE,
                meta={"ui": ui} if ui else None,
                text=html,
            )
        )

    def add_resource(self, resource: Resource) -> None:
        """Register a pre-built `ui://` resource.

        The escape hatch for resources `add_html_resource` cannot express (e.g. a
        `FileResource` serving HTML from disk). A resource without an explicit
        `mime_type` is served as `text/html;profile=mcp-app` — hosts will not
        render a `ui://` resource under any other MIME type, so an explicit
        mismatch is rejected.

        Raises:
            ValueError: If the resource URI does not use the `ui://` scheme, or
                its explicit `mime_type` is not `text/html;profile=mcp-app`.
        """
        _require_ui_scheme(resource.uri)
        if "mime_type" not in resource.model_fields_set:
            resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE})
        elif resource.mime_type != APP_MIME_TYPE:
            raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}")
        self._resources.append(ResourceBinding(resource=resource))

    def tools(self) -> Sequence[ToolBinding]:
        """The bound tools.

        Raises:
            ValueError: If a tool's `resource_uri` has no matching resource
                registered on this instance — a tool advertising a
                `_meta.ui.resourceUri` that 404s on `resources/read` is a
                misconfiguration, caught when the server consumes the extension.
        """
        registered = {binding.resource.uri for binding in self._resources}
        for tool, uri in self._tools:
            if uri not in registered:
                raise ValueError(
                    f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource "
                    "is registered; add it with add_html_resource() or add_resource()"
                )
        return [tool for tool, _ in self._tools]

    def resources(self) -> Sequence[ResourceBinding]:
        return self._resources


def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool:
    """Whether the connected client negotiated MCP Apps support.

    Returns `True` only when the client advertised the extension AND listed the
    `text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool
    can fall back to text-only output otherwise.
    """
    capabilities = _client_capabilities(ctx)
    extensions = capabilities.extensions if capabilities else None
    settings = extensions.get(EXTENSION_ID) if extensions else None
    if settings is None:
        return False
    mime_types = settings.get("mimeTypes")
    return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types


def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any:
    if isinstance(ctx, Context):
        return ctx.client_capabilities
    client_params = ctx.session.client_params
    return client_params.capabilities if client_params else None


def _require_ui_scheme(uri: str) -> None:
    if not uri.startswith("ui://"):
        raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}")
