"""``mgf.fastapi.logs`` — per-app web log surface (unified-logging Wave 3).

The web half of "access any Magogi app's logs at runtime" (design decision
**D3**: each app exposes its *own* auth-gated endpoint — no central UI). Two
routes, mounted by :func:`create_logs_router`:

  - ``GET <prefix>/info``   — :func:`mgf.common.observability.describe_logging`
    as JSON: where the logs live, the effective levels, the active sinks +
    per-component levels ("where/what/how").
  - ``GET <prefix>/stream`` — a live Server-Sent-Events stream of canonical
    :class:`~mgf.common.observability.LogRecord` JSON, one ``data:`` frame per
    record, composed over **mgf-livepush** (SSE + broker + slot-cap).

:class:`LogBroadcaster` is the producer: it bridges the stdlib logging
pipeline to a livepush :class:`~mgf.livepush.Broker`. Each emitted record
becomes a **redacted** canonical ``LogRecord`` JSON line published to the
channel the stream endpoint subscribes to.

Two hard rules from the design:

* **Redaction is mandatory** — the stream leaves the process, so every record
  is run through a :class:`~mgf.common.observability.Redactor` before publish
  (design §8).
* **Fail-open** — a slow or down broker must never block or crash the app;
  publishing is fire-and-forget and swallows transport errors (design §6.1).

Requires the ``[livepush]`` extra (``pip install mgf-fastapi[livepush]``).
"""

from __future__ import annotations

import asyncio
import contextvars
import json
import logging
from typing import TYPE_CHECKING

from mgf.common.observability import LogRecord, Redactor, describe_logging

if TYPE_CHECKING:
    from collections.abc import Callable
    from enum import Enum
    from typing import Any

    from mgf.livepush import Broker, SSEStreamService

    from fastapi import APIRouter, Request

DEFAULT_LOG_CHANNEL = "mgf.logs"

# Re-entrancy guard: while a record is being serialised + published, any
# logging done in the SAME task (e.g. the broker logging an error) is dropped,
# so the stream never feeds itself into an amplifying loop.
_in_broadcast: contextvars.ContextVar[bool] = contextvars.ContextVar(
    "mgf_fastapi_in_broadcast", default=False
)


class _BroadcastHandler(logging.Handler):
    """Root-logger handler that hands each record to its broadcaster."""

    def __init__(self, broadcaster: LogBroadcaster) -> None:
        super().__init__()
        self._broadcaster = broadcaster

    def emit(self, record: logging.LogRecord) -> None:
        # A logging handler MUST NOT raise into the caller.
        try:
            self._broadcaster._dispatch(record)
        except Exception:
            self.handleError(record)


class LogBroadcaster:
    """Bridges the logging pipeline to a livepush broker channel.

    Build one with the same broker the stream endpoint reads from, then
    :meth:`attach` it from inside the running event loop (e.g. the FastAPI
    lifespan) so the loop can be captured for thread-safe publishing::

        broadcaster = LogBroadcaster(broker, env=settings.env)

        @asynccontextmanager
        async def lifespan(app):
            broadcaster.attach()           # capture loop + install handler
            try:
                yield
            finally:
                broadcaster.detach()
    """

    def __init__(
        self,
        broker: Broker,
        *,
        channel: str = DEFAULT_LOG_CHANNEL,
        redactor: Redactor | None = None,
        env: str | None = None,
    ) -> None:
        self._broker = broker
        self._channel = channel
        self._redactor = redactor or Redactor.default()
        self._env = env
        self._loop: asyncio.AbstractEventLoop | None = None
        # DP-11: hold strong refs to in-flight publish tasks so the loop can't
        # GC them mid-publish (silently dropping the record).
        self._tasks: set[asyncio.Task[None]] = set()
        self._handler = _BroadcastHandler(self)

    @property
    def channel(self) -> str:
        return self._channel

    def attach(
        self, *, level: int = logging.NOTSET, logger: logging.Logger | None = None
    ) -> logging.Handler:
        """Capture the running loop and install the broadcast handler.

        Call from inside the event loop (the lifespan). Returns the installed
        handler so callers can tune it further if needed.
        """
        self._loop = asyncio.get_running_loop()
        self._handler.setLevel(level)
        (logger or logging.getLogger()).addHandler(self._handler)
        return self._handler

    def detach(self, *, logger: logging.Logger | None = None) -> None:
        """Remove the broadcast handler (idempotent)."""
        (logger or logging.getLogger()).removeHandler(self._handler)

    def _serialize(self, record: logging.LogRecord) -> str:
        from mgf.common import app_name, app_version  # lazy — avoid import cycle

        wire = LogRecord.from_python(
            record, app=app_name(), app_version=app_version(), env=self._env
        ).to_dict()
        self._redactor.redact(wire)  # mandatory: stream leaves the process
        return json.dumps(wire, separators=(",", ":"), ensure_ascii=False, default=str)

    async def publish_record(self, record: logging.LogRecord) -> None:
        """Serialise + redact + publish one record. Fail-open on every step.

        Never raises. (EH-10 §7 — every step swallows its own failure.)
        """
        token = _in_broadcast.set(True)
        try:
            line = self._serialize(record)
        except Exception:
            _in_broadcast.reset(token)
            return
        try:
            await self._broker.publish(self._channel, line)
        except Exception:  # noqa: S110 — fail-open by design
            # A slow/down broker must never crash the app, and we cannot log
            # the error here without feeding the very pipeline we're draining
            # (recursion). Dropping the off-box copy is the correct tradeoff.
            pass
        finally:
            _in_broadcast.reset(token)

    def _dispatch(self, record: logging.LogRecord) -> None:
        if _in_broadcast.get():
            return  # re-entrant log from our own publish path — drop it
        loop = self._loop
        if loop is None or loop.is_closed():
            return
        # emit() may run on any thread; hop to the loop and fire-and-forget.
        loop.call_soon_threadsafe(self._spawn, record)

    def _spawn(self, record: logging.LogRecord) -> None:
        loop = self._loop
        if loop is None or loop.is_closed():
            return
        task = loop.create_task(self.publish_record(record))
        self._tasks.add(task)
        task.add_done_callback(self._tasks.discard)


