"""Lifecycle: ``bootstrap()`` + ``AppContext`` + ``current_context()``.

See: ``docs/reference/lifecycle.md``.

The single canonical entry point for ``mgf-common``. Replaces the
v0.1 five-call sequence (``configure`` + ``setup_logging`` +
``setup_otel`` + ``install_excepthook`` +
``install_threading_excepthook``) with one transactional context
manager.

Design refs (in ``docs/CLOSED_BOX_REDESIGN.md``):

- §4.1   The lifecycle surface.
- §14.2  Bootstrap atomicity — transactional setup with rollback.
- §14.3  Bootstrap-time logging — deferred replay buffer.
- §14.5  Hot-reload via ``AppContext.reload(new_settings)``.
- §14.10 Bootstrap idempotency — replace + AUDIT log.

This module is the load-bearing surface of v0.3. The v0.1 APIs
(``configure``, ``setup_logging``, ``setup_otel``,
``install_excepthook``, ``install_threading_excepthook``) keep
working unchanged in Phase 1 so existing tests + consumer code
stay green; the deprecation warnings start in a focused later
phase.

Typical use::

    import mgf.common

    with mgf.common.bootstrap(app_name="myapp", app_version="1.0"):
        do_work()

For long-running services without a natural ``with`` scope::

    ctx = mgf.common.bootstrap(app_name="myapp", app_version="1.0")
    try:
        do_work()
    finally:
        ctx.shutdown()

Note: when used as ``ctx = bootstrap(...)`` (without ``with``),
the returned object is the AppContext itself, NOT the
contextmanager wrapper. The :func:`bootstrap` function checks
its call site and adapts (see implementation).
"""

from __future__ import annotations

import contextlib
import contextvars
import logging
import os
import sys
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from mgf.common.exceptions import BootstrapError

if TYPE_CHECKING:
    from collections.abc import Callable, Iterator

    from mgf.common.settings import MgfSettings


def _default_mgf_settings() -> MgfSettings:
    """PC-01: lazy-import default factory for ``AppContext.settings``.

    Defers the heavy pydantic + pydantic-settings provider-chain
    import (≈90 ms cumulative) out of `import mgf.common` and into
    the first `bootstrap()` call — i.e. into the path where a
    consumer is already opting into the full lifecycle wiring.

    Why a factory at module scope rather than inlining the import
    in `AppContext`'s dataclass: dataclass `default_factory` needs
    a callable evaluated at class-definition time; binding it to a
    module-level function that does its own deferred import is
    the cleanest shape (and avoids re-importing on every
    AppContext construction — `sys.modules` caches).
    """
    from mgf.common.settings import MgfSettings as _MgfSettings

    return _MgfSettings()


LOG = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Active-context discovery
# ---------------------------------------------------------------------------
#
# Hybrid model per FEEDBACK §6 Option C:
#   - ``_GLOBAL_CONTEXT``    → process-wide singleton, set by
#                              ``bootstrap()``. The 95 % case (single-
#                              app processes).
#   - ``_CONTEXT_VAR``       → per-task / per-thread override pushed
#                              by ``with AppContext(...):``. The 5 %
#                              case (plug-in architectures, monorepo
#                              test runs).
#
# ``current_context()`` consults the contextvar first, falls back
# to the global. Reads are O(1); fast path is a single
# ``ContextVar.get()`` call (~30 ns).


_GLOBAL_CONTEXT: AppContext | None = None
_CONTEXT_VAR: contextvars.ContextVar[AppContext | None] = contextvars.ContextVar(
    "mgf_common_context",
    default=None,
)
# RA-03: ``_WIRING_LOCK`` serialises every mutation of the shared
# global wiring state — the root logger handler list,
# ``sys.excepthook``, the OTel tracer provider, the identity cache,
# and the process-wide ``_GLOBAL_CONTEXT`` itself. Held by both
# ``bootstrap()`` (initial wiring + replace) and
# ``AppContext.reload()`` (re-wire). Without this, two threads
# calling ``bootstrap()`` race on ``logging.root.addHandler()`` /
# ``removeHandler()`` (the stdlib logging module is not internally
# thread-safe for handler-list mutations); the symptom is missing
# or duplicated log records depending on which thread's handlers
# survive the race.
#
# Named ``_WIRING_LOCK`` (not the historical ``_RELOAD_LOCK``)
# because it now guards both reload AND initial bootstrap. A plain
# ``Lock`` is sufficient — bootstrap and reload are not recursive
# into themselves (``_bootstrap_inner`` is reachable only via
# ``bootstrap`` or ``reload``, never via its own wiring steps).
_WIRING_LOCK = threading.Lock()

# v0.5 Phase D: tracks whether ``current_context()`` has already
# emitted its pre-bootstrap UnconfiguredWarning. One-shot per process
# to avoid spam; the warning fires the first time ``current_context()``
# is called before ``bootstrap()`` (which silently returned ``None``
# in v0.4 and earlier — leaving downstream "if ctx := ..." patterns
# silently failing closed).
_CURRENT_CONTEXT_UNCONFIGURED_WARNED: bool = False


# ---------------------------------------------------------------------------
# Wiring handles — internal state for shutdown / rollback
# ---------------------------------------------------------------------------


