from __future__ import annotations

import asyncio
from collections.abc import Callable, Iterator
import hashlib
import importlib.util
import inspect
from pathlib import Path
import re
import sys
import threading
from typing import TYPE_CHECKING, Any

from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.paths import DEFAULT_TOOL_DIR
from vibe.core.tools.base import BaseTool, BaseToolConfig, ToolPermission
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp import MCPRegistry
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.utils import name_matches, run_sync

if TYPE_CHECKING:
    from vibe.core.config import VibeConfig


def _try_canonical_module_name(path: Path) -> str | None:
    """Extract canonical module name for vibe package files.

    Prevents Pydantic class identity mismatches when the same module
    is imported via dynamic discovery and regular imports.
    """
    try:
        parts = path.resolve().parts
    except (OSError, ValueError):
        return None

    try:
        vibe_idx = parts.index("vibe")
    except ValueError:
        return None

    if vibe_idx + 1 >= len(parts):
        return None

    module_parts = [p.removesuffix(".py") for p in parts[vibe_idx:]]
    return ".".join(module_parts)


def _compute_module_name(path: Path) -> str:
    """Return canonical module name for vibe files, hash-based synthetic name otherwise."""
    if canonical := _try_canonical_module_name(path):
        return canonical

    resolved = path.resolve()
    path_hash = hashlib.md5(str(resolved).encode()).hexdigest()[:8]
    stem = re.sub(r"[^0-9A-Za-z_]", "_", path.stem) or "mod"
    return f"vibe_tools_discovered_{stem}_{path_hash}"


class NoSuchToolError(Exception):
    """Exception raised when a tool is not found."""


