import asyncio
import contextlib
import os
import shutil
import sys
from collections.abc import AsyncIterator
from pathlib import Path
from typing import TextIO, cast

import anyio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from typing_extensions import Unpack

from fastmcp.client.transports.base import ClientTransport, SessionKwargs
from fastmcp.utilities.logging import get_logger

logger = get_logger(__name__)


class StdioTransport(ClientTransport):
    """
    Base transport for connecting to an MCP server via subprocess with stdio.

    This is a base class that can be subclassed for specific command-based
    transports like Python, Node, Uvx, etc.
    """

    def __init__(
        self,
        command: str,
        args: list[str],
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        keep_alive: bool | None = None,
        log_file: Path | TextIO | None = None,
    ):
        """
        Initialize a Stdio transport.

        Args:
            command: The command to run (e.g., "python", "node", "uvx")
            args: The arguments to pass to the command
            env: Environment variables to set for the subprocess
            cwd: Current working directory for the subprocess
            keep_alive: Whether to keep the subprocess alive between connections.
                       Defaults to True. When True, the subprocess remains active
                       after the connection context exits, allowing reuse in
                       subsequent connections.
            log_file: Optional path or file-like object where subprocess stderr will
                   be written. Can be a Path or TextIO object. Defaults to sys.stderr
                   if not provided. When a Path is provided, the file will be created
                   if it doesn't exist, or appended to if it does. When set, server
                   errors will be written to this file instead of appearing in the console.
        """
        self.command = command
        self.args = args
        self.env = env
        self.cwd = cwd
        if keep_alive is None:
            keep_alive = True
        self.keep_alive = keep_alive
        self.log_file = log_file

        self._session: ClientSession | None = None
        self._connect_task: asyncio.Task | None = None
        self._ready_event = anyio.Event()
        self._stop_event = anyio.Event()

    @contextlib.asynccontextmanager
    async def connect_session(
        self, **session_kwargs: Unpack[SessionKwargs]
    ) -> AsyncIterator[ClientSession]:
        try:
            await self.connect(**session_kwargs)
            yield cast(ClientSession, self._session)
        finally:
            if not self.keep_alive:
                await self.disconnect()
            else:
                logger.debug("Stdio transport has keep_alive=True, not disconnecting")

    async def connect(
        self, **session_kwargs: Unpack[SessionKwargs]
    ) -> ClientSession | None:
        # If the connect task completed or the session's streams are dead,
        # the subprocess has exited. Tear down so we can start fresh.
        if self._connect_task is not None and (
            self._connect_task.done() or self._is_session_dead()
        ):
            await self.disconnect()

        if self._connect_task is not None:
            return

        session_future: asyncio.Future[ClientSession] = asyncio.Future()

        # start the connection task
        self._connect_task = asyncio.create_task(
            _stdio_transport_connect_task(
                command=self.command,
                args=self.args,
                env=self.env,
                cwd=self.cwd,
                log_file=self.log_file,
                # TODO(ty): remove when ty supports Unpack[TypedDict] inference
                session_kwargs=session_kwargs,  # type: ignore[arg-type]  # ty:ignore[invalid-argument-type]
                ready_event=self._ready_event,
                stop_event=self._stop_event,
                session_future=session_future,
            )
        )

        # wait for the client to be ready before returning
        await self._ready_event.wait()

        # Check if connect task completed with an exception (early failure)
        if self._connect_task.done():
            exception = self._connect_task.exception()
            if exception is not None:
                raise exception

        self._session = await session_future
        return self._session

    async def disconnect(self):
        if self._connect_task is None:
            return

        # signal the connection task to stop
        self._stop_event.set()

        # wait for the connection task to finish cleanly
        with contextlib.suppress(Exception):
            await self._connect_task

        # reset variables and events for potential future reconnects
        self._connect_task = None
        self._session = None
        self._stop_event = anyio.Event()
        self._ready_event = anyio.Event()

    def _is_session_dead(self) -> bool:
        """Check if the session's underlying streams have been closed.

        Checks both the write stream (stdin to subprocess) and the read
        stream (stdout from subprocess).  On some platforms the write-side
        pipe lingers after the process exits, so the read-side check
        (which reflects stdout_reader detecting the dead process) is the
        more reliable signal.
        """
        if self._session is None:
            return False
        try:
            if self._session._write_stream.statistics().open_send_streams == 0:
                return True
            return self._session._read_stream.statistics().open_send_streams == 0
        except AttributeError:
            return False

    async def close(self):
        await self.disconnect()

    def __del__(self):
        """Ensure that we send a disconnection signal to the transport task if we are being garbage collected."""
        if not self._stop_event.is_set():
            self._stop_event.set()

    def __repr__(self) -> str:
        return (
            f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>"
        )


