"""Unit tests for :mod:`mgf.common.observability.excepthook` and
:mod:`mgf.common.observability.threading_excepthook`.

The hooks have side effects on global state (sys.excepthook,
threading.excepthook) — the tests carefully save and restore the
prior state via fixtures so they never leak across the suite.
"""

from __future__ import annotations

import io
import logging
import sys
import threading
from typing import TYPE_CHECKING
from unittest.mock import patch

import pytest

from mgf.common.observability.excepthook import _safe_emit, _wire_excepthook
from mgf.common.observability.threading_excepthook import _wire_threading_excepthook

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

_APP = "mgftest"


@pytest.fixture
def _restore_sys_excepthook() -> Iterator[None]:
    prior = sys.excepthook
    yield
    sys.excepthook = prior


@pytest.fixture
def _restore_threading_excepthook() -> Iterator[None]:
    prior = threading.excepthook
    yield
    threading.excepthook = prior


@pytest.fixture(autouse=True)
def _isolate_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))


# ---------------------------------------------------------------------------
# _wire_excepthook
# ---------------------------------------------------------------------------


def test__wire_excepthook_replaces_sys_excepthook(
    _restore_sys_excepthook: None,
) -> None:
    prior = sys.excepthook
    _wire_excepthook()
    assert sys.excepthook is not prior


def test__wire_excepthook_is_idempotent(
    _restore_sys_excepthook: None,
) -> None:
    _wire_excepthook()
    once = sys.excepthook
    _wire_excepthook()
    twice = sys.excepthook
    # Same wrapper — second call is a no-op (no double-stacking).
    assert once is twice


def test_excepthook_writes_crash_report_for_runtime_error(
    tmp_path: Path,
    _restore_sys_excepthook: None,
) -> None:
    # Replace the prior hook with a no-op BEFORE installing ours, so
    # the chain at the end of our hook doesn't surface the synthetic
    # exception back to pytest and confuse the runner.
    sys.excepthook = lambda *_args: None
    _wire_excepthook()

    try:
        raise RuntimeError("excepthook test")
    except RuntimeError as e:
        sys.excepthook(type(e), e, e.__traceback__)

    crash_dir = tmp_path / "state" / _APP / "crashes"
    assert crash_dir.exists()
    files = list(crash_dir.glob("*.json"))
    assert files, "no crash report written by sys.excepthook"


def test_excepthook_lets_keyboard_interrupt_through(
    tmp_path: Path,
    _restore_sys_excepthook: None,
) -> None:
    # No-op prior hook so we don't perturb pytest's runner.
    sys.excepthook = lambda *_args: None
    _wire_excepthook()
    try:
        raise KeyboardInterrupt
    except KeyboardInterrupt as e:
        sys.excepthook(type(e), e, e.__traceback__)
    # KeyboardInterrupt is NOT a bug — no crash report should be written.
    crash_dir = tmp_path / "state" / _APP / "crashes"
    if crash_dir.exists():
        assert list(crash_dir.glob("*.json")) == []


# ---------------------------------------------------------------------------
# _wire_threading_excepthook
# ---------------------------------------------------------------------------


def test__wire_threading_excepthook_replaces_hook(
    _restore_threading_excepthook: None,
) -> None:
    prior = threading.excepthook
    _wire_threading_excepthook()
    assert threading.excepthook is not prior


def test__wire_threading_excepthook_is_idempotent(
    _restore_threading_excepthook: None,
) -> None:
    _wire_threading_excepthook()
    once = threading.excepthook
    _wire_threading_excepthook()
    twice = threading.excepthook
    assert once is twice


def test_threading_excepthook_skips_systemexit_silently(
    tmp_path: Path,
    _restore_threading_excepthook: None,
) -> None:
    threading.excepthook = lambda _args: None
    _wire_threading_excepthook()
    args = threading.ExceptHookArgs(
        (SystemExit, SystemExit("clean stop"), None, threading.current_thread())
    )
    threading.excepthook(args)
    crash_dir = tmp_path / "state" / _APP / "crashes"
    if crash_dir.exists():
        assert list(crash_dir.glob("*.json")) == []