@dataclass
class _WiringHandles:
    """Tracks every reversible side-effect of bootstrap.

    The lifecycle module appends ``(name, rollback_fn)`` tuples
    here as each wiring step succeeds. ``shutdown()`` (and
    ``bootstrap()``'s rollback path) walk this list LIFO, calling
    each ``rollback_fn`` with ``contextlib.suppress(Exception)``
    so a single failing rollback doesn't block the others.

    Internal — consumers never touch this directly.
    """

    rollbacks: list[tuple[str, Callable[[], None]]] = field(default_factory=list)
    prior_excepthook: Any = None
    prior_threading_excepthook: Any = None
    prior_app_name: str = "app"
    prior_app_version: str = "unknown"
    prior_logger_levels: dict[str, int] = field(default_factory=dict)
    bootstrap_log_buffer: _BootstrapLogBuffer | None = None


# ---------------------------------------------------------------------------
# AppContext — frozen identity + settings snapshot
# ---------------------------------------------------------------------------


@dataclass
class AppContext:
    """Per-tenant identity + settings for ``mgf-common``.

    Two construction modes:

    1. **Bootstrap-owned context.** Created by :func:`bootstrap`;
       carries ``_wiring`` (the rollback handles for the wired
       observability subsystems). Sets the process-wide global on
       construction; ``__exit__`` shuts down the wiring stack
       LIFO.

    2. **Scoped context (multi-tenancy).** Constructed directly —
       ``AppContext(app_name="plugin", app_version="1.0")`` — with
       NO wiring. Pushes itself onto the per-task contextvar on
       ``__enter__``; pops on ``__exit__``. Observability functions
       resolve identity via :func:`current_context` which checks
       the contextvar first, then the global. The scoped context
       is read-only — it does NOT re-wire logging / OTel / crash
       sinks; those stay parent-owned.

       Closes FEEDBACK API-02 (the v0.4-era last open 🔴 blocker)
       per Option C in `FEEDBACK.md` §6 — hybrid:
       process-wide global for the 95% case + contextvar for the
       5% library-in-library use cases.

    Usage::

        # Bootstrap-owned (the 95% case)
        with mgf.common.bootstrap(app_name="myapp") as ctx:
            do_work()

        # Scoped sub-context (the 5% case)
        with mgf.common.bootstrap(app_name="myapp") as ctx:
            do_work()

            # Plugin scope: identity routes to "myapp-plugin" for
            # the duration of the with-block
            with AppContext("myapp-plugin", "1.0"):
                run_plugin_code()

            # After: current_context() routes to "myapp" again
            do_more_work()

    Attributes
    ----------
    app_name
        Short app identifier. Used as OTel ``service.name``, log-
        file directory, env-var prefix for consumer-app settings.
    app_version
        Version string. Pass your top-level package's ``__version__``.
    settings
        The active :class:`mgf.common.settings.MgfSettings`
        snapshot. Defaults to ``MgfSettings()`` for scoped
        contexts; bootstrap-owned contexts get the user-supplied
        settings.
    _wiring
        Internal :class:`_WiringHandles`. Non-None for bootstrap-
        owned contexts; ``None`` for scoped sub-contexts. Not part
        of the public API.

    Threading model
    ---------------
    - Bootstrap-owned construction: single-threaded.
    - ``shutdown()``: single-threaded — call from the same thread
      that called bootstrap.
    - ``reload()``: lock-protected. Raises ``RuntimeError`` on a
      scoped context (no wiring to re-apply).
    - Scoped contexts (with-block): contextvars propagate across
      ``await`` points; safe in async code. Each task / thread
      has its own contextvar value (PEP 567).

    See ``docs/CLOSED_BOX_REDESIGN.md`` §14.9 for the full
    threading-model table.
    """

    app_name: str
    app_version: str
    settings: MgfSettings = field(default_factory=_default_mgf_settings)
    _wiring: _WiringHandles | None = field(default=None, repr=False, compare=False)
    _shut_down: bool = field(default=False, repr=False, compare=False)
    _cv_token: contextvars.Token[AppContext | None] | None = field(
        default=None,
        init=False,
        repr=False,
        compare=False,
    )

    # v0.4.2 (V04-03) — opt-in test-capture fields. Populated by
    # ``bootstrap_for_test(capture_logs=..., capture_spans=...)``;
    # always None on bootstrap-owned + scoped contexts. Public
    # for assertion access in tests.
    captured_logs: list[logging.LogRecord] | None = field(
        default=None,
        repr=False,
        compare=False,
    )
    """List of captured ``logging.LogRecord`` objects when the
    context was created via ``bootstrap_for_test(capture_logs=True)``;
    ``None`` otherwise."""

    _captured_span_exporter: Any = field(
        default=None,
        repr=False,
        compare=False,
    )

    @property
    def captured_spans(self) -> list[Any]:
        """Captured OTel spans when the context was created via
        ``bootstrap_for_test(capture_spans=True)``; empty list
        otherwise.

        Each call returns a fresh snapshot of finished spans (the
        underlying ``InMemorySpanExporter`` accumulates across
        calls; this property just snapshots). v0.4.2 (V04-03).
        """
        if self._captured_span_exporter is None:
            return []
        return list(self._captured_span_exporter.get_finished_spans())

    def __enter__(self) -> AppContext:
        """Enter the context.

        - **Bootstrap-owned** (``_wiring is not None``): return
          ``self`` so ``with bootstrap(...) as ctx:`` yields the
          live context directly. The dual-mode shape (with-block
          OR plain ``ctx = bootstrap(...)``) is preserved.
        - **Scoped** (``_wiring is None``): push ``self`` onto the
          per-task contextvar. ``current_context()`` returns
          ``self`` for the duration of the ``with`` block.
        """
        if self._wiring is None:
            self._cv_token = _CONTEXT_VAR.set(self)
        return self

    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
        """Exit the context.

        - **Bootstrap-owned**: run :meth:`shutdown` (LIFO rollback
          of every wiring step).
        - **Scoped**: pop the contextvar; ``current_context()``
          reverts to whatever was active before this scope was
          entered (typically the bootstrap-owned global).
        """
        if self._wiring is None:
            if self._cv_token is not None:
                _CONTEXT_VAR.reset(self._cv_token)
                self._cv_token = None
        else:
            self.shutdown()

    def shutdown(self) -> None:
        """Reverse every wiring step LIFO, restoring pre-bootstrap state.

        Idempotent — calling twice is a no-op (the second call
        finds ``_shut_down=True`` and returns).

        On a **scoped** context (``_wiring is None``) this is a
        no-op: scoped sub-contexts don't own any wiring to tear
        down. The contextvar is popped by ``__exit__``.

        Each rollback is wrapped in ``try / except`` so a single
        failure (e.g., a log handler whose ``close()`` raises)
        does not block the others. Failures are logged at WARNING
        level to stderr (best-effort visibility).
        """
        if self._shut_down:
            return
        self._shut_down = True

        if self._wiring is None:
            # Scoped sub-context — nothing to tear down.
            return

        # Walk the stack LIFO so the most-recently-wired step
        # rolls back first. Mirrors how a transactional rollback
        # peels back the call graph.
        for name, rollback_fn in reversed(self._wiring.rollbacks):
            try:
                rollback_fn()
            except Exception as e:
                # Rollback never raises — print to stderr; we
                # cannot rely on the logging stack being in a sane
                # state during shutdown.
                print(
                    f"mgf.common shutdown: rollback {name!r} failed: {e}",
                    file=sys.stderr,
                )

        # Clear the global if this context was the active one.
        global _GLOBAL_CONTEXT
        if _GLOBAL_CONTEXT is self:
            _GLOBAL_CONTEXT = None

    def reload(self, new_settings: MgfSettings) -> AppContext:
        """Re-apply wiring with new settings. Atomic.

        Closes FEEDBACK design ref §14.5 — the programmatic API
        for hot-reload. Consumers wire SIGHUP / inotify / polling
        themselves and call this method from the handler.

        On success, returns a NEW ``AppContext`` reflecting the
        new settings; the caller MUST update any stored
        references. On failure, raises :class:`BootstrapError`
        and the OLD wiring is preserved (the reload is atomic
        with the same rollback stack as ``bootstrap()``).

        **Failure-with-restore contract (RA-21):** ``reload()``
        calls ``self.shutdown()`` BEFORE attempting the new
        wiring. On a failed reload that successfully restores,
        the *process* is back to a working state (a fresh
        ``AppContext`` bound to the OLD settings now lives on
        ``_GLOBAL_CONTEXT``), but ``self`` is still flagged
        ``_shut_down=True`` — your caller-held reference now
        points at a torn-down corpse. To recover the live
        context after catching the re-raised ``BootstrapError``,
        call :func:`mgf.common.current_context` and update your
        stored reference::

            try:
                ctx = ctx.reload(new_settings)
            except BootstrapError:
                # Reload failed; the library restored the prior
                # wiring transparently, but `ctx` is stale.
                ctx = mgf.common.current_context()
                # `ctx` is now the restored old-settings context.

        If the restore ALSO fails the library is uninitialised
        (the ``reload.unrecoverable`` audit log fires) and
        ``current_context()`` returns ``None``; the caller must
        treat that as a hard failure and re-``bootstrap()`` or
        exit.

        ``app_name`` and ``app_version`` are NOT changeable via
        reload — they are part of the bootstrap identity and
        affect OTel ``service.name`` (which is process-global per
        FEEDBACK design ref §14.4). Use a fresh ``bootstrap()``
        if you need to change identity.

        Phase 1 implementation: stub that re-wires by tearing
        down + bootstrapping again. Phase 2 will replace this
        with a finer-grained per-subsystem reload (configurable
        which subsystems re-wire).

        Raises :class:`RuntimeError` on a **scoped** context
        (``_wiring is None``) — sub-contexts don't own wiring
        to reload. To change a scoped context's settings,
        construct a fresh ``AppContext(...)`` with the new
        settings.
        """
        if self._wiring is None:
            raise RuntimeError(
                "AppContext.reload() requires a bootstrap-owned context. "
                "Scoped sub-contexts (constructed directly via "
                "AppContext(...)) don't own wiring; construct a fresh "
                "AppContext with the new settings instead."
            )
        with _WIRING_LOCK:
            old_settings = self.settings
            self.shutdown()
            try:
                # Re-bootstrap with new settings. The result is a
                # fresh AppContext bound to the new wiring.
                new_ctx = _bootstrap_inner(
                    self.app_name,
                    self.app_version,
                    new_settings,
                )
            except BootstrapError:
                # Reload failed — try to restore the OLD wiring
                # so the process isn't left uninitialised. If
                # restore ALSO fails, we re-raise with the
                # restore failure as a chained exception.
                try:
                    _bootstrap_inner(self.app_name, self.app_version, old_settings)
                except BootstrapError as restore_e:
                    LOG.error(
                        "reload failed AND restore failed; library is uninitialised",
                        extra={"audit": True, "event": "reload.unrecoverable"},
                    )
                    raise restore_e
                raise
            return new_ctx