async def _stdio_transport_connect_task(
    command: str,
    args: list[str],
    env: dict[str, str] | None,
    cwd: str | None,
    log_file: Path | TextIO | None,
    session_kwargs: SessionKwargs,
    ready_event: anyio.Event,
    stop_event: anyio.Event,
    session_future: asyncio.Future[ClientSession],
):
    """A standalone connection task for a stdio transport. It is not a part of the StdioTransport class
    to ensure that the connection task does not hold a reference to the Transport object."""

    try:
        async with contextlib.AsyncExitStack() as stack:
            try:
                server_params = StdioServerParameters(
                    command=command,
                    args=args,
                    env=env,
                    cwd=cwd,
                )
                # Handle log_file: Path needs to be opened, TextIO used as-is
                if log_file is None:
                    log_file_handle = sys.stderr
                elif isinstance(log_file, Path):
                    log_file_handle = stack.enter_context(log_file.open("a"))
                else:
                    # Must be TextIO - use it directly
                    log_file_handle = log_file

                transport = await stack.enter_async_context(
                    stdio_client(server_params, errlog=log_file_handle)
                )
                read_stream, write_stream = transport
                session_future.set_result(
                    await stack.enter_async_context(
                        ClientSession(read_stream, write_stream, **session_kwargs)
                    )
                )

                logger.debug("Stdio transport connected")
                ready_event.set()

                # Wait until disconnect is requested (stop_event is set)
                await stop_event.wait()
            finally:
                # Clean up client on exit
                logger.debug("Stdio transport disconnected")
    except Exception:
        # Ensure ready event is set even if connection fails
        ready_event.set()
        raise


class PythonStdioTransport(StdioTransport):
    """Transport for running Python scripts."""

    def __init__(
        self,
        script_path: str | Path,
        args: list[str] | None = None,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        python_cmd: str = sys.executable,
        keep_alive: bool | None = None,
        log_file: Path | TextIO | None = None,
    ):
        """
        Initialize a Python transport.

        Args:
            script_path: Path to the Python script to run
            args: Additional arguments to pass to the script
            env: Environment variables to set for the subprocess
            cwd: Current working directory for the subprocess
            python_cmd: Python command to use (default: "python")
            keep_alive: Whether to keep the subprocess alive between connections.
                       Defaults to True. When True, the subprocess remains active
                       after the connection context exits, allowing reuse in
                       subsequent connections.
            log_file: Optional path or file-like object where subprocess stderr will
                   be written. Can be a Path or TextIO object. Defaults to sys.stderr
                   if not provided. When a Path is provided, the file will be created
                   if it doesn't exist, or appended to if it does. When set, server
                   errors will be written to this file instead of appearing in the console.
        """
        script_path = Path(script_path).resolve()
        if not script_path.is_file():
            raise FileNotFoundError(f"Script not found: {script_path}")
        if not str(script_path).endswith(".py"):
            raise ValueError(f"Not a Python script: {script_path}")

        full_args = [str(script_path)]
        if args:
            full_args.extend(args)

        super().__init__(
            command=python_cmd,
            args=full_args,
            env=env,
            cwd=cwd,
            keep_alive=keep_alive,
            log_file=log_file,
        )
        self.script_path = script_path


