"""Test-helpers — :func:`bootstrap_for_test`.

v0.4 Phase F. A thin wrapper over :func:`mgf.common.bootstrap`
that uses :meth:`MgfSettings.testing` (Phase C) by default —
WARNING-level logging, OTel disabled, deterministic preset. The
right shape for unit + integration tests that want a real
:class:`AppContext` without the noise / external dependencies of
production wiring.

v0.4.2 (V04-03) added optional ``capture_logs`` / ``capture_spans``
flags. When set, the helper installs an in-memory log handler
and / or an OTel ``InMemorySpanExporter``; the captured records
are accessible via ``ctx.captured_logs`` and ``ctx.captured_spans``
in the test body. Both default off — unchanged ergonomics for
the v0.4-Phase-F call shape.

Pair with pytest fixtures::

    import pytest

    from mgf.common import bootstrap_for_test, AppContext

    @pytest.fixture
    def app_ctx() -> AppContext:
        with bootstrap_for_test() as ctx:
            yield ctx

    def test_my_thing(app_ctx: AppContext) -> None:
        # ... ctx.settings is an MgfSettings.testing() instance
        ...

    @pytest.fixture
    def app_ctx_with_capture() -> AppContext:
        with bootstrap_for_test(capture_logs=True, capture_spans=True) as ctx:
            yield ctx

    def test_observability(app_ctx_with_capture: AppContext) -> None:
        # ... do work that emits logs + spans
        assert any("expected" in r.msg for r in app_ctx_with_capture.captured_logs)
        assert app_ctx_with_capture.captured_spans[0].name == "operation.X"

This is closure of FEEDBACK design ref §14.8 + V04-03.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

from mgf.common._lifecycle import bootstrap
from mgf.common.exceptions import VaultError as _VaultError  # noqa: F401  (used as fallback type)

if TYPE_CHECKING:
    from mgf.common._lifecycle import AppContext
    from mgf.common.settings import MgfSettings


class _InMemoryLogHandler(logging.Handler):
    """Append every emitted ``LogRecord`` to a backing list.

    No formatting; consumers inspect the raw ``LogRecord`` fields
    (``record.msg``, ``record.levelname``, ``record.name``,
    ``record.exc_info``, etc.) directly.

    Set ``level=NOTSET`` so every record reaches the handler;
    record-level filtering happens at the logger / handler-level
    upstream. The test typically wants to see EVERY record, not
    just those at WARNING+ (which is the default
    ``MgfSettings.testing()`` level).
    """

    def __init__(self, buffer: list[logging.LogRecord]) -> None:
        super().__init__(level=logging.NOTSET)
        self._buffer = buffer

    def emit(self, record: logging.LogRecord) -> None:
        self._buffer.append(record)


def _wire_log_capture(ctx: AppContext) -> list[logging.LogRecord]:
    """Install an in-memory log handler on the root logger.

    Returns the buffer list (also stashed on
    ``ctx.captured_logs``). Registers a rollback so the handler
    is removed when ``ctx.shutdown()`` runs.

    The handler is added at the root level so it captures every
    log record regardless of which logger emitted it. The root
    logger's level is also lowered to NOTSET while capturing so
    DEBUG records reach the handler — without this, the
    ``MgfSettings.testing()`` default WARNING level would drop
    them at the root.
    """
    buffer: list[logging.LogRecord] = []
    handler = _InMemoryLogHandler(buffer)
    root = logging.root
    prior_level = root.level
    root.addHandler(handler)
    root.setLevel(logging.NOTSET)

    def _remove() -> None:
        root.removeHandler(handler)
        root.setLevel(prior_level)

    if ctx._wiring is not None:
        ctx._wiring.rollbacks.append(("test_log_capture", _remove))
    return buffer


def _wire_span_capture(ctx: AppContext) -> Any:
    """Attach an OTel ``InMemorySpanExporter`` to the current SDK
    ``TracerProvider``.

    Requires an SDK ``TracerProvider`` to ALREADY be active —
    either via a prior :func:`mgf.common.bootstrap` call with
    ``OtelSettings(enabled=True)`` or via a session-scoped fixture
    (the project's ``tests/unit/observability/conftest.py`` shows
    the canonical pattern). Raises if no SDK provider is set.

    Why we don't auto-install a TracerProvider: OTel's
    ``trace.set_tracer_provider`` is one-shot per process — once
    set, every subsequent call is silently a no-op. Auto-
    installing here would claim the slot for the rest of the
    test session and break ANY test that later expects to
    install its own provider (e.g., session-scoped fixtures in
    other test directories).

    Requires ``mgf-common[observability]``; raises a clear error
    if the SDK isn't installed.
    """
    try:
        from opentelemetry import trace
        from opentelemetry.sdk.trace import TracerProvider
        from opentelemetry.sdk.trace.export import SimpleSpanProcessor
        from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
            InMemorySpanExporter,
        )
    except ImportError as e:
        raise RuntimeError(
            "bootstrap_for_test(capture_spans=True) requires the "
            "[observability] extra: pip install 'mgf-common[observability]'"
        ) from e

    provider = trace.get_tracer_provider()
    if not isinstance(provider, TracerProvider):
        raise RuntimeError(
            "bootstrap_for_test(capture_spans=True) requires an SDK "
            "TracerProvider to be active before the call. Either:\n"
            "  - call mgf.common.bootstrap(..., settings=MgfSettings("
            "otel=OtelSettings(enabled=True))) earlier in your test "
            "setup, OR\n"
            "  - install a TracerProvider in a session-scoped "
            "fixture (see tests/unit/observability/conftest.py for "
            "the canonical pattern).\n"
            "Auto-install was rejected because OTel's "
            "set_tracer_provider is one-shot per process; auto-"
            "installing here would claim the slot for the entire "
            "test session and break later session-scoped fixtures."
        )

    exporter = InMemorySpanExporter()
    processor = SimpleSpanProcessor(exporter)
    provider.add_span_processor(processor)

    def _shutdown() -> None:
        # SimpleSpanProcessor flushes synchronously on shutdown.
        # The processor remains attached to the provider (OTel
        # SDK has no remove API), but post-shutdown it no-ops on
        # every emit so subsequent tests aren't affected.
        processor.shutdown()

    if ctx._wiring is not None:
        ctx._wiring.rollbacks.append(("test_span_capture", _shutdown))
    return exporter


def bootstrap_for_test(
    app_name: str = "test-app",
    app_version: str = "0.0.1",
    *,
    settings: MgfSettings | None = None,
    capture_logs: bool = False,
    capture_spans: bool = False,
) -> AppContext:
    """Bootstrap with test-friendly defaults.

    Equivalent to::

        bootstrap(
            app_name=app_name,
            app_version=app_version,
            settings=settings or MgfSettings.testing(),
        )

    Defaults (from :meth:`MgfSettings.testing`):
      - ``preset="explicit"`` — deterministic; no auto-detection
        of TTY / journald / OTel endpoint.
      - ``logging.level="WARNING"`` — cuts test-suite noise.
      - ``otel.enabled=False`` — no real spans, no exporter overhead.
      - ``crash.sinks=["local"]`` — no remote crash shipping.

    Usage as a context manager::

        with bootstrap_for_test() as ctx:
            do_work_under_test()

    With opt-in capture (v0.4.2 — closes FEEDBACK V04-03)::

        with bootstrap_for_test(capture_logs=True, capture_spans=True) as ctx:
            do_work_under_test()
            assert any("expected" in r.msg for r in ctx.captured_logs)
            assert ctx.captured_spans[0].name == "operation.X"

    Parameters
    ----------
    app_name
        Default ``"test-app"``. Override for tests that exercise
        identity-dependent code paths (e.g., log file paths).
    app_version
        Default ``"0.0.1"``. Override when version-aware tests
        need a specific value.
    settings
        Optional pre-built :class:`MgfSettings`. When ``None``,
        :meth:`MgfSettings.testing` is used.
    capture_logs
        v0.4.2 (V04-03). When ``True``, install an in-memory
        ``logging.Handler`` on the root logger; captured
        ``LogRecord`` objects are accessible via
        ``ctx.captured_logs``. Root-logger level is lowered to
        NOTSET for the duration so DEBUG records reach the
        handler. Default ``False``.
    capture_spans
        v0.4.2 (V04-03). When ``True``, install an OTel
        ``InMemorySpanExporter``; spans are accessible via
        ``ctx.captured_spans`` (a property — each access returns
        a fresh snapshot). Requires ``mgf-common[observability]``;
        raises ``RuntimeError`` if the SDK is unavailable.
        Default ``False``.

        **Per-process accumulation (RA-19):** the OTel SDK has
        no API to remove a span processor once attached. Each
        ``capture_spans=True`` call adds a ``SimpleSpanProcessor``
        to the active ``TracerProvider``; the rollback shuts the
        processor down (post-shutdown emits no-op) but the
        processor object stays in the provider's processor
        list for the rest of the process. For a test session
        that calls ``bootstrap_for_test(capture_spans=True)``
        many times, the list grows linearly and every emitted
        span walks the full list (cheap per-entry, but not free
        — ~N µs per span at N=1000 processors). If a fixture
        scope allows it, prefer a session-scoped TracerProvider
        + ``capture_spans=True`` once, rather than per-test.

    Returns
    -------
    AppContext
        Same shape as :func:`bootstrap`'s return — a context
        manager AND an addressable context object. Capture fields
        (``captured_logs`` / ``captured_spans``) populated when
        the corresponding flag is set.
    """
    # PAPER-23 honesty probe (M2 Wave-2 §1.5): the canonical
    # test-helper path re-probes for the silent-plugin condition —
    # covers suites that imported mgf.common before pytest started
    # (where the import-time probe couldn't tell). One warning per
    # process; no-op outside pytest.
    from mgf.common._plugin_probe import warn_if_plugin_silently_absent

    warn_if_plugin_silently_absent()
    if settings is None:
        # PC-01: lazy-import keeps `import mgf.common` cheap by
        # not eagerly pulling the pydantic + pydantic-settings
        # chain. `_test_helpers` is re-exported by
        # `mgf.common.__init__` so the eager import here was the
        # second-largest contributor to cold-start cost.
        from mgf.common.settings import MgfSettings

        settings = MgfSettings.testing()
    ctx = bootstrap(
        app_name=app_name,
        app_version=app_version,
        settings=settings,
    )

    if capture_logs:
        ctx.captured_logs = _wire_log_capture(ctx)
    if capture_spans:
        ctx._captured_span_exporter = _wire_span_capture(ctx)

    return ctx


__all__ = ["bootstrap_for_test"]
