"""PC-02: sustained-throughput gate for the async logging path.

LG-12 (``test_logging_perf.py``) pins the cost of **one** record.
RA-11 (``test_perf_pins.py``) pins **one** formatter call, **one**
redaction, **one** context lookup. None of those catches a
regression in the *steady-state*: a change that adds 10 %
allocation pressure per record, or 10 % drain-thread CPU, would
slide past the per-call budgets and surface as the
``AsyncJsonHandler.dropped_count`` climbing in production — silent
data loss the per-call tests can't catch.

This test exercises the in-process async-handler path under a
paced load:

  - Build an :class:`AsyncJsonHandler` with an in-memory sink
    (no real I/O — measure the wrapper, not the disk).
  - Emit ~50 000 records over ~5 seconds at a steady rate
    (paced via ``time.sleep`` between batches; bursts mask
    backlog).
  - After the last emit, wait for the drain thread to deliver
    every record to the sink. Measure the drain-completion
    latency.
  - Assert: dropped count = 0; drain-completion latency under
    budget; peak-RSS growth under budget.

Marker: ``perf`` — runs in the nightly sweep, not on every PR.

References:
- ``docs/inprogress/PERFORMANCE_AND_CAPACITY.md`` PC-02 work unit
- ``docs/standards/TESTING.md`` §8.4 (perf marker convention)
- ``docs/standards/TESTING.md`` §8.5 (generous-budget rationale)
"""

from __future__ import annotations

import contextlib
import gc
import logging
import resource
import time

import pytest

from mgf.common.observability.async_handler import AsyncJsonHandler
from mgf.common.observability.json_formatter import JsonFormatter
from mgf.common.observability.redaction import Redactor

pytestmark = pytest.mark.perf


# Test parameters. Picked so the test runs ~5 s in the nightly
# sweep and gives the drain thread realistic room to keep pace
# without saturating the queue.
_TOTAL_RECORDS = 50_000
_TOTAL_DURATION_S = 5.0
# Pace via 1 ms batches: emit N records, sleep 1 ms. This works
# around `time.sleep`'s ~1 ms resolution on Linux. 50 000 / 5 000
# ms-batches = 10 records per ms ≈ 10 000 records/sec sustained —
# above any realistic consumer's log-emit rate.
_RECORDS_PER_BATCH = _TOTAL_RECORDS // int(_TOTAL_DURATION_S * 1000)
_BATCH_INTERVAL_S = _TOTAL_DURATION_S / (_TOTAL_RECORDS / _RECORDS_PER_BATCH)

# After the final emit, the drain thread has to flush the queue
# tail to the sink. 1 s is generous against measured ~ms-scale
# drain-completion times. A breach here means the drain thread
# is permanently behind under steady load — production would
# accumulate dropped records.
_DRAIN_COMPLETION_BUDGET_S = 1.0

# RSS growth across the 5 s window. Per-record allocations are
# reclaimed cleanly (verified pre-PC-02 by PC-05's measurement:
# 10 000 records add zero measurable RSS). 5 MB catches the
# new-leak case without false-positive on CI variance.
_RSS_GROWTH_BUDGET_MB = 5.0


class _RecordingHandler(logging.Handler):
    """Counts records routed through it. Used as the drain target
    so we measure the AsyncJsonHandler path without disk I/O."""

    def __init__(self) -> None:
        super().__init__()
        self.count = 0

    def emit(self, record: logging.LogRecord) -> None:
        # Defensive: a target handler that raises here would
        # surface as records "delivered but counter stuck" — the
        # contextlib.suppress mirrors how the production rotating-
        # file sink handles ENOSPC etc. without raising back to the
        # drain thread.
        with contextlib.suppress(Exception):
            self.count += 1


def _peak_rss_mb() -> float:
    """Return peak RSS in MB. On Linux, ``ru_maxrss`` is in KB."""
    gc.collect()
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0


def test_sustained_throughput_under_load() -> None:
    """50 000 paced records → zero drops, fast drain, bounded RSS."""
    sink = _RecordingHandler()
    sink.setFormatter(JsonFormatter(redactor=Redactor.default()))
    handler = AsyncJsonHandler(target=sink, max_queue_size=10_000)

    logger = logging.getLogger("mgf.test.pc-02")
    logger.setLevel(logging.INFO)
    logger.addHandler(handler)
    # Don't propagate to root — keeps the test isolated from
    # whatever root config the conftest set up.
    logger.propagate = False

    try:
        # Warm-up: prime the drain thread + import-level caches.
        for _ in range(100):
            logger.info("warmup", extra={"phase": "pre"})
        # Wait for the warm-up to drain so the measurement window
        # starts clean.
        deadline = time.perf_counter() + 1.0
        while sink.count < 100 and time.perf_counter() < deadline:
            time.sleep(0.01)

        rss_before = _peak_rss_mb()
        sink.count = 0

        # The paced emit loop. Batches keep CPU scheduling sane
        # — busy-waiting between sub-ms record emits would skew
        # the measurement.
        n_batches = _TOTAL_RECORDS // _RECORDS_PER_BATCH
        start = time.perf_counter()
        for batch in range(n_batches):
            for _ in range(_RECORDS_PER_BATCH):
                logger.info(
                    "sustained record",
                    extra={"batch": batch, "phase": "load"},
                )
            # Pace: aim for batch B to start at start + B * _BATCH_INTERVAL_S.
            deadline = start + (batch + 1) * _BATCH_INTERVAL_S
            now = time.perf_counter()
            if now < deadline:
                time.sleep(deadline - now)

        # Drain-completion latency: wait until the sink has seen
        # every record OR the budget elapses.
        emitted = n_batches * _RECORDS_PER_BATCH
        drain_start = time.perf_counter()
        drain_deadline = drain_start + _DRAIN_COMPLETION_BUDGET_S
        while sink.count < emitted and time.perf_counter() < drain_deadline:
            time.sleep(0.005)
        drain_completion_s = time.perf_counter() - drain_start

        rss_after = _peak_rss_mb()
        rss_growth_mb = rss_after - rss_before

        # Assertions. Order matters: drops first (silent data loss
        # is the worst-case symptom); then drain (slow drain
        # means the queue is full but recovering); then RSS (a
        # leak compounds).
        assert handler.dropped_count == 0, (
            f"PC-02: AsyncJsonHandler dropped {handler.dropped_count} "
            f"records across {_TOTAL_RECORDS} paced emits. Silent "
            f"data loss — the queue is bounded (default 10 000) and "
            f"the drain thread can't keep pace. Investigate "
            f"src/mgf/common/observability/async_handler.py drain "
            f"loop + the target handler's emit cost."
        )

        assert sink.count == emitted, (
            f"PC-02: drain incomplete after {drain_completion_s:.2f} s: "
            f"{sink.count}/{emitted} records delivered to sink "
            f"(budget {_DRAIN_COMPLETION_BUDGET_S:.1f} s). The drain "
            f"thread is permanently behind under steady load. "
            f"Production would accumulate dropped records as the "
            f"queue fills and overflows."
        )

        assert rss_growth_mb < _RSS_GROWTH_BUDGET_MB, (
            f"PC-02: peak RSS grew {rss_growth_mb:.1f} MB across the "
            f"5-second window (budget {_RSS_GROWTH_BUDGET_MB:.1f} MB). "
            f"A leak in the per-record path is the canonical cause — "
            f"check for handler-side buffers that don't release, "
            f"JsonFormatter caches that don't bound, or a Redactor "
            f"pattern table that grows per record."
        )
    finally:
        logger.removeHandler(handler)
        handler.close()
