"""Composite tool manager using unified MCP architecture."""

import asyncio
import json
import re
import time
from pathlib import Path
from typing import Any

from cadecoder.core.config import is_verbose
from cadecoder.core.logging import log
from cadecoder.tools.manager.base import ToolAuthorizationRequired, ToolManager
from cadecoder.tools.manager.config import (
    MCPAuthType,
    MCPServerConfig,
    MCPServerStore,
    MCPTransportType,
)
from cadecoder.tools.manager.mcp import MCPToolManager


class CompositeToolManager(ToolManager):
    """Manages tools from MCP servers (both built-in local and external)."""

    def __init__(
        self, enable_mcp: bool = True, local_only: bool = False, persona: str | None = None
    ):
        self._tool_source_map: dict[str, str] = {}
        self._mcp_tool_to_manager: dict[str, tuple[MCPToolManager, str]] = {}
        self._tool_schemas: dict[str, dict[str, Any]] = {}
        self._mcp_managers: list[MCPToolManager] = []
        self._enable_mcp = enable_mcp
        self._local_only = local_only
        # Case-insensitive index: lowercase name -> registered name
        self._ci_index: dict[str, str] = {}
        self._tools_loaded = False  # Track whether get_tools() has been called

        # Create built-in local tools MCP server (always enabled)
        local_mcp_config = self._create_local_tools_config()
        local_mcp_manager = MCPToolManager(local_mcp_config)
        self._mcp_managers.append(local_mcp_manager)
        self._local_mcp_manager = local_mcp_manager

        # Create built-in memory MCP server (agent-library)
        memory_mcp_config = self._create_memory_tools_config(persona=persona)
        if memory_mcp_config:
            memory_mcp_manager = MCPToolManager(memory_mcp_config)
            self._mcp_managers.append(memory_mcp_manager)

        # Load user-configured MCP servers (skip if local_only mode)
        if local_only:
            log.info("Local-only mode: external MCP servers disabled")
        elif enable_mcp:
            self._mcp_store = MCPServerStore()
            for server_config in self._mcp_store.list_enabled():
                mcp_manager = MCPToolManager(server_config, self._mcp_store)
                self._mcp_managers.append(mcp_manager)

    def _create_local_tools_config(self) -> MCPServerConfig:
        """Create MCP config for built-in local tools via stdio."""
        import importlib.util
        import os

        # Get the path without importing the module (which would trigger tool registration)
        spec = importlib.util.find_spec("cade_mcp_local.tools")
        if spec is None or spec.origin is None:
            raise ImportError("Could not find cade_mcp_local.tools module")
        local_tools_dir = Path(spec.origin).parent

        env: dict[str, str] = {
            "LOGURU_LEVEL": "WARNING",
            "CADE_PROJECT_ROOT": str(Path.cwd().resolve()),
        }
        # Forward tool result storage dir so MCP tools use the same
        # persona-scoped location as the orchestrator.
        tool_result_dir = os.environ.get("CADE_TOOL_RESULT_DIR")
        if tool_result_dir:
            env["CADE_TOOL_RESULT_DIR"] = tool_result_dir

        return MCPServerConfig(
            name="__builtin_local__",
            url="uv",
            transport=MCPTransportType.STDIO,
            command="uv",
            args=[
                "run",
                "--with",
                "arcade-mcp-server",
                "python",
                "-m",
                "arcade_mcp_server",
                "stdio",
                "--name",
                "Local",
                "--cwd",
                str(local_tools_dir),
            ],
            enabled=True,
            auth_type=MCPAuthType.NONE,
            env=env,
            suppress_stderr=True,  # Avoid DEBUG "Added tool" spam in terminal
        )

    def _create_memory_tools_config(self, persona: str | None = None) -> MCPServerConfig | None:
        """Create MCP config for the built-in memory server (agent-library).

        The memory server stores knowledge per-persona under
        ``~/.cadecoder/memory/<persona>/``.  Falls back to a ``default``
        persona directory when no persona is active.
        """
        try:
            import importlib.util

            if importlib.util.find_spec("librarian") is None:
                log.debug("agent-library not installed, skipping memory server")
                return None
        except Exception:
            return None

        from cadecoder.core.config import get_config

        persona_name = persona or "default"
        app_dir = Path(get_config().app_dir)
        memory_dir = app_dir / "memory" / persona_name
        memory_dir.mkdir(parents=True, exist_ok=True)

        db_path = memory_dir / "index.db"
        docs_path = memory_dir / "documents"
        docs_path.mkdir(parents=True, exist_ok=True)
        sources_path = memory_dir / "sources.json"

        return MCPServerConfig(
            name="__builtin_memory__",
            url="uv",
            transport=MCPTransportType.STDIO,
            command="uv",
            args=[
                "run",
                "python",
                "-m",
                "librarian.server",
                "stdio",
            ],
            enabled=True,
            auth_type=MCPAuthType.NONE,
            env={
                "DATABASE_PATH": str(db_path),
                "DOCUMENTS_PATH": str(docs_path),
                "SOURCES_CONFIG_PATH": str(sources_path),
                "LOGURU_LEVEL": "WARNING",
            },
            suppress_stderr=True,
        )

    def _normalize_local_tool_name(self, arcade_name: str) -> str:
        """Normalize local tool names to consistent Local_* format.

        With --name Local, the arcade-mcp-server produces FQNs like
        Local.Bash → sanitized to Local_Bash. These are already correct.

        For backward compatibility, also handle legacy formats:
        - ArcadeMCP_LocalBash → Local_Bash (old default toolkit name)
        - LocalBash → Local_Bash (no toolkit prefix)
        """
        # Already correct Local_* format (e.g., Local_Bash, Local_ReadFile)
        if arcade_name.startswith("Local_") and not arcade_name.startswith("Local_Local"):
            return arcade_name

        # Handle doubled Local prefix: Local_LocalBash → Local_Bash
        if arcade_name.startswith("Local_Local"):
            tool_part = arcade_name[len("Local_Local") :]
            if tool_part:
                return f"Local_{tool_part}"

        # Handle no-prefix format: LocalBash → Local_Bash
        if arcade_name.startswith("Local") and not arcade_name.startswith("Local_"):
            tool_part = arcade_name[5:]  # Everything after "Local"
            if tool_part:
                return f"Local_{tool_part[0].upper()}{tool_part[1:]}"

        # Handle legacy ArcadeMCP_ prefix
        if arcade_name.startswith("ArcadeMCP_"):
            without_prefix = arcade_name[len("ArcadeMCP_") :]
            return self._normalize_local_tool_name(without_prefix)

        return arcade_name

    @staticmethod
    def _sanitize_tool_name(name: str) -> str:
        """Sanitize a tool name for the Anthropic API.

        Anthropic requires tool names to match ``^[a-zA-Z0-9_-]{1,128}$``.
        Replace any disallowed characters with underscores and strip version
        suffixes (e.g. ``@3_1_2``).
        """
        # Strip version suffix first
        at_idx = name.find("@")
        if at_idx > 0:
            name = name[:at_idx]
        # Replace disallowed characters
        name = re.sub(r"[^a-zA-Z0-9_-]", "_", name)
        return name[:128]

    async def _timed_get_tools(
        self, mcp_manager: MCPToolManager
    ) -> tuple[MCPToolManager, list[dict[str, Any]], float]:
        """Call get_tools() on a single manager and measure elapsed time."""
        start = time.monotonic()
        tools = await mcp_manager.get_tools()
        elapsed = time.monotonic() - start
        return mcp_manager, tools, elapsed

    async def get_tools(self) -> list[dict[str, Any]]:
        """Get all available tools from MCP sources (loaded in parallel)."""
        all_tools: list[dict[str, Any]] = []
        local_count = 0
        mcp_count = 0

        verbose = is_verbose()
        verbose_console = None
        if verbose:
            from rich.console import Console

            verbose_console = Console(stderr=True)
            server_names = [m.config.name for m in self._mcp_managers]
            verbose_console.print(
                f"  [dim]Loading tools from {len(server_names)} servers in parallel...[/dim]"
            )

        total_start = time.monotonic()

        # Fire all MCP manager get_tools() calls in parallel
        tasks = [self._timed_get_tools(m) for m in self._mcp_managers]
        results = await asyncio.gather(*tasks, return_exceptions=True)

        # Process results (asyncio.gather preserves input order)
        auth_warnings: list[tuple[str, str | None]] = []

        for mcp_manager, result in zip(self._mcp_managers, results):
            if isinstance(result, BaseException):
                server_name = mcp_manager.config.name

                # Surface auth requirements so the user knows to authorize
                if isinstance(result, ToolAuthorizationRequired):
                    auth_url = getattr(result, "authorization_url", None)
                    auth_warnings.append((server_name, auth_url))
                    log.warning(f"MCP server '{server_name}' requires authorization")
                    if verbose and verbose_console:
                        verbose_console.print(
                            f"  [yellow]⚠ '{server_name}': requires authorization[/yellow]"
                        )
                else:
                    log.error(f"Failed to load tools from MCP '{server_name}': {result}")
                    if verbose and verbose_console:
                        verbose_console.print(
                            f"  [red]✗ '{server_name}': "
                            f"failed ({type(result).__name__}: {result})[/red]"
                        )
                continue

            _manager, mcp_tools, elapsed = result
            is_builtin = mcp_manager.config.name == "__builtin_local__"
            is_memory = mcp_manager.config.name == "__builtin_memory__"

            for tool in mcp_tools:
                arcade_name = tool.get("function", {}).get("name", "unknown")

                # Normalize local tool names back to original format
                if is_builtin:
                    tool_name = self._normalize_local_tool_name(arcade_name)
                    # Update the tool schema with normalized name
                    tool["function"]["name"] = tool_name
                    # Keep mapping from normalized name to arcade name for execution
                    self._tool_source_map[tool_name] = "local"
                    self._mcp_tool_to_manager[tool_name] = (mcp_manager, arcade_name)
                    local_count += 1
                elif is_memory:
                    # Rename Librarian_X → Memory_X for clarity
                    tool_name = self._sanitize_tool_name(arcade_name)
                    tool_name = re.sub(r"^Librarian_", "Memory_", tool_name)
                    tool["function"]["name"] = tool_name
                    self._tool_source_map[tool_name] = "local"
                    self._mcp_tool_to_manager[tool_name] = (mcp_manager, arcade_name)
                    local_count += 1
                else:
                    tool_name = self._sanitize_tool_name(arcade_name)
                    # Update schema with sanitized name
                    tool["function"]["name"] = tool_name
                    self._tool_source_map[tool_name] = "mcp"
                    self._mcp_tool_to_manager[tool_name] = (mcp_manager, arcade_name)
                    mcp_count += 1

                # Cache tool schema for error enrichment
                self._tool_schemas[tool_name] = tool
                # Build case-insensitive index
                self._ci_index[tool_name.lower()] = tool_name

                all_tools.append(tool)

            # Update server status for external MCP servers
            if not is_builtin and self._enable_mcp and hasattr(self, "_mcp_store"):
                self._mcp_store.update_status(mcp_manager.config.name, True, len(mcp_tools))

            label = "built-in local" if is_builtin else "MCP"
            log.info(
                f"Loaded {len(mcp_tools)} tools from {label} server "
                f"'{mcp_manager.config.name}' ({elapsed:.1f}s)"
            )

            if verbose and verbose_console:
                verbose_console.print(
                    f"  [green]✓ '{mcp_manager.config.name}': "
                    f"{len(mcp_tools)} tools ({elapsed:.1f}s)[/green]"
                )

        total_elapsed = time.monotonic() - total_start

        # Summary
        log.info(
            f"Total tools: {len(all_tools)} (Built-in: {local_count}, MCP: {mcp_count}) "
            f"in {total_elapsed:.1f}s"
        )

        if verbose and verbose_console:
            verbose_console.print(
                f"  [bold]Total: {len(all_tools)} tools from "
                f"{len(self._mcp_managers)} servers ({total_elapsed:.1f}s)[/bold]"
            )

        # Store auth warnings for CLI to display
        self._auth_warnings = auth_warnings

        self._tools_loaded = True
        return all_tools

    def _get_tool_schema(self, tool_name: str) -> dict[str, Any] | None:
        """Get the schema for a tool by name.

        Args:
            tool_name: Name of the tool.

        Returns:
            The tool's schema dict, or None if not found.
        """
        return self._tool_schemas.get(tool_name)

    def _enrich_error(self, name: str, error: Exception, inputs: dict[str, Any]) -> str:
        """Create enriched error message with tool schema.

        Args:
            name: Tool name that was called.
            error: The exception that occurred.
            inputs: The inputs that were passed to the tool.

        Returns:
            Enriched error message with the tool's input schema.
        """
        error_str = str(error)
        schema = self._get_tool_schema(name)

        if schema:
            params = schema.get("function", {}).get("parameters", {})
            params_json = json.dumps(params, indent=2)
            return f"Error calling tool '{name}': {error_str}. Tool input schema is: {params_json}"

        return f"Tool '{name}' does not exist: {error_str}"

    async def execute(self, name: str, inputs: dict[str, Any]) -> Any:
        """Execute a tool by routing to the appropriate MCP manager."""
        # Ensure tools have been loaded before execution
        if not self._tools_loaded:
            log.warning("Tools not loaded before execute() was called. Loading now...")
            await self.get_tools()

        manager_info = self._mcp_tool_to_manager.get(name)

        # Case-insensitive fallback (arcade_mcp_server mangles casing)
        if not manager_info:
            registered = self._ci_index.get(name.lower())
            if registered:
                manager_info = self._mcp_tool_to_manager.get(registered)
                log.debug(f"Case-insensitive tool match: '{name}' -> '{registered}'")

        if not manager_info:
            # Provide helpful diagnostic information
            available_tools = list(self._mcp_tool_to_manager.keys())[:10]
            raise Exception(
                f"Tool '{name}' not found in tool mappings. "
                f"Available tools (first 10): {available_tools}. "
                f"Total mappings: {len(self._mcp_tool_to_manager)}"
            )

        mcp_manager, arcade_name = manager_info
        source = self._tool_source_map.get(name, "unknown")
        log.debug(f"Executing {source} tool: {name} (arcade name: {arcade_name})")

        # Use the arcade name for execution (what the server expects)
        try:
            return await mcp_manager.execute(arcade_name, inputs)
        except Exception as e:
            # Enrich tool execution errors with schema information
            # This helps the LLM understand what went wrong and fix parameter issues
            error_str = str(e).lower()

            # Check if this looks like a parameter-related error
            is_param_error = any(
                keyword in error_str
                for keyword in [
                    "required",
                    "missing",
                    "argument",
                    "parameter",
                    "type",
                    "expected",
                    "invalid",
                    "schema",
                    "validation",
                ]
            )

            if is_param_error:
                enriched_error = self._enrich_error(name, e, inputs)
                raise Exception(enriched_error) from e

            # Re-raise other errors as-is
            raise

    def is_interactive_tool(self, name: str) -> bool:
        """Determine whether a tool is interactive (bash_tool)."""
        # Interactive tools are bash-like tools that need terminal access
        return name == "bash_tool"

    def get_tool_source(self, name: str) -> str | None:
        """Get the source type for a tool."""
        return self._tool_source_map.get(name)

    def has_external_tools(self) -> bool:
        """Check whether any external (non-local) MCP tools are loaded."""
        return any(source == "mcp" for source in self._tool_source_map.values())

    def get_all_tool_info(self) -> list[dict[str, Any]]:
        """Get info about all tools including their source."""
        tool_info = []
        for name, source in self._tool_source_map.items():
            info: dict[str, Any] = {"name": name, "source": source}
            if name in self._mcp_tool_to_manager:
                manager, arcade_name = self._mcp_tool_to_manager[name]
                info["server"] = manager.config.name
                info["transport"] = manager.config.transport.value
                if arcade_name != name:
                    info["arcade_name"] = arcade_name
            tool_info.append(info)
        return tool_info

    async def close(self) -> None:
        """Close all MCP connections in parallel."""
        if self._mcp_managers:
            results = await asyncio.gather(
                *[m.close() for m in self._mcp_managers],
                return_exceptions=True,
            )
            for mcp_manager, result in zip(self._mcp_managers, results):
                if isinstance(result, BaseException):
                    log.warning(f"Error closing MCP server '{mcp_manager.config.name}': {result}")