# ---------------------------------------------------------------------------
# Bootstrap-time log buffer (FEEDBACK §14.3)
# ---------------------------------------------------------------------------


class _BootstrapLogBuffer(logging.Handler):
    """Captures records emitted DURING bootstrap, replays them
    through the configured handlers once setup completes.

    Wired at bootstrap entry; replayed at the end of
    ``_wire_logging`` (or at any successful end-of-bootstrap);
    removed from the root logger after replay. No records are
    lost; no records are duplicated.

    On bootstrap FAILURE, the buffer flushes to stderr (best-
    effort visibility). The operator sees what happened before
    the BootstrapError is raised.

    Closes FEEDBACK design ref §14.3 (no story for logging from
    inside bootstrap itself).
    """

    def __init__(self) -> None:
        super().__init__(level=logging.DEBUG)
        self._records: list[logging.LogRecord] = []
        # RA-17: attach a minimal formatter so the emergency
        # ``flush_to_stderr`` path (taken on bootstrap failure
        # BEFORE the production handlers are wired) emits records
        # with level + logger + message, not just the bare message.
        # Without this, the operator sees a stream of "[mgf.common
        # bootstrap] <text>" lines with no levels — diagnosing a
        # mid-bootstrap failure becomes guesswork.
        self.setFormatter(
            logging.Formatter(
                fmt="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
                datefmt="%Y-%m-%dT%H:%M:%S",
            ),
        )

    def emit(self, record: logging.LogRecord) -> None:
        """Append the record to the in-memory buffer."""
        self._records.append(record)

    def replay_to(self, target_handlers: list[logging.Handler]) -> None:
        """Replay every captured record through ``target_handlers``.

        Each handler's level filter applies, so records emitted at
        DEBUG during bootstrap that the production root logger is
        configured to drop (level=INFO) get dropped here too —
        consistent with what would have happened if the production
        handlers had been wired before the records emitted.
        """
        for record in self._records:
            for h in target_handlers:
                if record.levelno >= h.level:
                    # Best-effort replay; one failing handler MUST
                    # NOT block replay through siblings.
                    with contextlib.suppress(Exception):
                        h.handle(record)

    def flush_to_stderr(self) -> None:
        """Emergency flush when bootstrap FAILED before logging
        was wired. Operators see what happened during the failed
        bootstrap before the BootstrapError surfaces."""
        for record in self._records:
            # Last-ditch. Skip records we can't even format.
            with contextlib.suppress(Exception):
                msg = self.format(record) if self.formatter else record.getMessage()
                sys.stderr.write(f"[mgf.common bootstrap] {msg}\n")


