"""Logging statistics — per-app counters + rates (unified-logging Wave 4).

A cheap, bounded, fail-safe view of *how much* an app is logging, alongside
the *what/where/how* of :func:`describe_logging`. :class:`LogStatsCollector`
is a :class:`logging.Handler` that counts records as they flow — total,
per-level, per-component (logger name), plus an overall and a recent-window
rate — and surfaces the async-handler overflow-drop count. :meth:`snapshot`
returns a frozen, JSON-able :class:`LogStats` for the per-app web surface
(Wave 3 ``/logs/info``) and the centralized shipper (Wave 5).

Design rules (OB-11):

* **Cheap** — every increment is O(1) under a short lock; ``emit`` does no
  formatting and no I/O.
* **Bounded** — the per-component map is capped (``max_components``); overflow
  is folded into ``__other__`` so a logger-name explosion can't grow memory
  without limit.
* **Fail-safe** — ``emit`` never raises into the caller (a stats handler must
  not be able to break logging).

Design: ``docs/inprogress/UNIFIED_LOGGING_DESIGN.md`` §5.4.
"""

from __future__ import annotations

import logging
import threading
import time
from collections import Counter, deque
from dataclasses import dataclass

LOG_STATS_SCHEMA_VERSION = "1.0.0"

_OTHER_COMPONENT = "__other__"
_DEFAULT_WINDOW_SECONDS = 60.0
_DEFAULT_MAX_COMPONENTS = 200


@dataclass(frozen=True)
class LogStats:
    """A point-in-time snapshot of an app's logging volume + rates."""

    total: int
    by_level: dict[str, int]
    by_component: dict[str, int]
    warnings: int
    errors: int
    dropped: int  # async-handler overflow drops (records never written)
    started_at: float  # epoch seconds (wall clock) when collection began
    uptime_seconds: float
    rate_per_second: float  # total / uptime (lifetime average)
    window_seconds: float
    window_rate_per_second: float  # records in the recent window / window
    component_cardinality_capped: bool
    schema_version: str = LOG_STATS_SCHEMA_VERSION

    def to_dict(self) -> dict[str, object]:
        """Serialise to a plain JSON-able dict (web surface + central ship)."""
        return {
            "total": self.total,
            "by_level": dict(self.by_level),
            "by_component": dict(self.by_component),
            "warnings": self.warnings,
            "errors": self.errors,
            "dropped": self.dropped,
            "started_at": self.started_at,
            "uptime_seconds": round(self.uptime_seconds, 3),
            "rate_per_second": round(self.rate_per_second, 3),
            "window_seconds": self.window_seconds,
            "window_rate_per_second": round(self.window_rate_per_second, 3),
            "component_cardinality_capped": self.component_cardinality_capped,
            "schema_version": self.schema_version,
        }


def _sum_dropped(logger: logging.Logger | None) -> int:
    """Sum overflow-drop counts across the root logger's handlers.

    Duck-typed on ``dropped_count`` so it picks up
    :class:`~mgf.common.observability.AsyncJsonHandler` (and any future
    drop-counting handler) without importing it.
    """
    total = 0
    for handler in logging.getLogger().handlers:
        count = getattr(handler, "dropped_count", None)
        if isinstance(count, int):
            total += count
    return total


