"""Process-wide unhandled-exception hook.

Installs a wrapper around ``sys.excepthook`` that:

  1. Lets ``KeyboardInterrupt`` and ``SystemExit`` pass through to the
     prior hook (Python's default behavior is correct for these).
  2. Logs the exception via the application logger at ERROR with full
     traceback (rule LG-05).
  3. Persists a local crash report and best-effort ships it to the
     configured remote sink (rule EH-05).
  4. Calls the prior hook so anything else chained on top still sees it.

Idempotent: re-running :func:`_wire_excepthook` (via a second
:func:`mgf.common.bootstrap` call) chains the new wrapper over the
existing one. The wrapper detects this and does not double-log.

See ``docs/standards/ERROR_HANDLING.md`` §4.2.
"""

from __future__ import annotations

import logging
import sys
import traceback
from typing import TYPE_CHECKING

from mgf.common._identity import app_name
from mgf.common.observability.crash_reporter import report_now

if TYPE_CHECKING:
    from types import TracebackType

# Sentinel attached to wrapped hooks so re-installation is idempotent
# (calling _wire_excepthook twice should not produce two crash reports
# per crash).
_INSTALLED_MARKER = "_mgf_common_excepthook_installed"


def _safe_emit(
    log: logging.Logger,
    msg: str,
    args: tuple[object, ...],
    exc_info: tuple[type[BaseException], BaseException, TracebackType | None],
) -> None:
    # PAPER-60: an excepthook fires precisely when something has gone
    # wrong, so it MUST NOT become a new failure source. During
    # interpreter teardown / pytest's unraisable-exception flow the
    # logger's stream can be closed, and `log.error` then raises
    # `ValueError: I/O on closed file` inside the hook — burying the
    # original exception. Skip the rich log path if the interpreter
    # is finalising, and fall back to writing the traceback to the
    # un-redirected `sys.__stderr__` if logging blows up. If even that
    # fails, swallow — excepthooks never raise.
    try:
        if not sys.is_finalizing():
            log.error(msg, *args, exc_info=exc_info)
            return
    except (ValueError, OSError):
        pass
    try:
        if sys.__stderr__ is not None:
            traceback.print_exception(*exc_info, file=sys.__stderr__)
    except Exception:  # noqa: S110 — excepthook last-resort path; never raise
        pass


def _wire_excepthook() -> None:
    """Install the process-wide unhandled-exception hook (rule EH-04).

    Internal — called by :func:`mgf.common.bootstrap`. The public
    ``install_excepthook`` shim was removed pre-v1.0; consumers MUST
    use bootstrap.
    """
    prior = sys.excepthook
    if getattr(prior, _INSTALLED_MARKER, False):
        # Already installed; chaining again would just produce two
        # crash reports per crash. No-op.
        return

    log = logging.getLogger(f"{app_name()}.unhandled")

    def hook(
        exc_type: type[BaseException],
        exc: BaseException,
        tb: TracebackType | None,
    ) -> None:
        # KeyboardInterrupt / SystemExit are NOT bugs — let the
        # default hook handle them (Python prints "KeyboardInterrupt"
        # and exits cleanly).
        if issubclass(exc_type, (KeyboardInterrupt, SystemExit)):
            prior(exc_type, exc, tb)
            return

        # Log first so the trace is in the structured log even if the
        # crash reporter itself trips. Routed through `_safe_emit` so
        # a closed-stderr cascade during teardown can't bury the
        # original exception (PAPER-60).
        _safe_emit(log, "unhandled exception", (), (exc_type, exc, tb))
        report_now(exc, source="excepthook")
        # Chain to the prior hook (Python default = print to stderr).
        prior(exc_type, exc, tb)

    setattr(hook, _INSTALLED_MARKER, True)
    sys.excepthook = hook


__all__: list[str] = []