# ---------------------------------------------------------------------------
# bootstrap() — the single canonical entry point
# ---------------------------------------------------------------------------


def bootstrap(
    app_name: str | None = None,
    app_version: str | None = None,
    *,
    settings: MgfSettings | None = None,
    setup_logging: bool = True,
) -> AppContext:
    """The single canonical bootstrap. Wires every observability
    subsystem in the correct order, returns the active context,
    cleans up on exit.

    Usage with auto-derived identity (v0.4+, recommended for
    packaged apps)::

        # Identity comes from sys.modules["__main__"].__spec__.parent
        # — works when launched via `python -m mypackage` or via a
        # console-script entry that preserves __spec__.
        with mgf.common.bootstrap() as ctx:
            do_work()

    Usage with explicit identity (works in every context, including
    ad-hoc scripts and test runners)::

        with mgf.common.bootstrap(app_name="myapp", app_version="1.0") as ctx:
            do_work()

    Or as a one-shot returning the :class:`AppContext` (for long-
    running services that own the lifecycle explicitly)::

        ctx = mgf.common.bootstrap(app_name="myapp", app_version="1.0")
        try:
            do_work()
        finally:
            ctx.shutdown()

    Parameters
    ----------
    app_name
        Short app identifier. Sets OTel ``service.name``, the
        default log-file directory, the SYSLOG identifier. When
        ``None`` (v0.4 Phase B default), derives from the launching
        package's distribution metadata via
        :func:`mgf.common._identity._derive_identity_from_metadata`.
        Falls through to the placeholder ``"app"`` (with an
        :class:`UnconfiguredWarning`) when derivation isn't possible.
    app_version
        Version string. Pass your top-level package's
        ``__version__`` — or leave as ``None`` to auto-derive
        alongside ``app_name``.
    settings
        Optional :class:`mgf.common.settings.MgfSettings` instance.
        When omitted, defaults to ``MgfSettings(preset="auto")`` —
        preserves v0.1 auto-detection (TTY / JOURNAL_STREAM /
        OTEL endpoint) for legacy consumers (closes FEEDBACK design
        ref §14.15). Pass ``MgfSettings(...)`` for explicit
        configuration; the default is ``preset="explicit"`` in
        that path.
    setup_logging
        Default ``True`` — install ``mgf-common``'s logging stack
        (StreamHandler + RotatingFileHandler with the JSON formatter
        per :class:`MgfSettings.logging`). Pass ``False`` to skip it
        when the consumer's framework owns the logging stack
        (Django's ``LOGGING`` dictConfig, FastAPI/uvicorn's logging,
        Flask's ``app.logger``, etc.). Identity registration,
        ``AppContext`` creation, OTel wiring, excepthooks, and the
        bootstrap log buffer still happen — only the logging-handler
        installation + per-component ``level_overrides`` are skipped.
        Closes BOOTSTRAP-01 — Django consumers were getting double
        log emission because ``bootstrap()`` was unconditionally
        adding handlers on top of the dictConfig-configured stack.

    Atomicity (FEEDBACK design ref §14.2)
    -------------------------------------
    Bootstrap is **transactional**. Each internal wiring step
    appends a rollback closure. On any failure, the stack is
    unwound LIFO, partial state is reversed, and a typed
    :class:`BootstrapError` is raised carrying the failed step
    name + the original cause via ``__cause__``.

    Idempotency (FEEDBACK design ref §14.10)
    ----------------------------------------
    Calling ``bootstrap()`` a second time without an intervening
    ``shutdown()`` REPLACES the active context (last-call-wins)
    and emits an AUDIT log record (``event="bootstrap.replace"``)
    so operators can find rogue re-bootstraps. Why replace not
    error: test harnesses and notebooks legit need to
    re-bootstrap; erroring would force a more complex pattern.

    Returns
    -------
    :class:`AppContext`
        The live application context. It is itself a context
        manager (``__enter__`` returns ``self``; ``__exit__``
        calls :meth:`AppContext.shutdown`), so the with-block
        and ``ctx = bootstrap(...)`` shapes both work without
        any wrapping. The previously-private
        ``_BootstrapContext`` wrapper was removed in v0.4 Phase A.
    """
    if settings is None:
        # FEEDBACK §14.15 — auto preset preserves v0.1 behaviour
        # when consumer didn't opt into explicit settings.
        # PC-01: lazy-import — see `_default_mgf_settings` rationale.
        from mgf.common.settings import MgfSettings

        settings = MgfSettings(preset="auto")

    # v0.4 Phase B — auto-derive identity from package metadata
    # when the caller omitted one or both args. Explicit args
    # always win. If derivation fails AND no explicit value was
    # given, emit UnconfiguredWarning so the caller knows why
    # they're getting the placeholder identity.
    if app_name is None or app_version is None:
        from mgf.common._identity import (
            UnconfiguredWarning,
            _derive_identity_from_metadata,
        )

        derived_name, derived_version = _derive_identity_from_metadata()
        if app_name is None:
            app_name = derived_name
        if app_version is None:
            app_version = derived_version
        if app_name is None or app_version is None:
            import warnings as _warnings

            _warnings.warn(
                "mgf.common.bootstrap() could not auto-derive identity "
                "from package metadata. Pass app_name=... and "
                "app_version=... explicitly, or run your app via "
                "`python -m <package>` so __main__.__spec__.parent "
                "resolves. Falling back to the placeholder identity.",
                UnconfiguredWarning,
                stacklevel=2,
            )
            if app_name is None:
                app_name = "app"
            if app_version is None:
                app_version = "unknown"

    # FEEDBACK §14.10 — idempotency: replace previous context
    # and AUDIT-log so accidental re-bootstraps are visible.
    #
    # RA-15: replace semantics. We do NOT call ``prior.shutdown()``
    # before re-wiring — its rollback closures (``_unwire_logging``,
    # ``_restore_identity``, ``_unwire_otel``, ...) are tied to the
    # SHARED globals (root logger handlers, sys.excepthook, OTel
    # provider). Running them now would tear down state the new
    # bootstrap is about to claim, then the new bootstrap would
    # tear it down again on its own rollback — a double-teardown
    # that can crash on the second pass. It also leaves the process
    # context-less if the new bootstrap then fails.
    #
    # Instead: run ``_bootstrap_inner`` FIRST. Its own rollback
    # path (LIFO of ``wiring.rollbacks``) handles failure cleanly,
    # restoring identity / excepthooks / logger levels via the
    # ``prior_*`` snapshots taken at each step entry. On success,
    # we propagate the OLDER ``prior_*`` snapshots forward (so the
    # new context's eventual shutdown restores the state from
    # BEFORE any mgf bootstrap was active, not the state from
    # between the two bootstraps — that intermediate state belonged
    # to the now-superseded prior and isn't meaningful to restore
    # to). Then mark ``prior`` superseded so a later
    # ``prior.shutdown()`` is a clean no-op.
    #
    # The AUDIT log fires BEFORE ``_bootstrap_inner`` so it lands
    # in the prior context's logging stack — the operator sees the
    # "I'm being replaced" notice in the prior log stream, not
    # buried in the new stream.
    #
    # RA-03: ``_WIRING_LOCK`` serialises the entire replace +
    # re-wire sequence. Two threads calling ``bootstrap()``
    # simultaneously must not race on ``logging.root.handlers``
    # mutations or on the ``_GLOBAL_CONTEXT`` read/write below;
    # holding the lock here gives last-call-wins semantics across
    # threads (the second thread to acquire the lock sees the
    # first's context in ``_GLOBAL_CONTEXT`` and takes the replace
    # path).
    with _WIRING_LOCK:
        prior: AppContext | None = None
        if _GLOBAL_CONTEXT is not None:
            prior = _GLOBAL_CONTEXT
            LOG.warning(
                "bootstrap called again; replacing existing context",
                extra={
                    "audit": True,
                    "event": "bootstrap.replace",
                    "previous_app_name": prior.app_name,
                    "previous_app_version": prior.app_version,
                    "new_app_name": app_name,
                    "new_app_version": app_version,
                },
            )

        new_context = _bootstrap_inner(
            app_name,
            app_version,
            settings,
            setup_logging=setup_logging,
        )

        # New bootstrap succeeded. Chain the prior_* snapshots forward
        # (so new_context.shutdown() restores PRE-prior state, not
        # in-between state) and mark prior as superseded.
        if prior is not None and prior._wiring is not None and new_context._wiring is not None:
            new_context._wiring.prior_app_name = prior._wiring.prior_app_name
            new_context._wiring.prior_app_version = prior._wiring.prior_app_version
            new_context._wiring.prior_excepthook = prior._wiring.prior_excepthook
            new_context._wiring.prior_threading_excepthook = (
                prior._wiring.prior_threading_excepthook
            )
            # Merge prior_logger_levels: take prior's snapshot for any
            # logger name not already in new_context's snapshot. Levels
            # newly overridden by new_context already carry the right
            # restore target (the pre-new value, which on success is
            # the prior-installed value); for levels NOT touched by new
            # but touched by prior, we still need prior's snapshot to
            # restore back to the pre-prior value.
            for name, level in prior._wiring.prior_logger_levels.items():
                new_context._wiring.prior_logger_levels.setdefault(name, level)
            prior._wiring.rollbacks.clear()
            prior._shut_down = True

        return new_context


