"""MCP Server — exposes AgntSpace tools via Model Context Protocol.

Any MCP-compatible client (Claude Desktop, other agents, IDE extensions)
can connect and use AgntSpace's tools: email, vault, tasks, journal, etc.

All tool calls are contract-enforced.
"""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mcp.server import Server
from mcp.types import Resource, TextContent, Tool

if TYPE_CHECKING:
    from agntspace.agent.tools import ToolExecutor
    from agntspace.vault.reader import VaultReader

log = logging.getLogger(__name__)


# Tools that are safe to expose under the ``read_only`` token scope.
# Conservative allowlist by design — anything not listed here is blocked
# with a clear error. Adding a tool requires auditing its side effects.
# Callers that MUTATE the vault, network, or external systems must NEVER
# be added here regardless of how innocuous they look.
READ_ONLY_TOOLS: frozenset[str] = frozenset({
    "vault_search",                  # read-only FTS over markdown
    "recall_memory",                 # reads consolidated memory files
    "deep_memory_recall",            # reads graph + daily files
    "query_memory_graph",            # pure graph query (no writes)
    "query_knowledge",               # unified read over graph + vault + memory
    "list_tasks",                    # list, no mutation
    "list_projects",                 # list, no mutation
    "list_subagents",                # list, no mutation
    "journal_read_today",            # reads today's journal
    "read_inbox",                    # IMAP read (marking-as-read is server-side, not ours)
    "read_email",                    # IMAP fetch
    "web_search",                    # external network query, no vault write
})


def _is_tool_allowed_for_scope(tool_name: str, scope: str) -> bool:
    """Return True if ``tool_name`` is authorized under ``scope``.

    Intentionally simple — the full contract enforcer still runs on
    every call. Scope is a NARROWING filter applied BEFORE the contract,
    not a replacement for it.
    """
    if scope == "full":
        return True
    if scope == "read_only":
        return tool_name in READ_ONLY_TOOLS
    # Unknown scope — fail closed. ``tokens._normalize_scope`` should
    # have prevented this, but defense in depth.
    return False


