"""Per-radio serialized, paced send queue.

Each radio (Meshtastic, MeshCore) gets its own RadioSendQueue instance.
The drain task pops one job at a time, executes the async send, resolves the
caller's Future with the actual bool result, then sleeps a randomized jitter
drawn from random.uniform(pace_min, pace_max) before popping the next job —
enforcing strict FIFO serialization with configurable inter-packet spacing.

Design notes
------------
* Jobs are ``(async_fn, concurrent.futures.Future | None)`` tuples where
  ``async_fn`` is a zero-argument coroutine factory (a lambda/closure that
  captures all send parameters).
* ``concurrent.futures.Future`` is used rather than ``asyncio.Future`` so the
  same future can be set from within ANY event loop (the MC drain runs on the
  MC loop; callers on the main loop wrap it via ``asyncio.wrap_future``).
* The queue is UNBOUNDED — no messages are ever dropped.
* Pacing min/max are read live from the supplied callables on every drain cycle
  so a config-GUI change takes effect on the next send without restart.
"""
from __future__ import annotations

import asyncio
import concurrent.futures
import logging
import random
from typing import Awaitable, Callable, Optional

logger = logging.getLogger(__name__)

_PACING_FLOOR = 0.25  # seconds — absolute minimum between sends


class RadioSendQueue:
    """Serialized, paced outbound queue for a single radio transport.

    Usage::

        q = RadioSendQueue(
            pace_min_fn=lambda: cfg.meshtastic_send_pacing_min_seconds,
            pace_max_fn=lambda: cfg.meshtastic_send_pacing_max_seconds,
        )
        # On the event loop where the drain should run:
        q.start(loop)
        ...
        # To enqueue from the SAME loop (awaitable):
        result = await q.enqueue_async(my_send_coro_factory)
        # To enqueue from a DIFFERENT thread/loop (non-blocking):
        cfut = q.enqueue_threadsafe(my_send_coro_factory, target_loop)
        result = await asyncio.wrap_future(cfut)
        # On shutdown:
        await q.stop()
    """

    def __init__(
        self,
        pace_min_fn: Callable[[], float],
        pace_max_fn: Callable[[], float],
    ) -> None:
        """
        Args:
            pace_min_fn: Zero-argument callable returning the minimum pacing
                         gap in seconds.  Called on every drain iteration so
                         config-GUI changes take effect immediately.
            pace_max_fn: Zero-argument callable returning the maximum pacing
                         gap in seconds.  The actual sleep is
                         random.uniform(pace_min, pace_max); a floor of
                         _PACING_FLOOR (0.25 s) is always enforced.
        """
        self._pace_min_fn = pace_min_fn
        self._pace_max_fn = pace_max_fn
        self._queue: Optional[asyncio.Queue] = None
        self._drain_task: Optional[asyncio.Task] = None
        self._loop: Optional[asyncio.AbstractEventLoop] = None

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def start(self, loop: asyncio.AbstractEventLoop) -> None:
        """Create the asyncio.Queue and launch the drain task on *loop*.

        Must be called from WITHIN *loop* (i.e. via ``loop.call_soon_threadsafe``
        or from a coroutine running on *loop*).
        """
        self._loop = loop
        self._queue = asyncio.Queue()
        self._drain_task = loop.create_task(self._drain(), name="radio-send-queue-drain")
        logger.debug("RadioSendQueue: drain task started on loop %r", loop)

    async def stop(self) -> None:
        """Cancel the drain task, drain the remaining queue, and resolve all
        pending futures with CancelledError.

        After this returns every caller blocked at
        ``await asyncio.wrap_future(cfut)`` for an enqueued item will see
        ``asyncio.CancelledError`` raised promptly — no caller hangs
        indefinitely.  The in-flight future (if the drain was mid-job) is
        already cancelled by the drain's own ``except CancelledError`` handler.
        """
        if self._drain_task is None:
            return
        self._drain_task.cancel()
        try:
            await self._drain_task
        except asyncio.CancelledError:
            pass
        self._drain_task = None
        # Resolve all futures still sitting in the queue so no caller hangs.
        if self._queue is not None:
            while True:
                try:
                    _, cfut = self._queue.get_nowait()
                    if cfut is not None and not cfut.done():
                        cfut.cancel()
                except asyncio.QueueEmpty:
                    break
        logger.debug("RadioSendQueue: drain task stopped")

    @property
    def running(self) -> bool:
        return self._drain_task is not None and not self._drain_task.done()

    # ------------------------------------------------------------------
    # Enqueue helpers
    # ------------------------------------------------------------------

    def _make_cfut(self) -> concurrent.futures.Future:
        return concurrent.futures.Future()

    async def enqueue_async(self, async_fn: Callable[[], Awaitable[bool]]) -> bool:
        """Enqueue *async_fn* and await its result on the current (same) loop.

        ``async_fn`` must be a zero-argument callable returning a coroutine
        that resolves to bool.  Raises if the queue is not started.
        """
        if self._queue is None or self._loop is None:
            raise RuntimeError("RadioSendQueue.start() not called")
        cfut: concurrent.futures.Future = self._make_cfut()
        await self._queue.put((async_fn, cfut))
        return await asyncio.wrap_future(cfut, loop=self._loop)

    def enqueue_threadsafe(
        self,
        async_fn: Callable[[], Awaitable[bool]],
        caller_loop: asyncio.AbstractEventLoop,
    ) -> concurrent.futures.Future:
        """Enqueue *async_fn* from a DIFFERENT loop or thread (non-blocking).

        Returns a ``concurrent.futures.Future``; wrap with
        ``asyncio.wrap_future(cfut, loop=caller_loop)`` to await on the
        caller's loop.  Raises if the queue is not started.
        """
        if self._queue is None or self._loop is None:
            raise RuntimeError("RadioSendQueue.start() not called")
        cfut: concurrent.futures.Future = self._make_cfut()
        asyncio.run_coroutine_threadsafe(self._queue.put((async_fn, cfut)), self._loop)
        return cfut

    def enqueue_fire_and_forget(self, async_fn: Callable[[], Awaitable[bool]]) -> None:
        """Enqueue *async_fn* with no result tracking (fire-and-forget).

        Safe to call from within the queue's own loop.
        """
        if self._queue is None:
            logger.warning("RadioSendQueue: fire-and-forget dropped (queue not started)")
            return
        self._queue.put_nowait((async_fn, None))

    # ------------------------------------------------------------------
    # Drain
    # ------------------------------------------------------------------

    async def _drain(self) -> None:
        """Pop items one at a time, execute, resolve future, pace."""
        while True:
            item = await self._queue.get()
            async_fn, cfut = item
            result = False
            try:
                result = await async_fn()
            except asyncio.CancelledError:
                if cfut is not None and not cfut.done():
                    cfut.cancel()
                raise
            except Exception as exc:
                logger.error("RadioSendQueue: send job raised: %s", exc, exc_info=True)
                if cfut is not None and not cfut.done():
                    cfut.set_exception(exc)
            else:
                if cfut is not None and not cfut.done():
                    cfut.set_result(result)

            # Pace: read live so config-GUI changes take effect next send.
            pace_min = max(_PACING_FLOOR, self._pace_min_fn())
            pace_max = max(pace_min, self._pace_max_fn())
            await asyncio.sleep(random.uniform(pace_min, pace_max))