def _bootstrap_inner(
    app_name: str,
    app_version: str,
    settings: MgfSettings,
    *,
    setup_logging: bool = True,
) -> AppContext:
    """The core wiring sequence with rollback. Internal — called
    by :func:`bootstrap` and :meth:`AppContext.reload`.
    """
    from mgf.common import _identity

    wiring = _WiringHandles()

    # 1. Install the bootstrap log buffer FIRST so any record
    #    emitted from the wiring steps below is captured even
    #    before logging is fully configured (§14.3).
    buffer = _BootstrapLogBuffer()
    logging.root.addHandler(buffer)
    wiring.bootstrap_log_buffer = buffer

    failed_step = "<not-started>"
    try:
        # 2. Wire identity. Saves the prior values so rollback can
        #    restore them.
        failed_step = "identity"
        wiring.prior_app_name = _identity._APP_NAME
        wiring.prior_app_version = _identity._APP_VERSION
        # ``_set_identity`` is the internal helper that populates
        # the identity cache without any deprecation noise. The
        # public ``configure()`` shim that wrapped it was removed
        # in v0.33.0 per its AP-12 cycle.
        _identity._set_identity(app_name, app_version)
        wiring.rollbacks.append(
            ("identity", lambda: _restore_identity(wiring)),
        )

        # 3. Build the AppContext now so the rest of the wiring
        #    can attach rollbacks to it.
        new_context = AppContext(
            app_name=app_name,
            app_version=app_version,
            settings=settings,
            _wiring=wiring,
        )

        # 4. Wire logging. Phase 1: defer to existing setup_logging
        #    helper for backward compat. Phase 6 will read more
        #    fields from MgfSettings.logging.
        #
        #    BOOTSTRAP-01: skipped when ``setup_logging=False`` — the
        #    consumer's framework owns the logging stack (Django
        #    LOGGING dictConfig, FastAPI / uvicorn logging, Flask
        #    app.logger, etc.). Identity, OTel, excepthooks, and the
        #    bootstrap-log-buffer replay below still happen.
        if setup_logging:
            failed_step = "logging"
            import re as _re

            from mgf.common.observability.logging_setup import _wire_logging
            from mgf.common.observability.redaction import (
                Redactor,
                _resolve_pattern_groups,
            )

            # Resolve pattern groups → tuple of compiled regexes.
            # Closes Phase 3's wiring of
            # MgfSettings.redaction.enabled_pattern_groups → the registry.
            # NOTE: "builtin" is always-on via Redactor._scrub_string's
            # iteration of _BUILTIN_VALUE_PATTERNS, so we filter it out
            # here to avoid double-scanning. Consumer-registered groups
            # ARE passed through.
            non_builtin_groups = [
                g for g in settings.redaction.enabled_pattern_groups if g != "builtin"
            ]
            resolved_patterns = _resolve_pattern_groups(non_builtin_groups)
            # Compose with any consumer-supplied raw regex strings from
            # settings.redaction.extra_value_patterns (compiled here).
            extra_compiled = tuple(_re.compile(p) for p in settings.redaction.extra_value_patterns)
            log_redactor = Redactor(
                extra_keys=settings.redaction.extra_keys,
                extra_value_patterns=resolved_patterns + extra_compiled,
            )
            _wire_logging(
                level=settings.logging.level,
                fmt=settings.logging.format,
                log_file=settings.logging.file,
                redactor=log_redactor,
            )
            wiring.rollbacks.append(("logging", _unwire_logging))

            # 5. Apply per-component level overrides (§14.6).
            failed_step = "level_overrides"
            for logger_name, level_str in settings.logging.level_overrides.items():
                logger = logging.getLogger(logger_name)
                wiring.prior_logger_levels[logger_name] = logger.level
                logger.setLevel(getattr(logging, level_str))
            wiring.rollbacks.append(
                ("level_overrides", lambda: _restore_logger_levels(wiring)),
            )

        # 6. Replay the bootstrap log buffer through whatever handlers
        #    are currently on root and remove the buffer. When
        #    ``setup_logging=False`` the handlers were installed by
        #    the framework before bootstrap() ran (Django's dictConfig
        #    fires at settings-module-load time); the buffer's records
        #    are still emitted through them.
        failed_step = "buffer_replay"
        buffer.replay_to(logging.root.handlers)
        logging.root.removeHandler(buffer)
        wiring.bootstrap_log_buffer = None

        # 7. Wire OTel.
        failed_step = "otel"
        from mgf.common.observability.otel_setup import _wire_otel

        _wire_otel(enabled=settings.otel.enabled if settings.otel.enabled else None)
        wiring.rollbacks.append(("otel", _unwire_otel))

        # 8. Wire excepthooks.
        failed_step = "excepthooks"
        wiring.prior_excepthook = sys.excepthook
        wiring.prior_threading_excepthook = threading.excepthook
        from mgf.common.observability.excepthook import _wire_excepthook
        from mgf.common.observability.threading_excepthook import (
            _wire_threading_excepthook,
        )

        _wire_excepthook()
        _wire_threading_excepthook()
        wiring.rollbacks.append(
            ("excepthooks", lambda: _restore_excepthooks(wiring)),
        )

        # 9. Crash sinks — Phase 1 doesn't change the existing
        #    env-var-driven sink machinery; Phase 3 introduces
        #    the registry pattern.
        failed_step = "crash_sinks"
        # No-op for Phase 1; rollback is also no-op.
        wiring.rollbacks.append(("crash_sinks", lambda: None))

    except BaseException as e:
        # Roll back every wiring step LIFO. Suppress secondary
        # rollback failures (§14.2 + §10 Q11 recommendation).
        for name, rollback_fn in reversed(wiring.rollbacks):
            try:
                rollback_fn()
            except Exception as rb_e:
                print(
                    f"mgf.common bootstrap: rollback {name!r} failed: {rb_e}",
                    file=sys.stderr,
                )
        # Flush the bootstrap log buffer to stderr so the operator
        # sees what was logged during the failed bootstrap.
        if wiring.bootstrap_log_buffer is not None:
            with contextlib.suppress(Exception):
                wiring.bootstrap_log_buffer.flush_to_stderr()
            with contextlib.suppress(Exception):
                logging.root.removeHandler(wiring.bootstrap_log_buffer)

        raise BootstrapError(failed_step, cause=e) from e

    # Set as the active global context. Multi-tenancy (Phase 2+):
    # AppContext-as-context-manager will push to _CONTEXT_VAR
    # instead of the global.
    global _GLOBAL_CONTEXT
    _GLOBAL_CONTEXT = new_context

    # v0.5 Phase E: emit a runtime snapshot for `mgf-common doctor
    # --live`. Off by default — only writes when MGF_DOCTOR_RUNTIME_PATH
    # is set. Best-effort; failures are logged, never raised.
    _maybe_emit_runtime_snapshot(new_context, settings)

    return new_context


