"""Async resource tracker for deterministic cleanup and leak detection.

Provides a central ``AsyncResourceTracker`` that manages the lifecycle of
asynchronous resources (connections, tasks, subscriptions).  Resources
conforming to the ``AsyncResource`` protocol are registered by name and
awaited on shutdown with a configurable timeout.  Any resource that is
still open when the tracker is garbage-collected is logged as a leak.

Implements issue #321: fix(security): close async resources and leaks.
"""

from __future__ import annotations

import asyncio
import contextlib
import logging
import threading
from typing import Protocol, runtime_checkable

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Protocol — any object with an async ``close()`` qualifies
# ---------------------------------------------------------------------------


@runtime_checkable
class AsyncResource(Protocol):
    """Protocol for asynchronous resources that can be closed."""

    async def close(self) -> None: ...  # pragma: no cover


# ---------------------------------------------------------------------------
# Tracker
# ---------------------------------------------------------------------------


class AsyncResourceTracker:
    """Thread-safe registry for async resources with deterministic cleanup.

    Resources are registered by a unique *name* and closed in bulk via
    :meth:`close_all`, which respects *timeout* and logs forced
    terminations.  The tracker also acts as an async context manager.

    Attributes:
        timed_out_resources: Names of resources that exceeded the timeout
            during the most recent :meth:`close_all` invocation.
    """

    def __init__(self) -> None:
        self._resources: dict[str, AsyncResource] = {}
        self._lock = threading.Lock()
        self._closed = False
        self.timed_out_resources: list[str] = []

    # -- Registration -------------------------------------------------------

    def register(self, name: str, resource: AsyncResource) -> None:
        """Register *resource* under a unique *name*.

        Args:
            name: Non-empty, unique identifier for the resource.
            resource: An object satisfying the :class:`AsyncResource` protocol.

        Raises:
            ValueError: If *name* is empty, *resource* is ``None``, or *name*
                is already registered.
        """
        if not name:
            raise ValueError("name must be a non-empty string")
        if resource is None:
            raise ValueError("resource must not be None")
        with self._lock:
            if self._closed:
                raise RuntimeError("Cannot register resource after tracker is closed")
            if name in self._resources:
                raise ValueError(f"Resource '{name}' is already registered")
            self._resources[name] = resource
            logger.debug("Registered async resource '%s'", name)

    # -- Bulk close ---------------------------------------------------------

    async def close_all(self, timeout: float = 30.0) -> None:
        """Close every registered resource within *timeout* seconds.

        Resources whose ``close()`` exceeds the deadline are logged as
        forced terminations and their names are appended to
        :attr:`timed_out_resources`.

        This method is idempotent — calling it on an already-closed tracker
        is a no-op.

        Args:
            timeout: Maximum wall-clock seconds to wait per resource.
        """
        with self._lock:
            if self._closed:
                return
            self._closed = True
            snapshot = dict(self._resources)
            self._resources.clear()

        self.timed_out_resources = []

        for name, resource in snapshot.items():
            try:
                await asyncio.wait_for(resource.close(), timeout=timeout)
                logger.info(
                    "Closed async resource '%s'",
                    name,
                )
            except TimeoutError:
                self.timed_out_resources.append(name)
                logger.warning(
                    "Forced termination: resource '%s' did not close "
                    "within timeout of %.1f s",
                    name,
                    timeout,
                )
            except (Exception, asyncio.CancelledError):
                logger.exception(
                    "Error closing async resource '%s'",
                    name,
                )

    # -- Query --------------------------------------------------------------

    @property
    def open_count(self) -> int:
        """Return the number of currently registered (open) resources."""
        with self._lock:
            return len(self._resources)

    # -- Finalizer / leak warning -------------------------------------------

    def _warn_unclosed(self) -> None:
        """Log a warning for every resource that was never closed."""
        with self._lock:
            names = list(self._resources.keys())
        for name in names:
            logger.warning(
                "Async resource '%s' was never closed (potential leak)",
                name,
            )

    def __del__(self) -> None:
        """Best-effort leak detection during garbage collection."""
        with contextlib.suppress(AttributeError):
            if self._resources:
                self._warn_unclosed()

    # -- Async context manager ----------------------------------------------

    async def __aenter__(self) -> AsyncResourceTracker:
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object,
    ) -> bool:
        await self.close_all()
        return False


__all__ = [
    "AsyncResource",
    "AsyncResourceTracker",
]