class LogStatsCollector(logging.Handler):
    """A logging handler that counts records for :class:`LogStats`.

    Install it on a logger (usually root) — typically via
    :func:`enable_log_stats`. It never formats or writes; it only counts.
    """

    def __init__(
        self,
        *,
        window_seconds: float = _DEFAULT_WINDOW_SECONDS,
        max_components: int = _DEFAULT_MAX_COMPONENTS,
        level: int = logging.NOTSET,
    ) -> None:
        super().__init__(level=level)
        self._lock = threading.Lock()
        self._total = 0
        self._by_level: Counter[str] = Counter()
        self._by_component: Counter[str] = Counter()
        self._recent: deque[float] = deque()
        self._window_s = window_seconds
        self._max_components = max_components
        self._capped = False
        self._started_wall = time.time()
        self._started_mono = time.monotonic()
        # Set by enable_log_stats so disable_log_stats can detach cleanly.
        self._target_logger: logging.Logger | None = None

    def emit(self, record: logging.LogRecord) -> None:
        # A stats handler MUST NOT raise into the caller.
        try:
            self._count(record)
        except Exception:  # fail-safe: a stats handler must never break logging
            self.handleError(record)

    def _count(self, record: logging.LogRecord) -> None:
        name = record.name or "root"
        level = record.levelname
        now = time.monotonic()
        with self._lock:
            self._total += 1
            self._by_level[level] += 1
            if name in self._by_component or len(self._by_component) < self._max_components:
                self._by_component[name] += 1
            else:
                self._by_component[_OTHER_COMPONENT] += 1
                self._capped = True
            self._recent.append(now)
            cutoff = now - self._window_s
            while self._recent and self._recent[0] < cutoff:
                self._recent.popleft()

    def reset(self) -> None:
        """Zero all counters and restart the clock."""
        with self._lock:
            self._total = 0
            self._by_level.clear()
            self._by_component.clear()
            self._recent.clear()
            self._capped = False
            self._started_wall = time.time()
            self._started_mono = time.monotonic()

    def snapshot(self) -> LogStats:
        """Build a frozen :class:`LogStats` from the current counters."""
        now = time.monotonic()
        with self._lock:
            uptime = max(now - self._started_mono, 0.0)
            # Trim the window before measuring its rate.
            cutoff = now - self._window_s
            while self._recent and self._recent[0] < cutoff:
                self._recent.popleft()
            window_count = len(self._recent)
            by_level = dict(self._by_level)
            by_component = dict(self._by_component)
            total = self._total
            capped = self._capped
            started_wall = self._started_wall

        window_elapsed = min(self._window_s, uptime) or self._window_s
        return LogStats(
            total=total,
            by_level=by_level,
            by_component=by_component,
            warnings=by_level.get("WARNING", 0),
            errors=by_level.get("ERROR", 0) + by_level.get("CRITICAL", 0),
            dropped=_sum_dropped(self._target_logger),
            started_at=started_wall,
            uptime_seconds=uptime,
            rate_per_second=(total / uptime) if uptime > 0 else 0.0,
            window_seconds=self._window_s,
            window_rate_per_second=window_count / window_elapsed,
            component_cardinality_capped=capped,
        )


_default_lock = threading.Lock()
_default_collector: LogStatsCollector | None = None


def enable_log_stats(
    *,
    logger: logging.Logger | None = None,
    level: int = logging.NOTSET,
    window_seconds: float = _DEFAULT_WINDOW_SECONDS,
    max_components: int = _DEFAULT_MAX_COMPONENTS,
    replace: bool = False,
) -> LogStatsCollector:
    """Install a process-wide :class:`LogStatsCollector` and return it.

    Idempotent by default: a second call returns the existing collector
    (pass ``replace=True`` to swap in a fresh one). Attaches to the root
    logger unless ``logger`` is given. Read the live numbers with
    :func:`current_log_stats`; remove with :func:`disable_log_stats`.
    """
    global _default_collector
    with _default_lock:
        if _default_collector is not None and not replace:
            return _default_collector
        if _default_collector is not None:
            target = _default_collector._target_logger or logging.getLogger()
            target.removeHandler(_default_collector)
        collector = LogStatsCollector(
            window_seconds=window_seconds,
            max_components=max_components,
            level=level,
        )
        target = logger or logging.getLogger()
        target.addHandler(collector)
        collector._target_logger = target
        _default_collector = collector
        return collector


def current_log_stats() -> LogStats | None:
    """Snapshot the process-wide collector, or ``None`` if stats aren't enabled."""
    collector = _default_collector
    return collector.snapshot() if collector is not None else None


def disable_log_stats() -> None:
    """Detach + clear the process-wide collector (idempotent)."""
    global _default_collector
    with _default_lock:
        collector = _default_collector
        if collector is not None:
            target = collector._target_logger or logging.getLogger()
            target.removeHandler(collector)
            _default_collector = None


__all__ = [
    "LogStats",
    "LogStatsCollector",
    "current_log_stats",
    "disable_log_stats",
    "enable_log_stats",
]