def _maybe_emit_runtime_snapshot(ctx: AppContext, settings: MgfSettings) -> None:
    """Write a runtime snapshot to ``MGF_DOCTOR_RUNTIME_PATH`` if set.

    Snapshot shape (PUBLIC for the duration of the file's life):

      {
        "app_name": "myapp",
        "app_version": "1.0.0",
        "settings": { ... },
        "log_handler_count": int,
        "otel_enabled": bool,
        "otel_endpoint": "...",
        "redaction_pattern_groups": ["builtin", ...],
      }

    Consumers MAY parse this JSON in tooling. Adding fields is
    backwards-compatible; renaming / removing is a public-API change.

    RA-22: this function is **best-effort by contract**. The caller
    has ALREADY set ``_GLOBAL_CONTEXT = new_context`` (see
    ``_bootstrap_inner``) — propagating an exception from here would
    leave the process with a fully-wired AppContext on the global
    AND a raised :class:`BootstrapError`, an inconsistent state. The
    practical failure modes ``settings.model_dump`` can hit include
    a consumer-supplied ``@computed_field`` raising during dump, a
    file-handle exhaustion on the atomic write, or a permissions
    error on the snapshot path. All three are operator-fixable
    *separately*; none should kill bootstrap. We log a WARNING with
    the failure type so the gap is visible, and return normally.
    """
    try:
        from mgf.common._doctor_snapshot import emit_runtime_snapshot

        snapshot = {
            "app_name": ctx.app_name,
            "app_version": ctx.app_version,
            "settings": settings.model_dump(mode="json", exclude_unset=False),
            "log_handler_count": len(logging.root.handlers),
            "otel_enabled": bool(
                os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or settings.otel.enabled
            ),
            "otel_endpoint": os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT", ""),
            "redaction_pattern_groups": list(settings.redaction.enabled_pattern_groups),
        }
        emit_runtime_snapshot(snapshot)
    except Exception as exc:
        LOG.warning(
            "runtime snapshot failed; bootstrap continues without it",
            extra={
                "audit": True,
                "event": "bootstrap.runtime_snapshot_failed",
                "error_type": type(exc).__name__,
                "error_message": str(exc),
            },
        )