class FastMCPStdioTransport(StdioTransport):
    """Transport for running FastMCP servers using the FastMCP CLI."""

    def __init__(
        self,
        script_path: str | Path,
        args: list[str] | None = None,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        keep_alive: bool | None = None,
        log_file: Path | TextIO | None = None,
    ):
        script_path = Path(script_path).resolve()
        if not script_path.is_file():
            raise FileNotFoundError(f"Script not found: {script_path}")
        if not str(script_path).endswith(".py"):
            raise ValueError(f"Not a Python script: {script_path}")

        super().__init__(
            command="fastmcp",
            args=["run", str(script_path)],
            env=env,
            cwd=cwd,
            keep_alive=keep_alive,
            log_file=log_file,
        )
        self.script_path = script_path


class NodeStdioTransport(StdioTransport):
    """Transport for running Node.js scripts."""

    def __init__(
        self,
        script_path: str | Path,
        args: list[str] | None = None,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        node_cmd: str = "node",
        keep_alive: bool | None = None,
        log_file: Path | TextIO | None = None,
    ):
        """
        Initialize a Node transport.

        Args:
            script_path: Path to the Node.js script to run
            args: Additional arguments to pass to the script
            env: Environment variables to set for the subprocess
            cwd: Current working directory for the subprocess
            node_cmd: Node.js command to use (default: "node")
            keep_alive: Whether to keep the subprocess alive between connections.
                       Defaults to True. When True, the subprocess remains active
                       after the connection context exits, allowing reuse in
                       subsequent connections.
            log_file: Optional path or file-like object where subprocess stderr will
                   be written. Can be a Path or TextIO object. Defaults to sys.stderr
                   if not provided. When a Path is provided, the file will be created
                   if it doesn't exist, or appended to if it does. When set, server
                   errors will be written to this file instead of appearing in the console.
        """
        script_path = Path(script_path).resolve()
        if not script_path.is_file():
            raise FileNotFoundError(f"Script not found: {script_path}")
        if not str(script_path).endswith(".js"):
            raise ValueError(f"Not a JavaScript script: {script_path}")

        full_args = [str(script_path)]
        if args:
            full_args.extend(args)

        super().__init__(
            command=node_cmd,
            args=full_args,
            env=env,
            cwd=cwd,
            keep_alive=keep_alive,
            log_file=log_file,
        )
        self.script_path = script_path


class UvStdioTransport(StdioTransport):
    """Transport for running commands via the uv tool."""

    def __init__(
        self,
        command: str,
        args: list[str] | None = None,
        module: bool = False,
        project_directory: Path | None = None,
        python_version: str | None = None,
        with_packages: list[str] | None = None,
        with_requirements: Path | None = None,
        env_vars: dict[str, str] | None = None,
        keep_alive: bool | None = None,
    ):
        # Basic validation
        if project_directory and not project_directory.exists():
            raise NotADirectoryError(
                f"Project directory not found: {project_directory}"
            )

        # Build uv arguments using the config
        uv_args: list[str] = []

        # Check if we need any environment setup
        if any(
            [
                python_version,
                with_packages,
                with_requirements,
                project_directory,
            ]
        ):
            # Use the config to build args, but we need to handle the command differently
            # since transport has specific needs
            uv_args = ["run"]

            if python_version:
                uv_args.extend(["--python", python_version])
            if project_directory:
                uv_args.extend(["--directory", str(project_directory)])

            # Note: Don't add fastmcp as dependency here, transport is for general use
            for pkg in with_packages or []:
                uv_args.extend(["--with", pkg])
            if with_requirements:
                uv_args.extend(["--with-requirements", str(with_requirements)])
        else:
            # No environment setup needed
            uv_args = ["run"]

        if module:
            uv_args.append("--module")

        if not args:
            args = []

        uv_args.extend([command, *args])

        # Get environment with any additional variables
        env: dict[str, str] | None = None
        if env_vars or project_directory:
            env = os.environ.copy()
            if project_directory:
                env["UV_PROJECT_DIR"] = str(project_directory)
            if env_vars:
                env.update(env_vars)

        super().__init__(
            command="uv",
            args=uv_args,
            env=env,
            cwd=None,  # Use --directory flag instead of cwd
            keep_alive=keep_alive,
        )