def test_threading_excepthook_writes_crash_report_for_runtime_error(
    tmp_path: Path,
    _restore_threading_excepthook: None,
) -> None:
    threading.excepthook = lambda _args: None
    _wire_threading_excepthook()
    try:
        raise RuntimeError("thread crash")
    except RuntimeError as e:
        args = threading.ExceptHookArgs(
            (type(e), e, e.__traceback__, threading.current_thread()),
        )
        threading.excepthook(args)
    crash_dir = tmp_path / "state" / _APP / "crashes"
    assert crash_dir.exists()
    files = list(crash_dir.glob("*.json"))
    assert files, "thread crash report not written"


# ---------------------------------------------------------------------------
# PAPER-60 — `_safe_emit` MUST NOT raise during interpreter / pytest shutdown
# when the logger's stream has been closed. The hook is the last line of
# defence; if it cascades, the original unraisable is buried.
# ---------------------------------------------------------------------------


def _capture_exc_info() -> tuple[type[BaseException], BaseException, object | None]:
    try:
        raise RuntimeError("paper-60 reproducer")
    except RuntimeError as e:
        return type(e), e, e.__traceback__


class TestSafeEmitPaper60:
    """Pin: `_safe_emit` MUST NOT raise during teardown when the
    logger's stream is closed. pytest's `capsys` only swaps
    `sys.stderr`, not `sys.__stderr__`, so the fallback path is
    verified by patching `sys.__stderr__` with an in-memory buffer.
    """

    def test_logger_raising_value_error_does_not_propagate(self) -> None:
        log = logging.getLogger("mgftest.paper60.closed-stream")
        buf = io.StringIO()

        def _boom(*_a: object, **_kw: object) -> None:
            raise ValueError("I/O operation on closed file.")

        with (
            patch.object(log, "error", _boom),
            patch("mgf.common.observability.excepthook.sys.__stderr__", buf),
        ):
            # Must not raise — the cascade observed in vmanager's
            # pytest teardown was the log.error() call propagating
            # ValueError out of the excepthook itself.
            _safe_emit(log, "unhandled exception", (), _capture_exc_info())

        # Fallback wrote the original traceback to __stderr__ so the
        # operator still sees what failed.
        assert "paper-60 reproducer" in buf.getvalue()

    def test_logger_raising_os_error_does_not_propagate(self) -> None:
        log = logging.getLogger("mgftest.paper60.oserror")
        buf = io.StringIO()

        def _boom(*_a: object, **_kw: object) -> None:
            raise OSError("disk full")

        with (
            patch.object(log, "error", _boom),
            patch("mgf.common.observability.excepthook.sys.__stderr__", buf),
        ):
            _safe_emit(log, "unhandled exception", (), _capture_exc_info())

        assert "paper-60 reproducer" in buf.getvalue()

    def test_interpreter_finalizing_skips_logger_entirely(self) -> None:
        log = logging.getLogger("mgftest.paper60.finalizing")
        buf = io.StringIO()
        called = False

        def _track(*_a: object, **_kw: object) -> None:
            nonlocal called
            called = True

        with (
            patch.object(log, "error", _track),
            patch(
                "mgf.common.observability.excepthook.sys.is_finalizing",
                return_value=True,
            ),
            patch("mgf.common.observability.excepthook.sys.__stderr__", buf),
        ):
            _safe_emit(log, "unhandled exception", (), _capture_exc_info())

        # Logger path skipped — the stricter Py3.13 unraisable flow
        # routes through the __stderr__ fallback only.
        assert called is False
        assert "paper-60 reproducer" in buf.getvalue()

    def test_double_failure_still_swallowed(self) -> None:
        log = logging.getLogger("mgftest.paper60.double")

        def _boom(*_a: object, **_kw: object) -> None:
            raise ValueError("closed")

        # Both the logger AND the fallback __stderr__ unusable —
        # the hook must STILL not raise.
        with (
            patch.object(log, "error", _boom),
            patch("mgf.common.observability.excepthook.sys.__stderr__", None),
        ):
            _safe_emit(log, "unhandled exception", (), _capture_exc_info())