# ---------------------------------------------------------------------------
# Rollback helpers — internal
# ---------------------------------------------------------------------------


def _restore_identity(wiring: _WiringHandles) -> None:
    from mgf.common import _identity

    _identity._APP_NAME = wiring.prior_app_name
    _identity._APP_VERSION = wiring.prior_app_version


def _unwire_logging() -> None:
    """Remove every handler from the root logger + reset basicConfig.

    Best-effort. Each handler is closed before removal so
    ``RotatingFileHandler``'s file descriptor is released cleanly.
    """
    for handler in list(logging.root.handlers):
        with contextlib.suppress(Exception):
            handler.close()
        with contextlib.suppress(Exception):
            logging.root.removeHandler(handler)


def _restore_logger_levels(wiring: _WiringHandles) -> None:
    for logger_name, prior_level in wiring.prior_logger_levels.items():
        logging.getLogger(logger_name).setLevel(prior_level)


def _unwire_otel() -> None:
    """Best-effort OTel teardown.

    OpenTelemetry's TracerProvider can't be 'reset' to a no-op
    cleanly — ``set_tracer_provider`` is one-shot per process.
    The pragmatic approach: shut down any active provider so its
    BatchSpanProcessor flushes; we accept that the tracer remains
    set (subsequent calls become no-ops if OTel is disabled).
    """
    with contextlib.suppress(Exception):
        from opentelemetry import trace as _trace

        provider = _trace.get_tracer_provider()
        shutdown = getattr(provider, "shutdown", None)
        if callable(shutdown):
            shutdown()