class UvxStdioTransport(StdioTransport):
    """Transport for running commands via the uvx tool."""

    def __init__(
        self,
        tool_name: str,
        tool_args: list[str] | None = None,
        project_directory: str | None = None,
        python_version: str | None = None,
        with_packages: list[str] | None = None,
        from_package: str | None = None,
        env_vars: dict[str, str] | None = None,
        keep_alive: bool | None = None,
    ):
        """
        Initialize a Uvx transport.

        Args:
            tool_name: Name of the tool to run via uvx
            tool_args: Arguments to pass to the tool
            project_directory: Project directory (for package resolution)
            python_version: Python version to use
            with_packages: Additional packages to include
            from_package: Package to install the tool from
            env_vars: Additional environment variables
            keep_alive: Whether to keep the subprocess alive between connections.
                       Defaults to True. When True, the subprocess remains active
                       after the connection context exits, allowing reuse in
                       subsequent connections.
        """
        # Basic validation
        if project_directory and not Path(project_directory).exists():
            raise NotADirectoryError(
                f"Project directory not found: {project_directory}"
            )

        # Build uvx arguments
        uvx_args: list[str] = []
        if python_version:
            uvx_args.extend(["--python", python_version])
        if from_package:
            uvx_args.extend(["--from", from_package])
        for pkg in with_packages or []:
            uvx_args.extend(["--with", pkg])

        # Add the tool name and tool args
        uvx_args.append(tool_name)
        if tool_args:
            uvx_args.extend(tool_args)

        env: dict[str, str] | None = None
        if env_vars:
            env = os.environ.copy()
            env.update(env_vars)

        super().__init__(
            command="uvx",
            args=uvx_args,
            env=env,
            cwd=project_directory,
            keep_alive=keep_alive,
        )
        self.tool_name: str = tool_name


class NpxStdioTransport(StdioTransport):
    """Transport for running commands via the npx tool."""

    def __init__(
        self,
        package: str,
        args: list[str] | None = None,
        project_directory: str | None = None,
        env_vars: dict[str, str] | None = None,
        use_package_lock: bool = True,
        keep_alive: bool | None = None,
    ):
        """
        Initialize an Npx transport.

        Args:
            package: Name of the npm package to run
            args: Arguments to pass to the package command
            project_directory: Project directory with package.json
            env_vars: Additional environment variables
            use_package_lock: Whether to use package-lock.json (--prefer-offline)
            keep_alive: Whether to keep the subprocess alive between connections.
                       Defaults to True. When True, the subprocess remains active
                       after the connection context exits, allowing reuse in
                       subsequent connections.
        """
        # verify npx is installed
        if shutil.which("npx") is None:
            raise ValueError("Command 'npx' not found")

        # Basic validation
        if project_directory and not Path(project_directory).exists():
            raise NotADirectoryError(
                f"Project directory not found: {project_directory}"
            )

        # Build npx arguments
        npx_args = []
        if use_package_lock:
            npx_args.append("--prefer-offline")

        # Add the package name and args
        npx_args.append(package)
        if args:
            npx_args.extend(args)

        # Get environment with any additional variables
        env = None
        if env_vars:
            env = os.environ.copy()
            env.update(env_vars)

        super().__init__(
            command="npx",
            args=npx_args,
            env=env,
            cwd=project_directory,
            keep_alive=keep_alive,
        )
        self.package = package
