"""``mgf.common.pytest_plugin`` — pytest fixtures for testing apps that use mgf-common.

Auto-registered via the ``pytest11`` entry point in ``pyproject.toml``.
After installing ``mgf-common`` (any extra) **from PyPI**, every pytest
run sees the fixtures below without further configuration. Disable
per-test with ``-p no:mgf_common``.

Editable-install caveat (PAPER-23)
----------------------------------

When mgf-common is consumed as an editable install via
``[tool.uv.sources]`` (or any tool that wires the source tree into a
sibling project without writing the dist-info ``entry_points.txt``),
the ``pytest11`` registration is silently skipped — pytest reads
entry points from installed dist-info, and ``uv sync`` against an
editable source target does not currently emit that file for sibling
projects. The fixtures still exist; they're just not auto-loaded.

Two ways to opt in explicitly when this happens:

  1. **Recommended** — add ``pytest_plugins`` to your project's
     ``tests/conftest.py``::

         pytest_plugins = ["mgf.common.pytest_plugin"]

     Pytest discovers this at collection start and loads the plugin
     before fixtures are resolved. Survives ``uv sync`` re-runs and is
     more discoverable than the entry-point form (a reader of conftest
     can see the dependency at a glance).

  2. **One-shot** — ``pytest -p mgf.common.pytest_plugin`` per
     invocation. Useful for ad-hoc verification that the plugin is on
     PYTHONPATH at all.

PyPI-installed consumers do NOT need either — the entry point fires
during ``pip install`` / ``uv pip install`` and pytest finds the
plugin automatically.

**The silence is loud now (M2 Wave-2 §1.5):** when ``mgf.common`` is
used inside a pytest run while this plugin never loaded and no
``pytest11`` entry point is visible, :mod:`mgf.common._plugin_probe`
emits one ``MgfPytestPluginMissingWarning`` per process — under the
federation's ``filterwarnings = ["error"]`` default that is a hard
failure, not a silent no-op. An explicit ``-p no:mgf_common`` (entry
point visible, deliberately disabled) stays silent;
``MGF_COMMON_NO_PLUGIN_WARN=1`` is the kill switch for intentionally
plugin-less editable setups.

Fixtures
========

Function-scoped:

  - :func:`mgf_settings` — a fresh ``MgfSettings.testing()`` instance.
  - :func:`mgf_context` — a fresh ``AppContext`` from
    ``bootstrap_for_test``. Auto-shuts-down at test end.
  - :func:`mgf_context_with_capture` — like :func:`mgf_context` but
    with ``capture_logs=True, capture_spans=True``. Span capture
    requires :func:`mgf_otel_provider` to be active (depend on it
    explicitly, or run in the default session config below).
  - :func:`redaction_recorder` — wraps the live :class:`Redactor`
    so tests can assert what got redacted.

Session-scoped:

  - :func:`mgf_otel_provider` — installs an SDK ``TracerProvider``
    once per session. OTel's ``set_tracer_provider`` is one-shot per
    process; this fixture claims the slot for the whole test run.
    Skipped if the ``[observability]`` extra is not installed.

Why this exists
===============

The strategic review showed that every consumer reinvents test-fixture
boilerplate. The audit (v0.5 Phase A) flagged ``bootstrap_for_test`` as
unused by every consumer — not because they don't want it, but because
nobody wires the fixture themselves. Auto-registering through the
plugin closes that gap.

Tests of this module use pytest's ``pytester`` fixture to exercise
the plugin in a sub-pytest session, isolating from the live test run.

See also
========

- ``docs/recipes/testing.md`` — full walk-through.
- :func:`mgf.common.bootstrap_for_test` — the underlying helper.
"""

from __future__ import annotations

import os
import time
from typing import TYPE_CHECKING, Any, NamedTuple

import pytest

from mgf.common._test_helpers import bootstrap_for_test
from mgf.common.observability import redaction
from mgf.common.settings import MgfSettings

if TYPE_CHECKING:
    from collections.abc import Generator, Iterator
    from pathlib import Path

    from mgf.common._lifecycle import AppContext


# ---------------------------------------------------------------------------
# Per-test perf attribution (TS-28) — the writer half of the perf contract.
#
# Opt-in via ``--mgf-perf`` or ``MGF_PERF=1`` (the federation test supervisor
# sets the env when invoked with ``mtest --perf``). Per test we run cProfile
# (function-level) AND activate ``perf_span`` collection (section/log-level),
# then write one JSON artifact the supervisor reads out-of-process. ALWAYS
# fail-open: perf capture must never change a test's outcome.
# ---------------------------------------------------------------------------