def _restore_excepthooks(wiring: _WiringHandles) -> None:
    sys.excepthook = wiring.prior_excepthook
    threading.excepthook = wiring.prior_threading_excepthook


# ---------------------------------------------------------------------------
# current_context() — readonly accessor
# ---------------------------------------------------------------------------


def current_context() -> AppContext | None:
    """Return the active :class:`AppContext`, or ``None`` if
    ``bootstrap()`` has not been called yet.

    Resolution order (FEEDBACK §6 Option C):
    1. Per-task contextvar (set by ``with AppContext(...):`` —
       Phase 2+).
    2. Process-wide global (set by ``bootstrap()``).

    Reads are O(1); the fast path is a single ``ContextVar.get()``.

    Threading: thread-safe. Safe to call from any thread; safe to
    call from inside an asyncio task (contextvars propagate
    correctly across ``await`` points).

    .. versionchanged:: 0.5.0
        Emits :class:`UnconfiguredWarning` once per process when
        called before any ``bootstrap()``/``AppContext`` is active.
        Closes the v0.5 strategic-review finding "current_context()
        returns None silently — cryptic downstream failures."
        The function still returns ``None`` for backwards-compat;
        consumers can opt into hard failure via
        ``warnings.filterwarnings("error", category=UnconfiguredWarning)``.
    """
    ctx = _CONTEXT_VAR.get()
    if ctx is not None:
        return ctx
    if _GLOBAL_CONTEXT is not None:
        return _GLOBAL_CONTEXT

    # Pre-bootstrap path. Warn only if bootstrap has not yet been
    # called and the identity cache is still at its placeholder
    # default — if identity was set (via ``_set_identity`` from a
    # test, or via a future helper), the consumer knows the
    # library is initialized and just doesn't have a context object,
    # which is fine.
    #
    # Shares the ``_UNCONFIGURED_WARNED`` guard with
    # :func:`mgf.common._identity._warn_if_unconfigured` so reading
    # ``app_name()`` then calling ``current_context()`` (or vice
    # versa) produces a single warning, not two.
    from mgf.common import _identity as _id

    if _id._CONFIGURED:
        return None

    if not _id._UNCONFIGURED_WARNED:
        _id._UNCONFIGURED_WARNED = True
        import warnings as _warnings

        _warnings.warn(
            "mgf.common.current_context() called before bootstrap() — "
            "returning None. Downstream `if ctx := current_context():` "
            "patterns will silently no-op. Call mgf.common.bootstrap("
            "app_name=..., app_version=...) at startup, or use "
            "mgf.common.bootstrap_for_test() in test fixtures.",
            _id.UnconfiguredWarning,
            stacklevel=2,
        )
    return None


# ---------------------------------------------------------------------------
# AppContext as a context manager (Phase 2+) — placeholder
# ---------------------------------------------------------------------------


@contextmanager
def reset_context() -> Iterator[None]:
    """Temporarily clear the process-wide bootstrap context, restoring the prior
    context on exit.

    A clean slate for benchmarks/tests that must run as if ``bootstrap()`` had
    not been called (e.g. measuring cold-path cost). NOT for production code —
    the context is process-wide, so clearing it affects all threads for the
    duration of the ``with`` block. (Public seam over ``_GLOBAL_CONTEXT`` —
    promoted in 0.49 so consumers stop reaching into ``mgf.common._lifecycle``.)
    """
    global _GLOBAL_CONTEXT
    prior = _GLOBAL_CONTEXT
    _GLOBAL_CONTEXT = None
    try:
        yield
    finally:
        _GLOBAL_CONTEXT = prior


@contextmanager
def _appcontext_cm(ctx: AppContext) -> Iterator[AppContext]:
    """Push an AppContext onto the contextvar stack for the
    duration of a ``with`` block. Phase 2+ uses this for opt-in
    multi-tenancy; Phase 1 ships the seam without exposing it.
    """
    token = _CONTEXT_VAR.set(ctx)
    try:
        yield ctx
    finally:
        _CONTEXT_VAR.reset(token)


__all__ = [
    "AppContext",
    "bootstrap",
    "current_context",
]
