"""MCP Client with lazy start, auto-stop, and health monitoring.

Provides :class:`McpClient`, a high-level wrapper around
:class:`~cleveragents.mcp.adapter.MCPToolAdapter` that adds lifecycle
management features required by the specification:

- **Lazy start**: The MCP server is started on first ``call_tool()``
  invocation rather than requiring an explicit ``start()`` call.
- **Auto-stop**: Idle servers are stopped after a configurable timeout
  (default: 5 minutes).
- **Health monitoring**: Periodic health checks with automatic restart
  on server crash.

Usage::

    from cleveragents.mcp.client import McpClient, McpClientConfig

    client = McpClient(McpClientConfig(
        server=MCPServerConfig(name="gh", transport="stdio", command="gh-mcp"),
        idle_timeout_seconds=300,
        health_check_interval_seconds=30,
    ))

    # Server starts automatically on first call:
    result = client.call_tool("list_repos", {"org": "acme"})

    # Shutdown when done:
    client.shutdown()
"""

from __future__ import annotations

import logging
import threading
import time
from typing import TYPE_CHECKING, Any

from pydantic import BaseModel, ConfigDict, Field

from cleveragents.mcp.adapter import (
    MCPServerConfig,
    MCPToolAdapter,
    MCPToolFilter,
    MCPToolResult,
    MCPTransport,
)

if TYPE_CHECKING:
    pass

logger = logging.getLogger(__name__)

__all__ = [
    "McpClient",
    "McpClientConfig",
    "McpClientState",
]

# Default idle timeout: 5 minutes.
_DEFAULT_IDLE_TIMEOUT_SECONDS: float = 300.0

# Default health-check interval: 30 seconds.
_DEFAULT_HEALTH_CHECK_INTERVAL_SECONDS: float = 30.0


class McpClientConfig(BaseModel):
    """Configuration for an :class:`McpClient` instance.

    Attributes:
        server: MCP server connection configuration.
        idle_timeout_seconds: Seconds of inactivity before auto-stop.
        health_check_interval_seconds: Seconds between health probes.
        auto_restart: Whether to restart the server on health-check failure.
        lazy_start: Whether to defer connection until first tool call.
    """

    server: MCPServerConfig
    idle_timeout_seconds: float = Field(
        default=_DEFAULT_IDLE_TIMEOUT_SECONDS,
        ge=0,
        description="Idle timeout before auto-stop (0 = disabled)",
    )
    health_check_interval_seconds: float = Field(
        default=_DEFAULT_HEALTH_CHECK_INTERVAL_SECONDS,
        ge=0,
        description="Health-check interval (0 = disabled)",
    )
    auto_restart: bool = Field(
        default=True,
        description="Auto-restart on health-check failure",
    )
    lazy_start: bool = Field(
        default=True,
        description="Defer connection until first tool call",
    )

    model_config = ConfigDict(str_strip_whitespace=True)


class McpClientState:
    """Observable state of an :class:`McpClient`."""

    IDLE = "idle"
    STARTING = "starting"
    RUNNING = "running"
    STOPPING = "stopping"
    STOPPED = "stopped"
    ERROR = "error"


