"""Phase 5 of the v0.3 closed-box redesign — CLI tests.

Covers:

  - Stable exit-code constants (numeric pin per rule API-04).
  - :func:`mgf.common.cli.run_and_translate` — exception → exit
    code resolution via MRO walk.
  - SystemExit / KeyboardInterrupt special-cases.
  - Untyped exceptions → :func:`report_now` (mocked) +
    :data:`EXIT_UNKNOWN`.
  - :func:`mgf.common.cli.translate_exceptions` decorator form.
  - Per-CLI exit_code_map overrides.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
    from collections.abc import Sequence

from mgf.common.cli import (
    EXIT_APP_ERROR,
    EXIT_CONFIG_ERROR,
    EXIT_ENVIRONMENT_ERROR,
    EXIT_GUEST_ERROR,
    EXIT_OPERATION_ERROR,
    EXIT_PROVIDER_ERROR,
    EXIT_SUCCESS,
    EXIT_UNKNOWN,
    EXIT_VAULT_ERROR,
    run_and_translate,
    translate_exceptions,
)
from mgf.common.cli._handler import _resolve_exit_code
from mgf.common.exceptions import (
    AppConfigError,
    AppError,
    BootstrapError,
    CancellationError,
    CommandError,
    ConfigError,
    GuestUnreachableError,
    HostEnvironmentError,
    HttpError,
    HttpUnauthorizedError,
    OperationError,
    ProviderError,
    ResourceNotFoundError,
    SchedulerError,
    VaultError,
)

# ---------------------------------------------------------------------------
# Exit-code stability pin (rule API-04)
# ---------------------------------------------------------------------------


class TestExitCodes:
    """The numeric values are part of the public contract — bumping
    any of them is a breaking change."""

    def test_pin_values(self) -> None:
        # Pin the actual integers — this test fails LOUDLY if any
        # constant moves, matching the AP-10 spirit for API stability.
        assert EXIT_SUCCESS == 0
        assert EXIT_CONFIG_ERROR == 1
        assert EXIT_OPERATION_ERROR == 2
        assert EXIT_GUEST_ERROR == 3
        assert EXIT_ENVIRONMENT_ERROR == 4
        assert EXIT_PROVIDER_ERROR == 5
        assert EXIT_VAULT_ERROR == 6
        assert EXIT_APP_ERROR == 7
        assert EXIT_UNKNOWN == 10


# ---------------------------------------------------------------------------
# run_and_translate — happy paths
# ---------------------------------------------------------------------------


class TestRunAndTranslateHappyPaths:
    """No exception → exit code from the inner function."""

    def test_returns_zero_on_none(self) -> None:
        def main(_argv: Sequence[str]) -> None:
            return None

        assert run_and_translate(main, []) == EXIT_SUCCESS

    def test_returns_zero_on_explicit_zero(self) -> None:
        def main(_argv: Sequence[str]) -> int:
            return 0

        assert run_and_translate(main, []) == EXIT_SUCCESS

    def test_passes_through_non_zero(self) -> None:
        def main(_argv: Sequence[str]) -> int:
            return 7

        assert run_and_translate(main, []) == 7

    def test_argv_is_passed_through(self) -> None:
        captured: list[Sequence[str]] = []

        def main(argv: Sequence[str]) -> int:
            captured.append(argv)
            return 0

        run_and_translate(main, ["foo", "bar"])
        assert captured == [["foo", "bar"]]


# ---------------------------------------------------------------------------
# run_and_translate — typed exception → category exit code
# ---------------------------------------------------------------------------


class TestExceptionCategoryMapping:
    """Each AppError category should map to its exit code via MRO."""

    @pytest.mark.parametrize(
        "exc,expected",
        [
            (ConfigError("x"), EXIT_CONFIG_ERROR),
            (AppConfigError("x"), EXIT_CONFIG_ERROR),
            (OperationError("x"), EXIT_OPERATION_ERROR),
            (CommandError(["true"], 1, ""), EXIT_OPERATION_ERROR),
            (CancellationError(), EXIT_OPERATION_ERROR),  # subclass of OperationError
            (BootstrapError("step", cause=RuntimeError("x")), EXIT_OPERATION_ERROR),
            (GuestUnreachableError("x"), EXIT_GUEST_ERROR),
            (HostEnvironmentError("x"), EXIT_ENVIRONMENT_ERROR),
            (ProviderError("x"), EXIT_PROVIDER_ERROR),
            (ResourceNotFoundError("x"), EXIT_PROVIDER_ERROR),
            (VaultError("x"), EXIT_VAULT_ERROR),
            # Base-level fallback (R-003): direct AppError subclasses with
            # no specific category resolve to EXIT_APP_ERROR, NOT EXIT_UNKNOWN.
            (SchedulerError("x"), EXIT_APP_ERROR),
            (HttpError("x"), EXIT_APP_ERROR),
            (HttpUnauthorizedError("x"), EXIT_APP_ERROR),
        ],
    )
    def test_category_mapping(
        self,
        exc: AppError,
        expected: int,
    ) -> None:
        def main(_argv: Sequence[str]) -> int:
            raise exc

        assert run_and_translate(main, []) == expected

    def test_subclass_of_consumer_exception_uses_base_mapping(self) -> None:
        class MyOpError(OperationError):
            pass

        def main(_argv: Sequence[str]) -> int:
            raise MyOpError("custom")

        assert run_and_translate(main, []) == EXIT_OPERATION_ERROR


# ---------------------------------------------------------------------------
# run_and_translate — EVERY typed AppError gets a typed exit (R-003 / EH-04)
# ---------------------------------------------------------------------------


def _public_app_error_classes() -> list[type[AppError]]:
    """Every public AppError subclass exported by mgf.common.exceptions."""
    import mgf.common.exceptions as exc_mod

    out: list[type[AppError]] = []
    for name in exc_mod.__all__:
        obj = getattr(exc_mod, name)
        if isinstance(obj, type) and issubclass(obj, AppError):
            out.append(obj)
    return out


class TestEveryTypedErrorIsMapped:
    """Completeness guard: no public AppError subclass may fall through to
    EXIT_UNKNOWN. This is the durable defence against the F-003 class of
    drift — when a new direct AppError subclass is added without a category,
    the AppError-base fallback (EXIT_APP_ERROR) catches it instead of
    silently producing an 'unknown crash'."""

    def test_no_public_app_error_resolves_to_unknown(self) -> None:
        offenders: list[str] = []
        for cls in _public_app_error_classes():
            # __new__ avoids per-class constructor-arg differences.
            inst = cls.__new__(cls)
            code = _resolve_exit_code(inst, None)
            if code is None or code == EXIT_UNKNOWN:
                offenders.append(cls.__name__)
        assert not offenders, (
            "these public AppError subclasses fall through to EXIT_UNKNOWN "
            f"(add a category or rely on the AppError fallback): {offenders}"
        )

    def test_typed_error_does_not_ship_a_crash_report(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        # A TYPED, expected error must NOT write a crash file — that is
        # reserved for genuinely untyped exceptions.
        import mgf.common.observability as obs

        calls: list[object] = []
        monkeypatch.setattr(
            obs,
            "report_now",
            lambda exc, **kw: calls.append(exc) or "/dev/null",
        )

        def main(_argv: Sequence[str]) -> int:
            raise SchedulerError("expected scheduler failure")

        assert run_and_translate(main, []) == EXIT_APP_ERROR
        assert calls == [], "typed AppError must not ship a crash report"

    def test_untyped_exception_still_ships_a_crash_report(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        import mgf.common.observability as obs

        calls: list[object] = []
        monkeypatch.setattr(
            obs,
            "report_now",
            lambda exc, **kw: calls.append(exc) or "/dev/null",
        )

        def main(_argv: Sequence[str]) -> int:
            raise RuntimeError("genuinely untyped")

        assert run_and_translate(main, []) == EXIT_UNKNOWN
        assert len(calls) == 1, "untyped exception MUST ship a crash report"


# ---------------------------------------------------------------------------
# run_and_translate — overrides
# ---------------------------------------------------------------------------


class TestExitCodeOverrides:
    """Per-CLI overrides via the ``exit_code_map`` parameter."""

    def test_override_for_specific_exception(self) -> None:
        class LicenseError(AppError):
            pass

        def main(_argv: Sequence[str]) -> int:
            raise LicenseError("expired")

        assert (
            run_and_translate(
                main,
                [],
                exit_code_map={LicenseError: 7},
            )
            == 7
        )

    def test_override_takes_precedence_over_default_at_same_mro_level(
        self,
    ) -> None:
        # Override the OperationError mapping itself.
        def main(_argv: Sequence[str]) -> int:
            raise CommandError(["true"], 1, "")

        # CommandError is OperationError; override CommandError to 99.
        assert (
            run_and_translate(
                main,
                [],
                exit_code_map={CommandError: 99},
            )
            == 99
        )

    def test_more_specific_subclass_override_beats_base(self) -> None:
        # Without override → ResourceNotFoundError → ProviderError → 5.
        # With override on the specific class → custom value.
        def main(_argv: Sequence[str]) -> int:
            raise ResourceNotFoundError("widget")

        assert (
            run_and_translate(
                main,
                [],
                exit_code_map={ResourceNotFoundError: 42},
            )
            == 42
        )


# ---------------------------------------------------------------------------
# run_and_translate — special cases
# ---------------------------------------------------------------------------


class TestSpecialCases:
    """SystemExit / KeyboardInterrupt / untyped catch-all."""

    def test_system_exit_with_int_passes_through(self) -> None:
        def main(_argv: Sequence[str]) -> int:
            raise SystemExit(42)

        assert run_and_translate(main, []) == 42

    def test_system_exit_with_zero(self) -> None:
        def main(_argv: Sequence[str]) -> int:
            raise SystemExit(0)

        assert run_and_translate(main, []) == 0

    def test_system_exit_with_string_becomes_unknown(self) -> None:
        def main(_argv: Sequence[str]) -> int:
            # argparse on parse failure does this.
            raise SystemExit("usage: ...")

        assert run_and_translate(main, []) == EXIT_UNKNOWN

    def test_keyboard_interrupt_returns_130(self) -> None:
        def main(_argv: Sequence[str]) -> int:
            raise KeyboardInterrupt

        assert run_and_translate(main, []) == 130

    def test_untyped_exception_returns_unknown_and_calls_report_now(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        captured: list[tuple[BaseException, str]] = []

        def fake_report_now(exc: BaseException, *, source: str) -> Path:
            captured.append((exc, source))
            return Path("/tmp/fake-crash.json")

        monkeypatch.setattr(
            "mgf.common.observability.report_now",
            fake_report_now,
        )

        def main(_argv: Sequence[str]) -> int:
            raise RuntimeError("untyped boom")

        assert run_and_translate(main, []) == EXIT_UNKNOWN
        assert len(captured) == 1
        assert isinstance(captured[0][0], RuntimeError)
        assert str(captured[0][0]) == "untyped boom"
        assert captured[0][1] == "cli"

    def test_crash_source_override(
        self,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        captured: list[str] = []

        def fake_report_now(exc: BaseException, *, source: str) -> Path:
            captured.append(source)
            return Path("/tmp/x.json")

        monkeypatch.setattr(
            "mgf.common.observability.report_now",
            fake_report_now,
        )

        def main(_argv: Sequence[str]) -> int:
            raise RuntimeError

        run_and_translate(main, [], crash_source="my-cli:dump")
        assert captured == ["my-cli:dump"]


# ---------------------------------------------------------------------------
# translate_exceptions decorator
# ---------------------------------------------------------------------------


class TestTranslateExceptionsDecorator:
    """Decorator form."""

    def test_basic_wrap(self) -> None:
        @translate_exceptions()
        def main(_argv: Sequence[str]) -> int | None:
            return None

        assert main([]) == EXIT_SUCCESS

    def test_wrap_translates_typed_exception(self) -> None:
        @translate_exceptions()
        def main(_argv: Sequence[str]) -> int:
            raise VaultError("locked")

        assert main([]) == EXIT_VAULT_ERROR

    def test_wrap_with_override(self) -> None:
        class CustomError(AppError):
            pass

        @translate_exceptions(exit_code_map={CustomError: 99})
        def main(_argv: Sequence[str]) -> int:
            raise CustomError("x")

        assert main([]) == 99

    def test_wrap_preserves_function_metadata(self) -> None:
        @translate_exceptions()
        def my_main(_argv: Sequence[str]) -> int:
            """My main fn."""
            return 0

        assert my_main.__name__ == "my_main"
        assert my_main.__doc__ == "My main fn."
