"""SEP-1686 client Task classes."""

from __future__ import annotations

import abc
import asyncio
import inspect
import time
import weakref
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Generic, TypeVar

import mcp.types
from mcp.types import GetTaskResult, TaskStatusNotification

from fastmcp.client.messages import Message, MessageHandler
from fastmcp.exceptions import ToolError
from fastmcp.utilities.logging import get_logger

logger = get_logger(__name__)

if TYPE_CHECKING:
    from fastmcp.client.client import CallToolResult, Client


class TaskNotificationHandler(MessageHandler):
    """MessageHandler that routes task status notifications to Task objects."""

    def __init__(self, client: Client):
        super().__init__()
        self._client_ref: weakref.ref[Client] = weakref.ref(client)

    async def dispatch(self, message: Message) -> None:
        """Dispatch messages, including task status notifications."""
        if isinstance(message, mcp.types.ServerNotification):
            if isinstance(message.root, TaskStatusNotification):
                client = self._client_ref()
                if client:
                    client._handle_task_status_notification(message.root)

        await super().dispatch(message)


TaskResultT = TypeVar("TaskResultT")


class Task(abc.ABC, Generic[TaskResultT]):
    """
    Abstract base class for MCP background tasks (SEP-1686).

    Provides a uniform API whether the server accepts background execution
    or executes synchronously (graceful degradation per SEP-1686).

    Subclasses:
        - ToolTask: For tool calls (result type: CallToolResult)
        - PromptTask: For prompts (future, result type: GetPromptResult)
        - ResourceTask: For resources (future, result type: ReadResourceResult)
    """

    def __init__(
        self,
        client: Client,
        task_id: str,
        immediate_result: TaskResultT | None = None,
    ):
        """
        Create a Task wrapper.

        Args:
            client: The FastMCP client
            task_id: The task identifier
            immediate_result: If server executed synchronously, the immediate result
        """
        self._client = client
        self._task_id = task_id
        self._immediate_result = immediate_result
        self._is_immediate = immediate_result is not None

        # Notification-based optimization (SEP-1686 notifications/tasks/status)
        self._status_cache: GetTaskResult | None = None
        self._status_event: asyncio.Event | None = None  # Lazy init
        self._status_callbacks: list[
            Callable[[GetTaskResult], None | Awaitable[None]]
        ] = []
        self._cached_result: TaskResultT | None = None

    def _check_client_connected(self) -> None:
        """Validate that client context is still active.

        Raises:
            RuntimeError: If accessed outside client context (unless immediate)
        """
        if self._is_immediate:
            return  # Already resolved, no client needed

        try:
            _ = self._client.session
        except RuntimeError as e:
            raise RuntimeError(
                "Cannot access task results outside client context. "
                "Task futures must be used within 'async with client:' block."
            ) from e

    @property
    def task_id(self) -> str:
        """Get the task ID."""
        return self._task_id

    @property
    def returned_immediately(self) -> bool:
        """Check if server executed the task immediately.

        Returns:
            True if server executed synchronously (graceful degradation or no task support)
            False if server accepted background execution
        """
        return self._is_immediate

    def _handle_status_notification(self, status: GetTaskResult) -> None:
        """Process incoming notifications/tasks/status (internal).

        Called by Client when a notification is received for this task.
        Updates cache, triggers events, and invokes user callbacks.

        Args:
            status: Task status from notification
        """
        # Update cache for next status() call
        self._status_cache = status

        # Wake up any wait() calls
        if self._status_event is not None:
            self._status_event.set()

        # Invoke user callbacks
        for callback in self._status_callbacks:
            try:
                result = callback(status)
                if inspect.isawaitable(result):
                    # Fire and forget async callbacks
                    asyncio.create_task(result)  # type: ignore[arg-type] # noqa: RUF006  # ty:ignore[invalid-argument-type]
            except Exception as e:
                logger.warning(f"Task callback error: {e}", exc_info=True)

    def on_status_change(
        self,
        callback: Callable[[GetTaskResult], None | Awaitable[None]],
    ) -> None:
        """Register callback for status change notifications.

        The callback will be invoked when a notifications/tasks/status is received
        for this task (optional server feature per SEP-1686 lines 436-444).

        Supports both sync and async callbacks (auto-detected).

        Args:
            callback: Function to call with GetTaskResult when status changes.
                     Can return None (sync) or Awaitable[None] (async).

        Example:
            >>> task = await client.call_tool("slow_operation", {}, task=True)
            >>>
            >>> def on_update(status: GetTaskResult):
            ...     print(f"Task {status.taskId} is now {status.status}")
            >>>
            >>> task.on_status_change(on_update)
            >>> result = await task  # Callback fires when status changes
        """
        self._status_callbacks.append(callback)

    async def status(self) -> GetTaskResult:
        """Get current task status.

        If server executed immediately, returns synthetic completed status.
        Otherwise queries the server for current status.
        """
        self._check_client_connected()

        if self._is_immediate:
            # Return synthetic completed status
            now = datetime.now(timezone.utc)
            return GetTaskResult(
                taskId=self._task_id,
                status="completed",
                createdAt=now,
                lastUpdatedAt=now,
                ttl=None,
                pollInterval=1000,
            )

        # Return cached status if available (from notification)
        if self._status_cache is not None:
            cached = self._status_cache
            # Don't clear cache - keep it for next call
            return cached

        # Query server and cache the result
        self._status_cache = await self._client.get_task_status(self._task_id)
        return self._status_cache

    @abc.abstractmethod
    async def result(self) -> TaskResultT:
        """Wait for and return the task result.

        Must be implemented by subclasses to return the appropriate result type.
        """
        ...

    async def wait(
        self, *, state: str | None = None, timeout: float = 300.0
    ) -> GetTaskResult:
        """Wait for task to reach a specific state or complete.

        Uses event-based waiting when notifications are available (fast),
        with fallback to polling (reliable). Optimally wakes up immediately
        on status changes when server sends notifications/tasks/status.

        Args:
            state: Desired state ('working', 'input_required', 'completed', 'failed', 'cancelled').
                   If None, waits until the task exits the 'working' state (completed, failed, cancelled, input_required, etc.)
            timeout: Maximum time to wait in seconds

        Returns:
            GetTaskResult: Final task status

        Raises:
            TimeoutError: If desired state not reached within timeout
        """
        self._check_client_connected()

        if self._is_immediate:
            # Already done
            return await self.status()

        # Initialize event for notification wake-ups
        if self._status_event is None:
            self._status_event = asyncio.Event()

        start = time.time()
        in_progress_states = {"working"}
        poll_interval = 0.5  # Fallback polling interval (500ms)

        while True:
            # Check cached status first (updated by notifications)
            if self._status_cache:
                current = self._status_cache.status
                if state is None:
                    if current not in in_progress_states:
                        return self._status_cache
                elif current == state:
                    return self._status_cache

            # Check timeout
            elapsed = time.time() - start
            if elapsed >= timeout:
                raise TimeoutError(
                    f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s"
                )

            remaining = timeout - elapsed

            # Wait for notification event OR poll timeout
            try:
                await asyncio.wait_for(
                    self._status_event.wait(), timeout=min(poll_interval, remaining)
                )
                self._status_event.clear()
            except asyncio.TimeoutError:
                # Fallback: poll server (notification didn't arrive in time)
                self._status_cache = await self._client.get_task_status(self._task_id)

    async def _wait_terminal(self, timeout: float = 300.0) -> GetTaskResult:
        """Wait until task reaches a terminal state (completed, failed, cancelled).

        Unlike wait(), this will not return on input_required — it continues
        waiting until the task fully resolves. Used internally by result().
        """
        terminal_states = {"completed", "failed", "cancelled"}
        status = await self.wait(timeout=timeout)
        while status.status not in terminal_states:
            # Task is in a non-terminal state (e.g. input_required) — reset
            # cache so the next wait() call blocks instead of returning immediately.
            self._status_cache = None
            status = await self.wait(timeout=timeout)
        return status

    async def cancel(self) -> None:
        """Cancel this task, transitioning it to cancelled state.

        Sends a tasks/cancel protocol request. The server will attempt to halt
        execution and move the task to cancelled state.

        Note: If server executed immediately (graceful degradation), this is a no-op
        as there's no server-side task to cancel.
        """
        if self._is_immediate:
            # No server-side task to cancel
            return
        self._check_client_connected()
        await self._client.cancel_task(self._task_id)
        # Invalidate cache to force fresh status fetch
        self._status_cache = None

    def __await__(self):
        """Allow 'await task' to get result."""
        return self.result().__await__()