class McpClient:
    """High-level MCP client with lifecycle management.

    Wraps an :class:`MCPToolAdapter` and adds lazy start, idle auto-stop,
    and health monitoring with automatic restart.

    Parameters
    ----------
    config:
        Client configuration including server config and lifecycle params.
    transport:
        Optional transport override (for testing).
    """

    def __init__(
        self,
        config: McpClientConfig,
        transport: MCPTransport | None = None,
    ) -> None:
        self._config = config
        self._adapter = MCPToolAdapter(
            config=config.server,
            transport=transport,
        )
        self._lock = threading.RLock()
        self._state = McpClientState.IDLE
        self._last_activity: float = 0.0
        self._idle_timer: threading.Timer | None = None
        self._health_timer: threading.Timer | None = None
        self._health_check_failures: int = 0
        self._restart_count: int = 0
        self._started = False
        self._shutting_down = False
        self._transport_override = transport

    # -- Properties -----------------------------------------------------------

    @property
    def state(self) -> str:
        """Current lifecycle state."""
        with self._lock:
            return self._state

    @property
    def server_name(self) -> str:
        """Name of the underlying MCP server."""
        return self._config.server.name

    @property
    def is_connected(self) -> bool:
        """Whether the adapter is currently connected."""
        return self._adapter.is_connected

    @property
    def adapter(self) -> MCPToolAdapter:
        """Underlying MCP tool adapter."""
        return self._adapter

    @property
    def last_activity(self) -> float:
        """Monotonic timestamp of last tool invocation."""
        with self._lock:
            return self._last_activity

    @property
    def idle_seconds(self) -> float:
        """Seconds since last tool invocation."""
        with self._lock:
            if self._last_activity == 0.0:
                return 0.0
            return time.monotonic() - self._last_activity

    @property
    def health_check_failures(self) -> int:
        """Number of consecutive health-check failures."""
        with self._lock:
            return self._health_check_failures

    @property
    def restart_count(self) -> int:
        """Number of automatic restarts triggered by health monitoring."""
        with self._lock:
            return self._restart_count

    # -- Lifecycle API --------------------------------------------------------

    def start(self) -> None:
        """Explicitly start the MCP server connection.

        If ``lazy_start`` is enabled, this is called automatically on first
        ``call_tool()`` invocation.  Calling ``start()`` explicitly is
        always safe and idempotent.

        Raises
        ------
        ConnectionError
            If the MCP server cannot be reached.
        """
        with self._lock:
            if self._started:
                return
            self._state = McpClientState.STARTING

        try:
            self._adapter.connect()
            self._adapter.discover_tools()
        except Exception:
            with self._lock:
                self._state = McpClientState.ERROR
            raise

        with self._lock:
            self._started = True
            self._state = McpClientState.RUNNING
            self._last_activity = time.monotonic()
            self._health_check_failures = 0

        self._schedule_idle_timer()
        self._schedule_health_check()
        logger.info("McpClient '%s' started", self.server_name)

    def shutdown(self) -> None:
        """Stop the MCP server connection and cancel all timers.

        Safe to call multiple times and from any thread.
        """
        with self._lock:
            if self._shutting_down:
                return
            self._shutting_down = True
            self._state = McpClientState.STOPPING

        self._cancel_idle_timer()
        self._cancel_health_check()
        self._adapter.disconnect()

        with self._lock:
            self._started = False
            self._state = McpClientState.STOPPED
            self._shutting_down = False

        logger.info("McpClient '%s' shut down", self.server_name)

    def _ensure_started(self) -> None:
        """Ensure the client is started (lazy start support).

        If lazy_start is enabled and the client hasn't started yet,
        triggers ``start()``.

        Raises
        ------
        RuntimeError
            If the client is not started and lazy_start is disabled.
        """
        with self._lock:
            if self._started:
                return
            if not self._config.lazy_start:
                msg = (
                    f"McpClient '{self.server_name}' is not started "
                    f"and lazy_start is disabled"
                )
                raise RuntimeError(msg)

        self.start()

    # -- Tool Invocation ------------------------------------------------------

    def call_tool(
        self,
        tool_name: str,
        arguments: dict[str, Any],
    ) -> MCPToolResult:
        """Invoke an MCP tool, starting the server if needed.

        This is the primary entry point for tool invocation.  When
        ``lazy_start`` is enabled, the server connection is established
        on the first call.

        Parameters
        ----------
        tool_name:
            Name of the tool as discovered from the server.
        arguments:
            Tool input arguments.

        Returns
        -------
        MCPToolResult
            Invocation outcome.
        """
        self._ensure_started()
        self._touch_activity()
        result = self._adapter.invoke(tool_name, arguments)
        self._touch_activity()
        return result

    def discover_tools(
        self,
        tool_filter: MCPToolFilter | None = None,
    ) -> list[Any]:
        """Discover tools from the MCP server.

        Triggers lazy start if needed.
        """
        self._ensure_started()
        self._touch_activity()
        return self._adapter.discover_tools(tool_filter)

    # -- Idle Timeout ---------------------------------------------------------

    def _touch_activity(self) -> None:
        """Record current time as the last activity timestamp."""
        with self._lock:
            self._last_activity = time.monotonic()
        self._schedule_idle_timer()

    def _schedule_idle_timer(self) -> None:
        """Schedule the idle auto-stop timer."""
        self._cancel_idle_timer()
        timeout = self._config.idle_timeout_seconds
        if timeout <= 0:
            return

        with self._lock:
            timer = threading.Timer(timeout, self._check_idle)
            timer.daemon = True
            self._idle_timer = timer

        timer.start()

    def _cancel_idle_timer(self) -> None:
        """Cancel any pending idle timer."""
        with self._lock:
            if self._idle_timer is not None:
                self._idle_timer.cancel()
                self._idle_timer = None

    def _check_idle(self) -> None:
        """Check if the client has been idle beyond the timeout."""
        with self._lock:
            if self._shutting_down or not self._started:
                return
            elapsed = time.monotonic() - self._last_activity
            if elapsed < self._config.idle_timeout_seconds:
                # Activity happened while timer was pending; reschedule.
                remaining = self._config.idle_timeout_seconds - elapsed
                timer = threading.Timer(remaining, self._check_idle)
                timer.daemon = True
                self._idle_timer = timer
                timer.start()
                return

        logger.info(
            "McpClient '%s' idle for %.1fs — auto-stopping",
            self.server_name,
            elapsed,
        )
        self.shutdown()

    # -- Health Monitoring ----------------------------------------------------

    def _schedule_health_check(self) -> None:
        """Schedule the next health-check timer."""
        self._cancel_health_check()
        interval = self._config.health_check_interval_seconds
        if interval <= 0:
            return

        with self._lock:
            timer = threading.Timer(interval, self._perform_health_check)
            timer.daemon = True
            self._health_timer = timer

        timer.start()

    def _cancel_health_check(self) -> None:
        """Cancel any pending health-check timer."""
        with self._lock:
            if self._health_timer is not None:
                self._health_timer.cancel()
                self._health_timer = None

    def _perform_health_check(self) -> None:
        """Perform a health check by pinging the MCP server.

        If the check fails and ``auto_restart`` is enabled, the client
        is restarted.  After a successful restart, the failure counter
        resets.
        """
        with self._lock:
            if self._shutting_down or not self._started:
                return

        healthy = self._check_health()
        if healthy:
            with self._lock:
                self._health_check_failures = 0
            self._schedule_health_check()
            return

        with self._lock:
            self._health_check_failures += 1
            failures = self._health_check_failures

        logger.warning(
            "McpClient '%s' health check failed (%d consecutive)",
            self.server_name,
            failures,
        )

        if self._config.auto_restart:
            self._do_restart()
        else:
            self._schedule_health_check()

    def _check_health(self) -> bool:
        """Probe the MCP server by attempting a tools/list call.

        Returns ``True`` if the server responds, ``False`` otherwise.
        """
        try:
            self._adapter.discover_tools()
            return True
        except Exception:
            logger.debug(
                "Health check probe failed for '%s'",
                self.server_name,
                exc_info=True,
            )
            return False

    def _do_restart(self) -> None:
        """Restart the MCP server connection."""
        logger.info(
            "McpClient '%s' restarting after health-check failure", self.server_name
        )
        try:
            self._adapter.reconnect()
            self._adapter.discover_tools()
        except Exception:
            logger.error(
                "McpClient '%s' restart failed",
                self.server_name,
                exc_info=True,
            )
            with self._lock:
                self._state = McpClientState.ERROR
            self._schedule_health_check()
            return

        with self._lock:
            self._restart_count += 1
            self._health_check_failures = 0
            self._state = McpClientState.RUNNING

        self._schedule_health_check()
        logger.info("McpClient '%s' restarted successfully", self.server_name)