def create_mcp_server(
    tool_executor: ToolExecutor,
    vault_path: Path | None = None,
    vault_reader: VaultReader | None = None,
    scope: str = "full",
) -> Server:
    """Create an MCP server that exposes AgntSpace tools.

    ``scope`` narrows the tool surface per-session. Each SSE connection
    gets its own Server instance built with the caller's scope, so scope
    is never shared across sessions. The stdio launcher always uses
    ``full`` since it's a trusted local subprocess spawned by the user.
    """
    from agntspace.agent.tools import TOOL_DEFINITIONS

    # Normalize / fail-closed on unknown scope values. The token store
    # already normalizes, but callers may pass raw strings.
    if scope not in ("full", "read_only"):
        log.warning("create_mcp_server: unknown scope %r, defaulting to read_only", scope)
        scope = "read_only"

    # Technical identifier stays "agntspace" — it's the JSON key clients
    # use in their MCP server config and renaming is a breaking change.
    # The other fields are what humans (and LLMs listing servers) see.
    try:
        from agntspace.api.routes.health import _get_version
        _ver = _get_version()
    except Exception:
        _ver = None

    server = Server(
        "agntspace",
        version=_ver,
        instructions=(
            "AgntSpace — your personal MCP. Exposes your vault: long-term "
            "memory, knowledge graph (entities + facts), tasks, projects, "
            "goals, journal, and per-KB wiki. Every tool call is gated by "
            "your CONTRACT.md permissions. Read SOUL.md and USER.md first "
            "to ground answers in who the user is."
        ),
        website_url="https://agntspace.com",
    )

    @server.list_tools()
    async def list_tools() -> list[Tool]:
        """List all tools the session is authorized to call.

        Filtering here (rather than showing everything and blocking at
        call time) gives the client's LLM an honest picture of what it
        can do — it won't waste tokens deliberating over a tool we'd
        reject anyway. Full-scope sessions still see everything.
        """
        tools = []
        for td in TOOL_DEFINITIONS:
            if not _is_tool_allowed_for_scope(td["name"], scope):
                continue
            tools.append(Tool(
                name=td["name"],
                description=td.get("description", ""),
                inputSchema=td.get("input_schema", {"type": "object", "properties": {}}),
            ))
        return tools

    @server.call_tool()
    async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
        """Execute a tool call with scope filter + contract enforcement.

        Scope check runs BEFORE the contract — a read-only token must
        never even reach the contract enforcer with a write action, so
        the contract's audit trail stays clean of noise-blocks.
        """
        log.info("MCP tool call: %s (scope=%s)", name, scope)

        # Scope narrowing — read-only tokens can't invoke mutating tools.
        if not _is_tool_allowed_for_scope(name, scope):
            return [TextContent(
                type="text",
                text=(
                    f"Error: Tool '{name}' is not available under token scope "
                    f"'{scope}'. Mint a full-scope token to use it."
                ),
            )]

        # ToolExecutor.execute() is sync — safe to call directly
        result = tool_executor.execute(name, arguments)

        if result.get("error"):
            return [TextContent(type="text", text=f"Error: {result['error']}")]

        if result.get("needs_confirmation"):
            return [TextContent(
                type="text",
                text=f"Confirmation required: {result.get('reason', 'Contract requires approval for this action')}",
            )]

        return [TextContent(type="text", text=json.dumps(result, indent=2, default=str))]

    @server.list_resources()
    async def list_resources() -> list[Resource]:
        """List vault identity files as MCP resources."""
        resources = [
            Resource(
                uri="agntspace://vault/SOUL.md",
                name="Agent Identity (SOUL.md)",
                mimeType="text/markdown",
            ),
            Resource(
                uri="agntspace://vault/CONTRACT.md",
                name="User-Agent Contract",
                mimeType="text/markdown",
            ),
            Resource(
                uri="agntspace://vault/memory/MEMORY.md",
                name="Long-term Memory",
                mimeType="text/markdown",
            ),
            Resource(
                uri="agntspace://vault/USER.md",
                name="User Profile",
                mimeType="text/markdown",
            ),
        ]
        # Add KB schemas as resources if vault available
        _reader = vault_reader
        _vpath = vault_path
        if _reader and _reader.paths:
            kb_dir = _reader.paths.resolve("knowledge")
            if kb_dir.is_dir():
                for kb in kb_dir.iterdir():
                    schema = kb / "SCHEMA.md"
                    if kb.is_dir() and schema.exists():
                        resources.append(Resource(
                            uri=f"agntspace://vault/knowledge/{kb.name}/SCHEMA.md",
                            name=f"KB: {kb.name} (schema)",
                            mimeType="text/markdown",
                        ))
        elif _vpath:
            # Fallback: try agntspace-knowledge/ at root level
            kb_dir = _vpath.parent / "agntspace-knowledge"
            if not kb_dir.is_dir():
                kb_dir = _vpath / "knowledge"
            if kb_dir.is_dir():
                for kb in kb_dir.iterdir():
                    schema = kb / "SCHEMA.md"
                    if kb.is_dir() and schema.exists():
                        resources.append(Resource(
                            uri=f"agntspace://vault/knowledge/{kb.name}/SCHEMA.md",
                            name=f"KB: {kb.name} (schema)",
                            mimeType="text/markdown",
                        ))
        return resources

    @server.read_resource()
    async def read_resource(uri: str) -> str:
        """Read a vault file as MCP resource."""
        # Parse uri: agntspace://vault/path
        path = str(uri).replace("agntspace://vault/", "")
        _reader = vault_reader
        _vpath = vault_path
        if _reader:
            # Use reader's resolve which handles VaultPaths
            try:
                doc = _reader.read(path)
                return doc.raw
            except FileNotFoundError:
                # Try identity dir
                try:
                    doc = _reader.read(f"system/identity/{path}")
                    return doc.raw
                except FileNotFoundError:
                    pass
        elif _vpath:
            full_path = _vpath / path
            if not full_path.exists():
                full_path = _vpath / "system" / "identity" / path
            if full_path.exists():
                return full_path.read_text(encoding="utf-8")
        return f"Error: resource not found: {path}"

    return server