class ToolTask(Task["CallToolResult"]):
    """
    Represents a tool call that may execute in background or immediately.

    Provides a uniform API whether the server accepts background execution
    or executes synchronously (graceful degradation per SEP-1686).

    Usage:
        task = await client.call_tool_as_task("analyze", args)

        # Check status
        status = await task.status()

        # Wait for completion
        await task.wait()

        # Get result (waits if needed)
        result = await task.result()  # Returns CallToolResult

        # Or just await the task directly
        result = await task
    """

    def __init__(
        self,
        client: Client,
        task_id: str,
        tool_name: str,
        immediate_result: CallToolResult | None = None,
        raise_on_error: bool = True,
    ):
        """
        Create a ToolTask wrapper.

        Args:
            client: The FastMCP client
            task_id: The task identifier
            tool_name: Name of the tool being executed
            immediate_result: If server executed synchronously, the immediate result
            raise_on_error: Whether task.result() should raise ToolError on errors
        """
        super().__init__(client, task_id, immediate_result)
        self._tool_name = tool_name
        self._raise_on_error = raise_on_error

    async def result(self) -> CallToolResult:
        """Wait for and return the tool result.

        If server executed immediately, returns the immediate result.
        Otherwise waits for background task to complete and retrieves result.

        Returns:
            CallToolResult: The parsed tool result (same as call_tool returns)
        """
        # Check cache first
        if self._cached_result is not None:
            return self._cached_result

        if self._is_immediate:
            assert self._immediate_result is not None  # Type narrowing
            result = self._immediate_result
            if result.is_error and self._raise_on_error:
                if result.content and isinstance(
                    result.content[0], mcp.types.TextContent
                ):
                    msg = result.content[0].text
                else:
                    msg = f"Tool '{self._tool_name}' returned an error"
                raise ToolError(msg)
        else:
            # Check client connected
            self._check_client_connected()

            # Wait for completion using event-based wait (respects notifications)
            await self._wait_terminal()

            # Get the raw result (dict or CallToolResult)
            raw_result = await self._client.get_task_result(self._task_id)

            # Convert to CallToolResult if needed and parse
            if isinstance(raw_result, dict):
                # Raw dict from get_task_result - parse as CallToolResult
                mcp_result = mcp.types.CallToolResult.model_validate(raw_result)
                result = await self._client._parse_call_tool_result(
                    self._tool_name,
                    mcp_result,
                    raise_on_error=self._raise_on_error,
                )
            elif isinstance(raw_result, mcp.types.CallToolResult):
                # Already a CallToolResult from MCP protocol - parse it
                result = await self._client._parse_call_tool_result(
                    self._tool_name,
                    raw_result,
                    raise_on_error=self._raise_on_error,
                )
            else:
                # Legacy ToolResult format - convert to MCP type
                if hasattr(raw_result, "content") and hasattr(
                    raw_result, "structured_content"
                ):
                    mcp_result = mcp.types.CallToolResult(
                        content=raw_result.content,
                        structuredContent=raw_result.structured_content,
                        _meta=raw_result.meta,  # type: ignore[call-arg]  # _meta is Pydantic alias for meta field  # ty:ignore[unknown-argument]
                    )
                    result = await self._client._parse_call_tool_result(
                        self._tool_name,
                        mcp_result,
                        raise_on_error=self._raise_on_error,
                    )
                else:
                    # Unknown type - just return it
                    result = raw_result

        # Cache before returning
        self._cached_result = result
        return result


