"""Helpers for running an async coroutine from sync code via a daemon thread.

``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
cycle finishes, which can add hundreds of milliseconds before the caller
receives the result. Running the loop in a daemon thread lets the caller
observe the result as soon as the coroutine completes while cleanup finishes
in the background.
"""

from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable
import threading


class AsyncThreadRunner[T](threading.Thread):
    """Run an async coroutine in a daemon thread and expose its result.

    The runner catches all exceptions from the coroutine and stores them in
    ``exception`` so ``event`` is always set — this prevents callers waiting
    on ``event`` from hanging forever when the coroutine crashes.

    Typical usage::

        runner = AsyncThreadRunner(lambda: my_coro(arg))
        runner.start()
        if not runner.event.wait(timeout=5.0):
            ...  # timed out
        if runner.exception is not None:
            raise runner.exception
        result = runner.result
    """

    def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
        super().__init__(daemon=True)
        self._coro_factory = coro_factory
        self.result: T | None = None
        self.exception: BaseException | None = None
        self.event = threading.Event()

    async def _runner(self) -> None:
        try:
            self.result = await self._coro_factory()
        except Exception as exc:  # noqa: BLE001  # pylint: disable=broad-except
            # Capture all exceptions so ``event`` is always set — otherwise a
            # crash would hang the waiter forever.
            self.exception = exc
        finally:
            self.event.set()

    def run(self) -> None:
        asyncio.run(self._runner())