_PERF_TRUE = frozenset({"1", "true", "TRUE", "yes", "on"})


class _PerfCfg(NamedTuple):
    perf_dir: Path
    top_n: int


def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("mgf-common")
    group.addoption(
        "--mgf-perf",
        action="store_true",
        default=False,
        help="Capture per-test perf attribution (cProfile functions + "
        "perf_span sections) as artifacts for the test supervisor (TS-28).",
    )
    group.addoption(
        "--mgf-perf-dir",
        action="store",
        default=None,
        help="Directory for perf artifacts "
        "(default: $MGF_PERF_DIR or <rootdir>/.gates/last-run/perf).",
    )
    group.addoption(
        "--mgf-perf-top-n",
        action="store",
        type=int,
        default=None,
        help="Max cProfile function rows per test (default: 15).",
    )


def _perf_config(config: pytest.Config) -> _PerfCfg | None:
    """Resolve perf config from flags/env, or None when capture is off."""
    from pathlib import Path

    from mgf.common.testing import _perf

    enabled = bool(config.getoption("--mgf-perf", default=False)) or (
        os.environ.get("MGF_PERF") in _PERF_TRUE
    )
    if not enabled:
        return None
    raw_dir = config.getoption("--mgf-perf-dir", default=None) or os.environ.get("MGF_PERF_DIR")
    perf_dir = Path(raw_dir) if raw_dir else Path(config.rootpath) / ".gates" / "last-run" / "perf"
    top_n_opt = config.getoption("--mgf-perf-top-n", default=None)
    if top_n_opt is not None:
        top_n = int(top_n_opt)
    else:
        env_top = os.environ.get("MGF_PERF_TOP_N", "")
        top_n = int(env_top) if env_top.isdigit() else _perf.DEFAULT_TOP_N
    return _PerfCfg(perf_dir=perf_dir, top_n=top_n)


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item: pytest.Item) -> Generator[None, None, None]:
    """Wrap the call phase to capture per-test perf attribution (fail-open)."""
    cfg = _perf_config(item.config)
    if cfg is None:
        yield
        return

    import contextlib

    from mgf.common.testing import _perf

    profiler = None
    try:
        import cProfile

        profiler = cProfile.Profile()
    except Exception:  # fail-open — perf must never break a test.
        profiler = None

    t0 = time.perf_counter()
    with _perf.collect_spans() as collector:
        if profiler is not None:
            try:
                profiler.enable()
            except Exception:  # fail-open.
                profiler = None
        try:
            yield
        finally:
            if profiler is not None:
                with contextlib.suppress(Exception):
                    profiler.disable()

    # Build + write the artifact out-of-band; never let it touch the outcome.
    with contextlib.suppress(Exception):
        total_ms = (time.perf_counter() - t0) * 1000.0
        functions = (
            _perf.functions_from_profile(profiler, cfg.top_n) if profiler is not None else ()
        )
        summary = _perf.PerfSummary(
            test_id=item.nodeid,
            total_ms=total_ms,
            functions=functions,
            spans=tuple(collector.spans),
        )
        _perf.write_perf_artifact(cfg.perf_dir, summary)


# ---------------------------------------------------------------------------
# Function-scoped fixtures.
# ---------------------------------------------------------------------------


@pytest.fixture
def mgf_settings() -> MgfSettings:
    """Return a fresh :class:`MgfSettings.testing` instance for one test.

    The testing preset disables OTel auto-init, sets log level to
    WARNING, and turns off TTY-detection (deterministic regardless of
    the test runner's stdout state).
    """
    return MgfSettings.testing()


@pytest.fixture
def mgf_context(mgf_settings: MgfSettings) -> Iterator[AppContext]:
    """Yield a bootstrapped :class:`AppContext` for one test.

    The context is fully wired: identity is set, structured logging is
    active at WARNING level, the crash reporter is installed, OTel is
    disabled. ``ctx.shutdown()`` runs LIFO rollback when the test ends.

    Use this instead of calling :func:`mgf.common.bootstrap` directly
    in tests — the rollback semantics protect against state bleed
    between tests.
    """
    with bootstrap_for_test(settings=mgf_settings) as ctx:
        yield ctx


