"""Log-volume-control filters (rule LG-13).

High-frequency log sites (inner loops, per-event callbacks, per-tick
progress) MUST use rate-limiting or sampling so a runaway condition can't
flood every sink (LG-13). LOGGING.md §15 prescribes three drop-in
``logging.Filter`` shapes; the federation ships them here as importable code so
consumers attach a filter instead of copy-pasting from the doc:

    from mgf.common.observability import RateLimitFilter
    handler.addFilter(RateLimitFilter(max_per_second=200))

- :class:`RateLimitFilter` — cap records/sec per (logger, level); excess
  dropped, with a once-per-window WARNING reporting the drop count.
- :class:`SampleFilter` — keep 1-in-N records (telemetry where the
  distribution matters, not individuals).
- :class:`DedupFilter` — collapse identical (logger, level, msg) within a
  window. Bounded: expired keys are pruned so memory can't grow unbounded.

All three are thread-safe (a sink is shared across threads).
"""

from __future__ import annotations

import logging
import random
import time
from collections import defaultdict
from threading import Lock

__all__ = ["DedupFilter", "RateLimitFilter", "SampleFilter"]


class RateLimitFilter(logging.Filter):
    """Allow at most ``max_per_second`` records per (logger, level) pair.

    Excess records are dropped; a WARNING fires at most once per window per
    key reporting how many were dropped, so the loss is visible.
    """

    def __init__(self, max_per_second: float = 100.0) -> None:
        super().__init__()
        self._max = max_per_second
        self._counts: dict[tuple[str, int], int] = defaultdict(int)
        self._dropped: dict[tuple[str, int], int] = defaultdict(int)
        self._window_start = time.monotonic()
        self._lock = Lock()

    def filter(self, record: logging.LogRecord) -> bool:
        key = (record.name, record.levelno)
        with self._lock:
            now = time.monotonic()
            if now - self._window_start >= 1.0:
                self._flush(now)
            if self._counts[key] >= self._max:
                self._dropped[key] += 1
                return False
            self._counts[key] += 1
            return True

    def _flush(self, now: float) -> None:
        for (logger_name, _level), dropped in self._dropped.items():
            if dropped:
                logging.getLogger(logger_name).warning(
                    "rate-limited: dropped %d records in the last second",
                    dropped,
                )
        self._counts.clear()
        self._dropped.clear()
        self._window_start = now


class SampleFilter(logging.Filter):
    """Keep 1 in ``sample_rate`` records; the rest dropped silently.

    For per-event telemetry where individuals don't matter but the
    distribution does. ``random`` is fine here — the choice is not
    security-relevant.
    """

    def __init__(self, *, sample_rate: int = 100) -> None:
        super().__init__()
        if sample_rate < 1:
            raise ValueError("sample_rate MUST be >= 1")
        self._sample_rate = sample_rate

    def filter(self, record: logging.LogRecord) -> bool:
        return random.randint(1, self._sample_rate) == 1  # noqa: S311 — non-crypto


class DedupFilter(logging.Filter):
    """Within ``window_s``, suppress identical (logger, level, msg) tuples.

    Bounded: on each call, keys older than the window are pruned, so memory
    is O(distinct messages seen within one window), not unbounded.
    """

    def __init__(self, window_s: float = 5.0) -> None:
        super().__init__()
        self._window_s = window_s
        self._last: dict[tuple[str, int, str], float] = {}
        self._lock = Lock()

    def filter(self, record: logging.LogRecord) -> bool:
        key = (record.name, record.levelno, record.getMessage())
        now = time.monotonic()
        with self._lock:
            self._prune(now)
            last_seen = self._last.get(key)
            if last_seen is not None and (now - last_seen) < self._window_s:
                return False
            self._last[key] = now
            return True

    def _prune(self, now: float) -> None:
        cutoff = now - self._window_s
        expired = [k for k, t in self._last.items() if t < cutoff]
        for k in expired:
            del self._last[k]
