"""Durable in-process task registry.

Both the single-run path (``web.routers.runs``) and the campaign scheduler
(``evaluation.campaign_runner``) drive long-lived ``asyncio`` tasks whose
authority lives in the DB — the in-process handle is only there to cancel or
gracefully stop the task. This module is the one deep seam they share: it owns
the (task, cancel-event) pair per id, wires cleanup, and stops everything on
shutdown. Each caller instantiates its own registry, so id spaces never collide.
"""

import asyncio
import logging
from typing import Awaitable, Callable

_logger = logging.getLogger("agenteval")

# A coroutine factory: given the cancel event the registry owns, return the
# coroutine to run. The event exists before the task starts, so a cancel that
# arrives during startup is never lost.
CoroFactory = Callable[[asyncio.Event], Awaitable[None]]


class TaskRegistry:
    """Tracks live asyncio tasks and their cooperative cancel events by id."""

    def __init__(self) -> None:
        self._tasks: dict[str, asyncio.Task] = {}
        self._cancel_events: dict[str, asyncio.Event] = {}

    def launch(self, id: str, make_coro: CoroFactory) -> asyncio.Task:
        """Start a tracked task for ``id`` (idempotent for a live id).

        Creates the cancel event first, hands it to ``make_coro`` to build the
        coroutine, then schedules the task and wires a done-callback that pops
        both handles when it ends. If ``id`` already has a live task, returns it
        without starting a second coroutine.
        """
        existing = self._tasks.get(id)
        if existing is not None:
            return existing

        cancel = self._cancel_events.setdefault(id, asyncio.Event())
        task = asyncio.create_task(make_coro(cancel), name=id)
        self._tasks[id] = task
        task.add_done_callback(lambda _t, _id=id: self._cleanup(_id))
        return task

    def cancel(self, id: str) -> bool:
        """Signal the cooperative cancel event for ``id``.

        Returns True if a live cancel event was signalled, False if there was
        nothing to signal (e.g. the task already ended or never existed) — the
        caller uses this to decide any fallback such as marking the DB directly.
        """
        event = self._cancel_events.get(id)
        if event is None:
            return False
        event.set()
        return True

    def get(self, id: str) -> "asyncio.Task | None":
        """Return the live task for ``id``, or None if there is none."""
        return self._tasks.get(id)

    def is_running(self, id: str) -> bool:
        return id in self._tasks

    async def shutdown_all(self) -> None:
        """Cooperatively stop, then hard-cancel and await, every live task."""
        for event in list(self._cancel_events.values()):
            event.set()
        tasks = list(self._tasks.values())
        for task in tasks:
            task.cancel()
        for task in tasks:
            # CancelledError is expected (we just cancelled the task) and does
            # not derive from Exception in 3.8+, so list it explicitly.
            try:
                await task
            except (asyncio.CancelledError, Exception) as exc:  # noqa: BLE001
                _logger.debug("task registry shutdown: task ended with %r", exc)
        self._tasks.clear()
        self._cancel_events.clear()

    def _cleanup(self, id: str) -> None:
        self._tasks.pop(id, None)
        self._cancel_events.pop(id, None)