class PromptTask(Task[mcp.types.GetPromptResult]):
    """
    Represents a prompt call that may execute in background or immediately.

    Provides a uniform API whether the server accepts background execution
    or executes synchronously (graceful degradation per SEP-1686).

    Usage:
        task = await client.get_prompt_as_task("analyze", args)
        result = await task  # Returns GetPromptResult
    """

    def __init__(
        self,
        client: Client,
        task_id: str,
        prompt_name: str,
        immediate_result: mcp.types.GetPromptResult | None = None,
    ):
        """
        Create a PromptTask wrapper.

        Args:
            client: The FastMCP client
            task_id: The task identifier
            prompt_name: Name of the prompt being executed
            immediate_result: If server executed synchronously, the immediate result
        """
        super().__init__(client, task_id, immediate_result)
        self._prompt_name = prompt_name

    async def result(self) -> mcp.types.GetPromptResult:
        """Wait for and return the prompt result.

        If server executed immediately, returns the immediate result.
        Otherwise waits for background task to complete and retrieves result.

        Returns:
            GetPromptResult: The prompt result with messages and description
        """
        # Check cache first
        if self._cached_result is not None:
            return self._cached_result

        if self._is_immediate:
            assert self._immediate_result is not None
            result = self._immediate_result
        else:
            # Check client connected
            self._check_client_connected()

            # Wait for completion using event-based wait (respects notifications)
            await self._wait_terminal()

            # Get the raw MCP result
            mcp_result = await self._client.get_task_result(self._task_id)

            # Parse as GetPromptResult
            result = mcp.types.GetPromptResult.model_validate(mcp_result)

        # Cache before returning
        self._cached_result = result
        return result


