""":class:`AsyncJsonHandler` — non-blocking JSON log handler.

Closes the strategic-review §3.3 finding: every log call goes
through synchronous I/O (writing to stderr / file / journald).
Inside an asyncio event loop, that's event-loop poison — a slow
disk or a blocking journald write stalls every coroutine.

This module ships a non-blocking handler that:

  - Accepts log records on the calling thread without blocking
    (puts on a bounded :class:`queue.Queue`; drops or back-pressures
    on overflow).
  - Drains the queue from a dedicated daemon thread that calls the
    *target* handler — typically a :class:`logging.StreamHandler`
    or :class:`logging.FileHandler` configured with mgf-common's
    :class:`JsonFormatter`.
  - Disposes cleanly on shutdown (drains pending records up to a
    timeout, then exits).

The implementation uses a thread (not an :mod:`asyncio` task) so:

  - Logging from threads outside the event loop (the threading
    excepthook, sync code paths in async services, the
    :func:`~mgf.common.observability.report_now` crash reporter)
    continues to work.
  - The stdlib :class:`logging` module's thread-safety guarantees
    apply unchanged.
  - There's no event-loop dependency — the handler is usable in
    sync programs that just want non-blocking I/O.

Usage:

.. code-block:: python

    import logging
    import sys

    from mgf.common.observability import (
        AsyncJsonHandler,
        JsonFormatter,
        Redactor,
    )

    target = logging.StreamHandler(sys.stderr)
    target.setFormatter(JsonFormatter(redactor=Redactor.default()))

    handler = AsyncJsonHandler(target=target, max_queue_size=10_000)
    logging.getLogger().addHandler(handler)
    logging.getLogger().setLevel(logging.INFO)

    # ... your code ... log calls return immediately ...

    # On shutdown:
    handler.close()  # drains the queue, joins the worker thread

For most consumers, the bootstrap helper handles wiring; reach for
:class:`AsyncJsonHandler` directly only when the consumer needs
fine-grained control over the queue size or overflow policy.
"""

from __future__ import annotations

import contextlib
import logging
import queue
import threading
import time
from typing import Literal

# Sentinel pushed onto the queue to tell the drain thread to exit
# cleanly. Using ``object()`` makes it identity-distinguishable
# from any real ``LogRecord``.
_SHUTDOWN = object()