def _client_slot_key(request: Request) -> str:
    client = request.client
    return client.host if client is not None else "anon"


def _default_log_stats() -> Any:
    """Process-wide :class:`~mgf.common.observability.LogStats`, or ``None``.

    Defensive import: ``current_log_stats`` landed in mgf-common 0.42; this
    package supports ``>=0.41``, so on 0.41 (or when stats aren't enabled) the
    ``/logs/info`` ``stats`` field is simply ``null``.
    """
    try:
        from mgf.common.observability import current_log_stats
    except ImportError:
        return None
    return current_log_stats()


def build_log_stream_service(
    broker: Broker,
    redis: Any,
    *,
    cap: int = 20,
    heartbeat_seconds: float = 15.0,
    slot_prefix: str = "livepush:logslot:",
) -> SSEStreamService:
    """Convenience: build the livepush :class:`SSEStreamService` the stream
    endpoint needs from a broker + a redis client (for the connection-slot cap).

    Consumers who already have an ``SSEStreamService`` can skip this and pass
    theirs straight to :func:`create_logs_router`.
    """
    from mgf.livepush import SlotCap, SSEStreamService

    return SSEStreamService(
        broker,
        SlotCap(redis, cap=cap, key_prefix=slot_prefix),
        heartbeat_seconds=heartbeat_seconds,
    )


def create_logs_router(
    sse_service: SSEStreamService,
    *,
    auth: Callable[..., Any],
    channel: str = DEFAULT_LOG_CHANNEL,
    prefix: str = "/logs",
    tags: list[str] | None = None,
    slot_key: Callable[[Request], str] | None = None,
    info_provider: Callable[[], Any] = describe_logging,
    stats_provider: Callable[[], Any] | None = None,
) -> APIRouter:
    """Build the auth-gated per-app log router (``/info`` + ``/stream``).

    ``auth`` is a FastAPI dependency callable applied to **both** routes — pass
    ``mgf.fastapi.security.bearer_session()`` or an ``IpAllowlist(...)``. Auth is
    required by design (§8): the web surface exposes logs off-box. ``sse_service``
    must read the same ``channel`` a :class:`LogBroadcaster` publishes to.

    ``/info`` returns the ``describe_logging()`` introspection plus a ``stats``
    key — the process-wide :class:`~mgf.common.observability.LogStats` snapshot
    (``null`` when stats aren't enabled). Override the source via
    ``stats_provider`` (a callable returning a ``LogStats``-like object or
    ``None``).
    """
    from mgf.livepush.fastapi import sse_streaming_response

    from fastapi import APIRouter, Depends

    key_func = slot_key or _client_slot_key
    stats_fn = stats_provider or _default_log_stats
    router_tags: list[str | Enum] = [*tags] if tags else ["logs"]
    router = APIRouter(prefix=prefix, tags=router_tags, dependencies=[Depends(auth)])

    @router.get("/info")
    async def logs_info() -> dict[str, Any]:
        info: dict[str, Any] = info_provider().to_dict()
        stats = stats_fn()
        info["stats"] = stats.to_dict() if stats is not None else None
        return info

    @router.get("/stream")
    async def logs_stream(request: Request) -> Any:
        return await sse_streaming_response(
            sse_service, request, channel=channel, slot_key=key_func(request)
        )

    return router


__all__ = [
    "DEFAULT_LOG_CHANNEL",
    "LogBroadcaster",
    "build_log_stream_service",
    "create_logs_router",
]