class ResourceTask(
    Task[list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]]
):
    """
    Represents a resource read that may execute in background or immediately.

    Provides a uniform API whether the server accepts background execution
    or executes synchronously (graceful degradation per SEP-1686).

    Usage:
        task = await client.read_resource_as_task("file://data.txt")
        contents = await task  # Returns list[ReadResourceContents]
    """

    def __init__(
        self,
        client: Client,
        task_id: str,
        uri: str,
        immediate_result: list[
            mcp.types.TextResourceContents | mcp.types.BlobResourceContents
        ]
        | None = None,
    ):
        """
        Create a ResourceTask wrapper.

        Args:
            client: The FastMCP client
            task_id: The task identifier
            uri: URI of the resource being read
            immediate_result: If server executed synchronously, the immediate result
        """
        super().__init__(client, task_id, immediate_result)
        self._uri = uri

    async def result(
        self,
    ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]:
        """Wait for and return the resource contents.

        If server executed immediately, returns the immediate result.
        Otherwise waits for background task to complete and retrieves result.

        Returns:
            list[ReadResourceContents]: The resource contents
        """
        # Check cache first
        if self._cached_result is not None:
            return self._cached_result

        if self._is_immediate:
            assert self._immediate_result is not None
            result = self._immediate_result
        else:
            # Check client connected
            self._check_client_connected()

            # Wait for completion using event-based wait (respects notifications)
            await self._wait_terminal()

            # Get the raw MCP result
            mcp_result = await self._client.get_task_result(self._task_id)

            # Parse as ReadResourceResult or extract contents
            if isinstance(mcp_result, mcp.types.ReadResourceResult):
                # Already parsed by TasksResponse - extract contents
                result = list(mcp_result.contents)
            elif isinstance(mcp_result, dict) and "contents" in mcp_result:
                # Dict format - parse each content item
                parsed_contents = []
                for item in mcp_result["contents"]:
                    if isinstance(item, dict):
                        if "blob" in item:
                            parsed_contents.append(
                                mcp.types.BlobResourceContents.model_validate(item)
                            )
                        else:
                            parsed_contents.append(
                                mcp.types.TextResourceContents.model_validate(item)
                            )
                    else:
                        parsed_contents.append(item)
                result = parsed_contents
            else:
                # Fallback - might be the list directly
                result = mcp_result if isinstance(mcp_result, list) else [mcp_result]

        # Cache before returning
        self._cached_result = result
        return result
