"""Captured CDP connection handle for trusted in-process plugins.

``SUPERVISOR_REGISTRY.capture(task_id)`` pins one supervisor's current WebSocket and the
default page session attached on it. ``call()`` sends only over that socket. Once the
supervisor reconnects, stops, or is replaced in the registry, the handle is invalid and
every call raises ``CapturedCDPInvalid``: it never retargets a newer connection.

This grants no new capability. In-process plugin code can already reach the supervisor;
the handle exists so plugins stop writing its private call table and socket (catalog
review on #132272). It performs no origin, consent or target-ownership checks.
"""

from __future__ import annotations

import asyncio
import concurrent.futures
import inspect
import math
import threading
import time
from typing import TYPE_CHECKING, Any, Callable, Optional

if TYPE_CHECKING:
    from tools.browser_supervisor import CDPSupervisor, _SupervisorRegistry


class CapturedCDPInvalid(RuntimeError):
    """The captured connection is gone (reconnect, stop, or registry replacement)."""


class CapturedCDP:
    """A CDP channel pinned to one supervisor connection. Build via ``SUPERVISOR_REGISTRY.capture``."""

    def __init__(self, supervisor: CDPSupervisor, registry: _SupervisorRegistry) -> None:
        # Runs on the supervisor loop (see ``capture``) so ws and session id are one snapshot.
        self._supervisor = supervisor
        self._registry = registry
        self._loop: asyncio.AbstractEventLoop = supervisor._loop  # type: ignore[assignment]
        self._ws = supervisor._ws
        self.task_id: str = supervisor.task_id
        self.cdp_url: str = supervisor.cdp_url
        # The default page session at capture time. A later ``focus_page`` on the supervisor
        # does not change it: calls with this id keep reaching the page captured here.
        self.page_session_id: str = supervisor._page_session_id or ""

    def is_valid(self) -> bool:
        """True while the captured connection is still the supervisor's live one."""
        sup = self._supervisor
        return (self._registry.get(self.task_id) is sup and sup._active and not sup._stop_requested
                and sup._ws is self._ws and self._loop.is_running())

    def _invalid(self) -> CapturedCDPInvalid:
        return CapturedCDPInvalid(f"captured CDP connection for task {self.task_id!r} is gone "
                                  "(supervisor reconnected, stopped or was replaced); capture again")

    async def _send(self, method: str, params: Optional[dict[str, Any]],
                    session_id: Optional[str], timeout: Optional[float],
                    deadline: Optional[float], before_send: Optional[Callable[[], None]]) -> dict[str, Any]:
        # ``_cdp`` reaches its send without awaiting, so this check and the send run in one
        # loop step: a reconnect cannot swap the socket in between.
        if not self.is_valid():
            raise self._invalid()
        if deadline is not None and time.monotonic() >= deadline:
            raise TimeoutError("Captured CDP dispatch deadline expired")
        if before_send is not None:
            outcome = before_send()
            if outcome is not None:
                if inspect.iscoroutine(outcome):
                    outcome.close()
                raise TypeError("before_send must return None synchronously")
        if deadline is not None and time.monotonic() >= deadline:
            raise TimeoutError("Captured CDP dispatch deadline expired")
        if not self.is_valid():
            raise self._invalid()
        try:
            return await self._supervisor._cdp(method, params, session_id=session_id, timeout=timeout)
        except Exception:
            # A reply lost to a dropped socket (timeout, ConnectionError, ConnectionClosed)
            # surfaces as invalidation; a CDP error on a live connection passes through.
            if not self.is_valid():
                raise self._invalid() from None
            raise

    def call(self, method: str, params: Optional[dict[str, Any]] = None, *,
             session_id: Optional[str] = None, timeout: Optional[float] = 10.0,
             before_send: Optional[Callable[[], None]] = None) -> dict[str, Any]:
        """Send ``method`` on the captured connection and return the raw CDP reply
        (``{"id", "result"}``). ``session_id=None`` targets the browser endpoint;
        pass ``page_session_id`` (or a session you attached) for page domains.

        Raises ``CapturedCDPInvalid`` when the connection is gone, ``TimeoutError`` when no
        reply arrives in ``timeout`` seconds, ``RuntimeError`` for a CDP error reply.
        Blocks the calling thread; never call it from the supervisor's own loop.

        A finite timeout also fences send admission while this call waits in the
        supervisor queue. ``timeout=None`` deliberately retains a reply until
        the connection closes: use it only in a consumer-owned worker when a
        late reply (for example an attachment session ID) must not be lost.
        Keep the consumer's own wait/issuance deadline finite and retain cleanup
        responsibility after it expires.

        ``before_send`` is a trusted synchronous nonblocking validation callback
        run on the supervisor loop immediately before CDP dispatch. It may raise
        to refuse a cancelled/expired operation. It must not call a blocking
        capture/CDP API. This is not origin or target-ownership authorization.
        """
        if timeout is not None:
            if (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
                    or not math.isfinite(timeout) or timeout <= 0
                    or timeout > threading.TIMEOUT_MAX - 1.0):
                raise ValueError("Captured CDP timeout must be positive and finite, or None")
        if before_send is not None and not callable(before_send):
            raise TypeError("before_send must be callable")
        deadline = None if timeout is None else time.monotonic() + timeout
        try:
            running = asyncio.get_running_loop()
        except RuntimeError:
            running = None
        if running is self._loop:
            raise RuntimeError("CapturedCDP.call() blocks and cannot run on the supervisor loop")
        if not self.is_valid():
            raise self._invalid()
        from agent.async_utils import safe_schedule_threadsafe

        fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout, deadline, before_send), self._loop)
        if fut is None:
            raise self._invalid()
        while timeout is None:
            # Unbounded wait still ends with the loop: a closed loop drops queued callbacks.
            try:
                return fut.result(timeout=1.0)
            except concurrent.futures.TimeoutError:
                if self._loop.is_closed():
                    fut.cancel()
                    raise self._invalid() from None
        try:
            return fut.result(timeout=timeout + 1.0)
        except concurrent.futures.TimeoutError:
            fut.cancel()
            raise TimeoutError(f"CDP {method} got no reply within {timeout}s") from None


def capture(registry: _SupervisorRegistry, task_id: str, *, timeout: float = 10.0) -> CapturedCDP:
    """Pin the running supervisor for ``task_id``; never starts, reconnects or refocuses one."""
    from tools.browser_supervisor import _LoopUnavailable, _schedule

    found = registry.get(task_id)
    loop = found._loop if found is not None else None
    if found is None or loop is None or not loop.is_running():
        raise CapturedCDPInvalid(f"no running browser supervisor for task {task_id!r}")
    sup = found

    async def _snapshot() -> CapturedCDP:
        if not sup._active or sup._ws is None or not sup._page_session_id:
            raise CapturedCDPInvalid(f"browser supervisor for task {task_id!r} has no attached page")
        return CapturedCDP(sup, registry)

    try:
        return _schedule(_snapshot(), loop, timeout=timeout)
    except _LoopUnavailable:
        raise CapturedCDPInvalid(f"no running browser supervisor for task {task_id!r}") from None
