from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any

from ..logger import logger
from .server import MCPServer


@dataclass
class _ServerCommand:
    action: str
    timeout_seconds: float | None
    future: asyncio.Future[None]


class _ServerWorker:
    def __init__(
        self,
        server: MCPServer,
        connect_timeout_seconds: float | None,
        cleanup_timeout_seconds: float | None,
    ) -> None:
        self._server = server
        self._connect_timeout_seconds = connect_timeout_seconds
        self._cleanup_timeout_seconds = cleanup_timeout_seconds
        self._queue: asyncio.Queue[_ServerCommand] = asyncio.Queue()
        self._task = asyncio.create_task(self._run())

    @property
    def is_done(self) -> bool:
        return self._task.done()

    async def connect(self) -> None:
        await self._submit("connect", self._connect_timeout_seconds)

    async def cleanup(self) -> None:
        await self._submit("cleanup", self._cleanup_timeout_seconds)

    async def _submit(self, action: str, timeout_seconds: float | None) -> None:
        loop = asyncio.get_running_loop()
        future: asyncio.Future[None] = loop.create_future()
        await self._queue.put(
            _ServerCommand(action=action, timeout_seconds=timeout_seconds, future=future)
        )
        await future

    async def _run(self) -> None:
        while True:
            command = await self._queue.get()
            should_exit = command.action == "cleanup"
            try:
                if command.action == "connect":
                    await _run_with_timeout_in_task(self._server.connect, command.timeout_seconds)
                elif command.action == "cleanup":
                    await _run_with_timeout_in_task(self._server.cleanup, command.timeout_seconds)
                else:
                    raise ValueError(f"Unknown command: {command.action}")
                if not command.future.cancelled():
                    command.future.set_result(None)
            except BaseException as exc:
                if not command.future.cancelled():
                    command.future.set_exception(exc)
            if should_exit:
                return


async def _run_with_timeout_in_task(
    func: Callable[[], Awaitable[Any]], timeout_seconds: float | None
) -> None:
    # Use an in-task timeout to preserve task affinity for MCP cleanup.
    # asyncio.wait_for creates a new Task on Python < 3.11, which breaks
    # libraries that require connect/cleanup in the same task (e.g. AnyIO cancel scopes).
    if timeout_seconds is None:
        await func()
        return
    timeout_context = getattr(asyncio, "timeout", None)
    if timeout_context is not None:
        async with timeout_context(timeout_seconds):
            await func()
        return
    task = asyncio.current_task()
    if task is None:
        await asyncio.wait_for(func(), timeout=timeout_seconds)
        return
    timed_out = False
    loop = asyncio.get_running_loop()

    def _cancel() -> None:
        nonlocal timed_out
        timed_out = True
        task.cancel()

    handle = loop.call_later(timeout_seconds, _cancel)
    try:
        await func()
    except asyncio.CancelledError as exc:
        if timed_out:
            raise asyncio.TimeoutError() from exc
        raise
    finally:
        handle.cancel()