class ToolManager:
    """Manages tool discovery and instantiation for an Agent.

    Discovers available tools from the provided search paths. Each Agent
    should have its own ToolManager instance.
    """

    def __init__(
        self,
        config_getter: Callable[[], VibeConfig],
        mcp_registry: MCPRegistry | None = None,
        connector_registry: ConnectorRegistry | None = None,
        *,
        defer_mcp: bool = False,
        permission_getter: Callable[[str], ToolPermission | None] | None = None,
    ) -> None:
        self._config_getter = config_getter
        self._permission_getter = permission_getter
        self._mcp_registry = mcp_registry or MCPRegistry()
        self._connector_registry = connector_registry
        self._instances: dict[str, BaseTool] = {}
        self._search_paths: list[Path] = self._compute_search_paths(self._config)
        self._lock = threading.Lock()
        self._mcp_integrated = False

        self._all_tools: dict[str, type[BaseTool]] = {
            cls.get_name(): cls for cls in self._iter_tool_classes(self._search_paths)
        }
        if not defer_mcp:
            self.integrate_all()

    @property
    def _config(self) -> VibeConfig:
        return self._config_getter()

    @staticmethod
    def _compute_search_paths(config: VibeConfig) -> list[Path]:
        paths: list[Path] = [DEFAULT_TOOL_DIR.path]

        paths.extend(config.tool_paths)

        mgr = get_harness_files_manager()
        paths.extend(mgr.project_tools_dirs)
        paths.extend(mgr.user_tools_dirs)

        unique: list[Path] = []
        seen: set[Path] = set()
        for p in paths:
            rp = p.resolve()
            if rp not in seen:
                seen.add(rp)
                unique.append(rp)
        return unique

    @staticmethod
    def _iter_tool_classes(search_paths: list[Path]) -> Iterator[type[BaseTool]]:
        """Iterate over all search_paths to find tool classes.

        Note: if a search path is not a directory, it is treated as a single tool file.
        """
        for base in search_paths:
            if not base.is_dir() and base.name.endswith(".py"):
                if tools := ToolManager._load_tools_from_file(base):
                    for tool in tools:
                        yield tool

            for path in base.rglob("*.py"):
                if tools := ToolManager._load_tools_from_file(path):
                    for tool in tools:
                        yield tool

    @staticmethod
    def _load_tools_from_file(file_path: Path) -> list[type[BaseTool]] | None:
        if not file_path.is_file():
            return
        name = file_path.name
        if name.startswith("_"):
            return

        module_name = _compute_module_name(file_path)

        if module_name in sys.modules:
            module = sys.modules[module_name]
        else:
            spec = importlib.util.spec_from_file_location(module_name, file_path)
            if spec is None or spec.loader is None:
                return
            module = importlib.util.module_from_spec(spec)
            sys.modules[module_name] = module
            try:
                spec.loader.exec_module(module)
            except Exception:
                return

        tools = []
        for tool_obj in vars(module).values():
            if not inspect.isclass(tool_obj):
                continue
            if not issubclass(tool_obj, BaseTool) or tool_obj is BaseTool:
                continue
            if inspect.isabstract(tool_obj):
                continue
            tools.append(tool_obj)
        return tools

    @staticmethod
    def discover_tool_defaults(
        search_paths: list[Path] | None = None,
    ) -> dict[str, dict[str, Any]]:
        if search_paths is None:
            search_paths = [DEFAULT_TOOL_DIR.path]

        defaults: dict[str, dict[str, Any]] = {}
        for cls in ToolManager._iter_tool_classes(search_paths):
            try:
                tool_name = cls.get_name()
                config_class = cls._get_tool_config_class()
                defaults[tool_name] = config_class().model_dump(exclude_none=True)
            except Exception as e:
                logger.warning(
                    "Failed to get defaults for tool %s: %s", cls.__name__, e
                )
                continue
        return defaults

    @property
    def registered_tools(self) -> dict[str, type[BaseTool]]:
        with self._lock:
            return dict(self._all_tools)

    @property
    def available_tools(self) -> dict[str, type[BaseTool]]:
        with self._lock:
            runtime_available = {
                name: cls
                for name, cls in self._all_tools.items()
                if self._is_tool_available(cls)
            }

        # Per-source filtering first (MCP server/connector disabled flags).
        result = self._apply_per_source_filtering(runtime_available)

        # Global overrides take precedence.
        if self._config.enabled_tools:
            return {
                name: cls
                for name, cls in result.items()
                if name_matches(name, self._config.enabled_tools)
            }
        if self._config.disabled_tools:
            return {
                name: cls
                for name, cls in result.items()
                if not name_matches(name, self._config.disabled_tools)
            }
        return result

    def _is_tool_available(self, cls: type[BaseTool]) -> bool:
        # Backwards-compatibility check to avoid breaking
        # existing custom tools that call is_available without parameters
        if inspect.signature(cls.is_available).parameters:
            return cls.is_available(self._config)
        return cls.is_available()

    def _apply_per_source_filtering(
        self, tools: dict[str, type[BaseTool]]
    ) -> dict[str, type[BaseTool]]:
        """Filter out MCP/connector tools disabled at the server or connector level."""
        disabled_sources, per_source_disabled = self._build_source_disable_index()
        if not disabled_sources and not per_source_disabled:
            return tools

        return {
            name: cls
            for name, cls in tools.items()
            if not self._is_source_disabled(cls, disabled_sources, per_source_disabled)
        }

    def _build_source_disable_index(
        self,
    ) -> tuple[set[tuple[str, bool]], dict[tuple[str, bool], set[str]]]:
        """Return (fully_disabled, per_tool_disabled) keyed by (source_name, is_connector)."""
        disabled_sources: set[tuple[str, bool]] = set()
        per_source_disabled: dict[tuple[str, bool], set[str]] = {}

        for srv in self._config.mcp_servers:
            key = (srv.name, False)
            if srv.disabled:
                disabled_sources.add(key)
            elif srv.disabled_tools:
                per_source_disabled[key] = set(srv.disabled_tools)

        for cfg in self._config.connectors:
            if cfg.disabled_tools and not cfg.disabled:
                per_source_disabled[(cfg.name, True)] = set(cfg.disabled_tools)

        if self._connector_registry is not None:
            by_name = self._config.connectors_by_name()
            for name in self._connector_registry.get_connector_names():
                cfg = by_name.get(name)
                if cfg is None or cfg.disabled:
                    disabled_sources.add((name, True))

        return disabled_sources, per_source_disabled

    @staticmethod
    def _is_source_disabled(
        tool_cls: type[BaseTool],
        disabled_sources: set[tuple[str, bool]],
        per_source_disabled: dict[tuple[str, bool], set[str]],
    ) -> bool:
        if not issubclass(tool_cls, MCPTool):
            return False
        server_name = tool_cls.get_server_name()
        if server_name is None:
            return False
        key = (server_name, tool_cls.is_connector())
        if key in disabled_sources:
            return True
        return tool_cls.get_remote_name() in per_source_disabled.get(key, set())

    def integrate_mcp(self, *, raise_on_failure: bool = False) -> None:
        """Discover and register MCP tools (sync wrapper).

        Idempotent: subsequent calls after a successful integration are
        no-ops to avoid redundant MCP discovery.
        """
        run_sync(self._integrate_mcp_async(raise_on_failure=raise_on_failure))

    async def _integrate_mcp_async(self, *, raise_on_failure: bool = False) -> None:
        """Async MCP discovery — canonical implementation."""
        if self._mcp_integrated:
            return
        if not self._config.mcp_servers:
            self._mcp_registry.sync_active_servers([])
            return

        try:
            mcp_tools = await self._mcp_registry.get_tools_async(
                self._config.mcp_servers
            )
        except Exception as exc:
            logger.warning("MCP integration failed: %s", exc)
            if raise_on_failure:
                raise
            return

        with self._lock:
            self._all_tools = {**self._all_tools, **mcp_tools}
        self._mcp_integrated = True
        logger.info(
            "MCP integration registered %d tools (via registry)", len(mcp_tools)
        )

    def _purge_connector_state(self) -> None:
        """Remove stale connector tool classes and cached instances."""
        stale_keys = [
            name
            for name, cls in self._all_tools.items()
            if issubclass(cls, MCPTool) and cls.is_connector()
        ]
        for key in stale_keys:
            self._all_tools.pop(key, None)
            self._instances.pop(key, None)

    def _purge_mcp_state(self) -> None:
        """Remove stale MCP tool classes and cached instances."""
        stale_keys = [
            name
            for name, cls in self._all_tools.items()
            if issubclass(cls, MCPTool) and not cls.is_connector()
        ]
        for key in stale_keys:
            self._all_tools.pop(key, None)
            self._instances.pop(key, None)

    def integrate_connectors(self) -> None:
        """Discover and register connector tools (sync wrapper)."""
        run_sync(self.integrate_connectors_async())

    async def integrate_connectors_async(self) -> None:
        """Discover and register connector tools — canonical implementation.

        Thread-safe: can be called from the deferred-init background thread.
        """
        if self._connector_registry is None:
            return

        try:
            connector_tools = await self._connector_registry.get_tools_async()
        except Exception as exc:
            logger.warning(f"Connector integration failed: {exc}")
            with self._lock:
                self._purge_connector_state()
            return

        with self._lock:
            self._purge_connector_state()
            self._all_tools.update(connector_tools)
        logger.info(f"Connector integration registered {len(connector_tools)} tools")

    async def refresh_remote_tools_async(self) -> None:
        """Force MCP and connector re-discovery for the current config."""
        with self._lock:
            self._mcp_registry.clear()
            self._purge_mcp_state()
            self._mcp_integrated = False
            self._purge_connector_state()
            if self._connector_registry is not None:
                self._connector_registry.clear()

        await self._integrate_all_async()

    def refresh_remote_tools(self) -> None:
        """Sync wrapper for :meth:`refresh_remote_tools_async`."""
        run_sync(self.refresh_remote_tools_async())

    def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None:
        """Discover MCP and connector tools in parallel.

        Runs both async discovery paths concurrently via ``asyncio.gather``
        inside a single ``run_sync`` call.
        """
        run_sync(self._integrate_all_async(raise_on_mcp_failure=raise_on_mcp_failure))

    async def _integrate_all_async(self, *, raise_on_mcp_failure: bool = False) -> None:
        """Run MCP and connector discovery concurrently.

        Uses ``return_exceptions=True`` so that a failing MCP server does
        not cancel in-flight connector discovery (or vice-versa).
        """
        mcp_result, connector_result = await asyncio.gather(
            self._integrate_mcp_async(raise_on_failure=raise_on_mcp_failure),
            self.integrate_connectors_async(),
            return_exceptions=True,
        )

        # Re-raise MCP errors when the caller asked for them.
        if isinstance(mcp_result, BaseException):
            if raise_on_mcp_failure:
                raise mcp_result
            logger.warning(f"MCP integration failed: {mcp_result}")

        if isinstance(connector_result, BaseException):
            logger.warning(f"Connector integration failed: {connector_result}")

    def get_tool_config(self, tool_name: str) -> BaseToolConfig:
        with self._lock:
            tool_class = self._all_tools.get(tool_name)

        if tool_class:
            config_class = tool_class._get_tool_config_class()
            default_config = config_class()
        else:
            config_class = BaseToolConfig
            default_config = BaseToolConfig()

        user_overrides = self._config.tools.get(tool_name)
        permission_override = (
            self._permission_getter(tool_name) if self._permission_getter else None
        )
        if user_overrides is None and permission_override is None:
            return config_class()

        merged_dict = {**default_config.model_dump(), **(user_overrides or {})}
        if permission_override is not None:
            merged_dict["permission"] = permission_override.value
        return config_class.model_validate(merged_dict)

    def get(self, tool_name: str) -> BaseTool:
        """Get a tool instance, creating it lazily on first call.

        Raises:
            NoSuchToolError: If the requested tool is not available.
        """
        if tool_name in self._instances:
            return self._instances[tool_name]

        available = self.available_tools
        if tool_name not in available:
            raise NoSuchToolError(
                f"Unknown or disabled tool: {tool_name}. "
                f"Available: {list(available.keys())}"
            )
        tool_class = available[tool_name]
        self._instances[tool_name] = tool_class.from_config(
            lambda: self.get_tool_config(tool_name)
        )
        return self._instances[tool_name]

    def reset_all(self) -> None:
        self._instances.clear()