@pytest.fixture
def mgf_context_with_capture(
    mgf_settings: MgfSettings,
    mgf_otel_provider: None,
) -> Iterator[AppContext]:
    """Yield an :class:`AppContext` with log + span capture enabled.

    Tests then read ``ctx.captured_logs`` (list of ``LogRecord``) and
    ``ctx.captured_spans`` (list of OTel span proxies). Useful for
    asserting on observability output without parsing real exporter
    payloads.

    Depends on :func:`mgf_otel_provider` (session-scoped) for the
    span side. If the ``[observability]`` extra isn't installed, the
    OTel-side fixture skips and span capture won't work — log capture
    still works.
    """
    with bootstrap_for_test(
        settings=mgf_settings,
        capture_logs=True,
        capture_spans=True,
    ) as ctx:
        yield ctx


@pytest.fixture
def redaction_recorder(
    monkeypatch: pytest.MonkeyPatch,
) -> Iterator[list[dict[str, Any]]]:
    """Record every :meth:`Redactor.redact` call's pre/post payload.

    Yields a list that grows as the active redactor processes payloads.
    Each entry is ``{"input_keys": [...], "output_keys": [...],
    "input_size": int, "output_size": int}``. Useful for asserting
    that a specific Authorization header was redacted in a test that
    exercises the HTTP / log path.

    Implementation: monkeypatches :meth:`Redactor.redact` to record +
    delegate. Restored at fixture teardown.
    """
    records: list[dict[str, Any]] = []
    original = redaction.Redactor.redact

    def _recording_redact(self: redaction.Redactor, payload: dict[str, Any]) -> None:
        before_keys = list(payload.keys())
        before_size = len(payload)
        original(self, payload)
        after_keys = list(payload.keys())
        records.append(
            {
                "input_keys": before_keys,
                "output_keys": after_keys,
                "input_size": before_size,
                "output_size": len(payload),
            }
        )

    monkeypatch.setattr(redaction.Redactor, "redact", _recording_redact)
    yield records
    # monkeypatch teardown restores the original automatically.


# ---------------------------------------------------------------------------
# Session-scoped fixtures.
# ---------------------------------------------------------------------------


@pytest.fixture(scope="session")
def mgf_otel_provider() -> Iterator[None]:
    """Install an SDK :class:`TracerProvider` once per test session.

    OTel's ``trace.set_tracer_provider`` is **one-shot per process** —
    once set, every subsequent call is a silent no-op. Tests that need
    span capture MUST share a single provider for the whole session;
    this fixture is the canonical place to claim that slot.

    Skipped if the ``[observability]`` extra (`opentelemetry-sdk`) is
    not installed. Tests depending on this fixture will be skipped
    (with a useful reason); they SHOULD use :func:`mgf_context_with_capture`
    rather than depend on this directly.

    Yields ``None`` — the provider is read via
    ``opentelemetry.trace.get_tracer_provider()``.

    Cleanup: NONE. The provider remains for the rest of the session;
    OTel offers no public reset API. This is documented determinism
    leak the strategic review flagged (self-review §1) — the v0.5
    plan accepts the limitation rather than reach into OTel internals.
    """
    try:
        from opentelemetry import trace
        from opentelemetry.sdk.trace import TracerProvider
    except ImportError:
        pytest.skip("mgf-common[observability] not installed; OTel fixtures unavailable")

    # If a provider is already active (e.g., the consumer's own
    # session-scoped fixture beat us to it), defer to it.
    current = trace.get_tracer_provider()
    if isinstance(current, TracerProvider):
        yield
        return

    provider = TracerProvider()
    trace.set_tracer_provider(provider)
    yield
    # No teardown — see docstring.


# ---------------------------------------------------------------------------
# caplog convenience helper.
# ---------------------------------------------------------------------------


def decode_json_logs(caplog: pytest.LogCaptureFixture) -> list[dict[str, Any]]:
    """Return JSON-decoded records from pytest's ``caplog``.

    For tests using ``MgfSettings.testing()`` with
    ``log_format=LogFormat.JSON``, every captured ``LogRecord``'s
    formatted message is a JSON string. This helper decodes the
    records that ARE JSON and silently skips the others.

    Returns a fresh list on each call; safe to call multiple times in
    a single test.

    Not a fixture — the dependence on ``caplog`` plus pytest's
    yield-then-populate ordering makes the fixture form awkward.
    Call directly::

        def test_something(caplog: pytest.LogCaptureFixture) -> None:
            ... # do work
            decoded = decode_json_logs(caplog)
            assert decoded[0]["level"] == "WARNING"
    """
    import json

    decoded: list[dict[str, Any]] = []
    for record in caplog.records:
        try:
            decoded.append(json.loads(record.getMessage()))
        except (ValueError, TypeError):
            continue
    return decoded


__all__ = [
    "decode_json_logs",
    "mgf_context",
    "mgf_context_with_capture",
    "mgf_otel_provider",
    "mgf_settings",
    "redaction_recorder",
]