class MCPServerManager(AbstractAsyncContextManager["MCPServerManager"]):
    """Manage MCP server lifecycles and expose only connected servers.

    Use this helper to keep MCP connect/cleanup on the same task and avoid
    run failures when a server is unavailable. The manager will attempt to
    connect each server and then expose the connected subset via
    `active_servers`.

    Basic usage:
        async with MCPServerManager([server_a, server_b]) as manager:
            agent = Agent(
                name="Assistant",
                instructions="...",
                mcp_servers=manager.active_servers,
            )

    FastAPI lifespan example:
        @asynccontextmanager
        async def lifespan(app: FastAPI):
            async with MCPServerManager([server_a, server_b]) as manager:
                app.state.mcp_manager = manager
                yield

        app = FastAPI(lifespan=lifespan)

    Important behaviors:
    - `active_servers` only includes servers that connected successfully.
      `failed_servers` holds the failures and `errors` maps servers to errors.
    - `drop_failed_servers=True` removes failed servers from `active_servers`
      (recommended). If False, `active_servers` will still include all servers.
    - `strict=True` raises on the first connection failure. If False, failures
      are recorded and the run can proceed with the remaining servers.
    - `reconnect(failed_only=True)` retries failed servers and refreshes
      `active_servers`.
    - `connect_in_parallel=True` uses a dedicated worker task per server to
      allow concurrent connects while preserving task affinity for cleanup.
    """

    def __init__(
        self,
        servers: Iterable[MCPServer],
        *,
        connect_timeout_seconds: float | None = 10.0,
        cleanup_timeout_seconds: float | None = 10.0,
        drop_failed_servers: bool = True,
        strict: bool = False,
        suppress_cancelled_error: bool = True,
        connect_in_parallel: bool = False,
    ) -> None:
        self._all_servers = list(servers)
        self._active_servers = list(servers)
        self.connect_timeout_seconds = connect_timeout_seconds
        self.cleanup_timeout_seconds = cleanup_timeout_seconds
        self.drop_failed_servers = drop_failed_servers
        self.strict = strict
        self.suppress_cancelled_error = suppress_cancelled_error
        self.connect_in_parallel = connect_in_parallel
        self._workers: dict[MCPServer, _ServerWorker] = {}

        self.failed_servers: list[MCPServer] = []
        self._failed_server_set: set[MCPServer] = set()
        self._connected_servers: set[MCPServer] = set()
        self.errors: dict[MCPServer, BaseException] = {}

    @property
    def active_servers(self) -> list[MCPServer]:
        """Return the active MCP servers after connection attempts."""
        return list(self._active_servers)

    @property
    def all_servers(self) -> list[MCPServer]:
        """Return all MCP servers managed by this instance."""
        return list(self._all_servers)

    async def __aenter__(self) -> MCPServerManager:
        await self.connect_all()
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool | None:
        await self.cleanup_all()
        return None

    async def connect_all(self) -> list[MCPServer]:
        """Connect all servers in order and return the active list."""
        previous_connected_servers = set(self._connected_servers)
        previous_active_servers = list(self._active_servers)
        self.failed_servers = []
        self._failed_server_set = set()
        self.errors = {}

        servers_to_connect = self._servers_to_connect(self._all_servers)
        connected_servers: list[MCPServer] = []
        try:
            if self.connect_in_parallel:
                await self._connect_all_parallel(servers_to_connect)
            else:
                for server in servers_to_connect:
                    await self._attempt_connect(server)
                    if server not in self._failed_server_set:
                        connected_servers.append(server)
        except BaseException:
            if self.connect_in_parallel:
                await self._cleanup_servers(servers_to_connect)
            else:
                servers_to_cleanup = self._unique_servers(
                    [*connected_servers, *self.failed_servers]
                )
                await self._cleanup_servers(servers_to_cleanup)
            if self.drop_failed_servers:
                self._active_servers = [
                    server for server in self._all_servers if server in previous_connected_servers
                ]
            else:
                self._active_servers = previous_active_servers
            raise

        self._refresh_active_servers()

        return self._active_servers

    async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]:
        """Reconnect servers and return the active list.

        Args:
            failed_only: If True, only retry servers that previously failed.
                If False, cleanup and retry all servers.
        """
        if failed_only:
            servers_to_retry = self._unique_servers(self.failed_servers)
        else:
            await self.cleanup_all()
            servers_to_retry = list(self._all_servers)
            self.failed_servers = []
            self._failed_server_set = set()
            self.errors = {}

        servers_to_retry = self._servers_to_connect(servers_to_retry)
        try:
            if self.connect_in_parallel:
                await self._connect_all_parallel(servers_to_retry)
            else:
                for server in servers_to_retry:
                    await self._attempt_connect(server)
        finally:
            self._refresh_active_servers()
        return self._active_servers

    async def cleanup_all(self) -> None:
        """Cleanup all servers in reverse order."""
        for server in reversed(self._all_servers):
            try:
                await self._cleanup_server(server)
            except asyncio.CancelledError as exc:
                if not self.suppress_cancelled_error:
                    raise
                logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc)
                self.errors[server] = exc
            except Exception as exc:
                logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc)
                self.errors[server] = exc

    async def _run_with_timeout(
        self, func: Callable[[], Awaitable[Any]], timeout_seconds: float | None
    ) -> None:
        await _run_with_timeout_in_task(func, timeout_seconds)

    async def _attempt_connect(
        self, server: MCPServer, *, raise_on_error: bool | None = None
    ) -> None:
        if raise_on_error is None:
            raise_on_error = self.strict
        try:
            await self._run_connect(server)
            self._connected_servers.add(server)
            if server in self.failed_servers:
                self._remove_failed_server(server)
                self.errors.pop(server, None)
        except asyncio.CancelledError as exc:
            if not self.suppress_cancelled_error:
                raise
            self._record_failure(server, exc, phase="connect")
        except Exception as exc:
            self._record_failure(server, exc, phase="connect")
            if raise_on_error:
                raise
        except BaseException as exc:
            self._record_failure(server, exc, phase="connect")
            raise

    def _refresh_active_servers(self) -> None:
        if self.drop_failed_servers:
            failed = set(self._failed_server_set)
            self._active_servers = [server for server in self._all_servers if server not in failed]
        else:
            self._active_servers = list(self._all_servers)

    def _record_failure(self, server: MCPServer, exc: BaseException, phase: str) -> None:
        logger.exception("Failed to %s MCP server '%s': %s", phase, server.name, exc)
        if server not in self._failed_server_set:
            self.failed_servers.append(server)
            self._failed_server_set.add(server)
        self.errors[server] = exc

    async def _run_connect(self, server: MCPServer) -> None:
        if self.connect_in_parallel:
            worker = self._get_worker(server)
            await worker.connect()
        else:
            await self._run_with_timeout(server.connect, self.connect_timeout_seconds)

    async def _cleanup_server(self, server: MCPServer) -> None:
        if self.connect_in_parallel and server in self._workers:
            worker = self._workers[server]
            if worker.is_done:
                self._workers.pop(server, None)
                self._connected_servers.discard(server)
                return
            try:
                await worker.cleanup()
            finally:
                self._workers.pop(server, None)
                self._connected_servers.discard(server)
            return
        try:
            await self._run_with_timeout(server.cleanup, self.cleanup_timeout_seconds)
        finally:
            self._connected_servers.discard(server)

    async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> None:
        for server in reversed(list(servers)):
            try:
                await self._cleanup_server(server)
            except asyncio.CancelledError as exc:
                if not self.suppress_cancelled_error:
                    raise
                logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc)
                self.errors[server] = exc
            except Exception as exc:
                logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc)
                self.errors[server] = exc

    async def _connect_all_parallel(self, servers: list[MCPServer]) -> None:
        tasks = [
            asyncio.create_task(self._attempt_connect(server, raise_on_error=False))
            for server in servers
        ]
        results = await asyncio.gather(*tasks, return_exceptions=True)
        if not self.suppress_cancelled_error:
            for result in results:
                if isinstance(result, asyncio.CancelledError):
                    raise result
        for result in results:
            if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
                raise result
        if self.strict and self.failed_servers:
            first_failure = None
            if self.suppress_cancelled_error:
                for server in self.failed_servers:
                    error = self.errors.get(server)
                    if error is None or isinstance(error, asyncio.CancelledError):
                        continue
                    first_failure = server
                    break
            else:
                first_failure = self.failed_servers[0]
            if first_failure is not None:
                error = self.errors.get(first_failure)
                if error is not None:
                    raise error
                raise RuntimeError(f"Failed to connect MCP server '{first_failure.name}'")

    def _get_worker(self, server: MCPServer) -> _ServerWorker:
        worker = self._workers.get(server)
        if worker is None or worker.is_done:
            worker = _ServerWorker(
                server=server,
                connect_timeout_seconds=self.connect_timeout_seconds,
                cleanup_timeout_seconds=self.cleanup_timeout_seconds,
            )
            self._workers[server] = worker
        return worker

    def _remove_failed_server(self, server: MCPServer) -> None:
        if server in self._failed_server_set:
            self._failed_server_set.remove(server)
        self.failed_servers = [
            failed_server for failed_server in self.failed_servers if failed_server != server
        ]

    def _servers_to_connect(self, servers: Iterable[MCPServer]) -> list[MCPServer]:
        unique = self._unique_servers(servers)
        if not self._connected_servers:
            return unique
        return [server for server in unique if server not in self._connected_servers]

    @staticmethod
    def _unique_servers(servers: Iterable[MCPServer]) -> list[MCPServer]:
        seen: set[MCPServer] = set()
        unique: list[MCPServer] = []
        for server in servers:
            if server not in seen:
                seen.add(server)
                unique.append(server)
        return unique