class AsyncJsonHandler(logging.Handler):
    """Non-blocking logging handler — bounded queue + drain thread.

    Wraps an inner *target* handler. Calls to :meth:`emit` from any
    thread (logging emits from threads, asyncio loops, signal
    handlers) put the :class:`logging.LogRecord` on a bounded queue
    and return immediately; a daemon thread drains the queue and
    forwards records to ``target.handle(record)``.

    Parameters
    ----------
    target
        The actual destination handler — typically a
        :class:`logging.StreamHandler` or :class:`logging.FileHandler`
        configured with the formatter you want
        (:class:`mgf.common.observability.JsonFormatter` is the
        canonical choice). The target's :meth:`emit` is called from
        the drain thread, so it MUST be thread-safe (stdlib handlers
        all are).
    max_queue_size
        Maximum number of records buffered before the overflow
        policy kicks in. Default 10 000 — enough to absorb a burst,
        small enough to bound memory.
    on_overflow
        Behavior when the queue is full:

          - ``"drop"`` (default): drop the record; increment a
            counter; emit a one-shot loud warning via the target
            handler.
          - ``"block"``: block the calling thread until queue space
            is available. Backpressure mode — preserves every
            record at the cost of stalling the caller. Use only
            when log loss is unacceptable AND the caller can
            tolerate the latency.
    drain_interval
        Internal — drain-thread poll timeout in seconds. Default
        0.5; lower values reduce shutdown latency at the cost of
        more wakeups.

    Attributes
    ----------
    dropped_count
        Number of records dropped due to overflow (always 0 in
        ``block`` mode). Visible to consumers via
        :attr:`AsyncJsonHandler.dropped_count`.
    """

    def __init__(
        self,
        *,
        target: logging.Handler,
        max_queue_size: int = 10_000,
        on_overflow: Literal["drop", "block"] = "drop",
        drain_interval: float = 0.5,
    ) -> None:
        super().__init__()
        self._target = target
        self._queue: queue.Queue[logging.LogRecord | object] = queue.Queue(
            maxsize=max_queue_size,
        )
        self._on_overflow = on_overflow
        self._drain_interval = drain_interval

        # Mutable state — the worker reads, emit() writes.
        self._dropped_count = 0
        self._overflow_warned = False
        self._closed = False

        self._stop = threading.Event()
        self._worker = threading.Thread(
            target=self._drain,
            daemon=True,
            name="mgf-async-log",
        )
        self._worker.start()

    @property
    def dropped_count(self) -> int:
        """Number of records dropped to overflow since handler start."""
        return self._dropped_count

    # ------------------------------------------------------------------
    # logging.Handler protocol — emit + close.
    # ------------------------------------------------------------------

    def emit(self, record: logging.LogRecord) -> None:
        """Enqueue ``record`` for the drain thread.

        Never raises. On overflow with ``on_overflow="drop"``,
        increments :attr:`dropped_count` and emits a one-shot
        warning to the target. With ``on_overflow="block"``,
        blocks until queue space is available.
        """
        if self._closed:
            return
        try:
            if self._on_overflow == "block":
                self._queue.put(record)
            else:
                self._queue.put_nowait(record)
        except queue.Full:
            self._dropped_count += 1
            if not self._overflow_warned:
                self._overflow_warned = True
                self._emit_overflow_warning()
        except Exception:
            # logging.Handler subclasses MUST NOT raise from emit().
            # Fall back to the standard handleError shape so the
            # logger's error reporter sees the failure.
            self.handleError(record)

    def close(self) -> None:
        """Drain pending records + stop the worker thread.

        Idempotent. Waits up to a few seconds for the worker to
        flush; on timeout the remaining records are abandoned (logging
        should never block process exit indefinitely).
        """
        if self._closed:
            return
        self._closed = True

        # Tell the worker to stop after the current backlog drains.
        # If the queue is full, the sentinel is picked up after some
        # records drain. Worker has a polling timeout, so it sees
        # _stop set even if it never sees the sentinel.
        self._stop.set()
        with contextlib.suppress(queue.Full):
            self._queue.put_nowait(_SHUTDOWN)

        self._worker.join(timeout=5.0)

        # Close the wrapped target. Unconditional — ensure target
        # cleanup even if the worker hung.
        try:
            self._target.close()
        finally:
            super().close()

    # ------------------------------------------------------------------
    # Worker thread.
    # ------------------------------------------------------------------

    def _drain(self) -> None:
        """Pull records from the queue and forward to the target.

        Exits when the shutdown sentinel arrives OR :attr:`_stop` is
        set AND the queue is empty (so a clean shutdown drains the
        backlog before exiting).
        """
        while True:
            try:
                item = self._queue.get(timeout=self._drain_interval)
            except queue.Empty:
                if self._stop.is_set():
                    return
                continue

            if item is _SHUTDOWN:
                return

            try:
                self._target.handle(item)  # type: ignore[arg-type]
            except Exception:
                # Logging never breaks the app — swallow handler
                # errors. The target's ``handleError`` does the right
                # thing; if it doesn't, it's the target's bug.
                self._target.handleError(item)  # type: ignore[arg-type]

    # ------------------------------------------------------------------
    # One-shot overflow warning. Synthesises a LogRecord and feeds
    # it directly to the target (bypassing the queue, since the
    # queue is what overflowed).
    # ------------------------------------------------------------------

    def _emit_overflow_warning(self) -> None:
        record = logging.LogRecord(
            name="mgf.common.observability.async_handler",
            level=logging.WARNING,
            pathname=__file__,
            lineno=0,
            msg=(
                "AsyncJsonHandler overflow: dropping log records "
                "(queue full at maxsize=%(maxsize)d). "
                "Subsequent drops will be silent — check "
                "AsyncJsonHandler.dropped_count for the running total."
            ),
            args=({"maxsize": self._queue.maxsize},),
            exc_info=None,
        )
        record.created = time.time()
        # If even the warning fails, give up — we've done our best.
        with contextlib.suppress(Exception):
            self._target.handle(record)


__all__ = ["AsyncJsonHandler"]
