"""User-perspective tests for ``mgf.common.exceptions``.

Surfaces exercised:
  - The seven category bases (``ConfigError`` / ``OperationError`` /
    ``ProviderError`` / ``GuestError`` / ``HostEnvironmentError`` /
    ``VaultError`` / ``SchedulerError``)
  - The seven generic concretes
    (``SchemaValidationError`` / ``AppConfigError`` /
    ``CommandError`` / ``ResourceNotFoundError`` /
    ``ResourceAlreadyExistsError`` / ``GuestUnreachableError`` /
    ``GuestCommandError``)
  - ``BootstrapError`` (carries .failed_step)
  - ``CancellationError`` (catchable as ``OperationError``)
  - Subclassing pattern for project-specific exceptions
"""

from __future__ import annotations

import pytest

from mgf.common.exceptions import (
    AppConfigError,
    AppConfigErrorKind,
    AppError,
    BootstrapError,
    CancellationError,
    CommandError,
    HostEnvironmentError,
    HttpBadRequestError,
    HttpClientError,
    HttpConflictError,
    HttpError,
    HttpForbiddenError,
    HttpGoneError,
    HttpInternalServerError,
    HttpNotFoundError,
    HttpServerError,
    HttpServiceUnavailableError,
    HttpUnauthorizedError,
    HttpUnprocessableError,
    OperationError,
    ProviderError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
    SchemaValidationError,
)


class TestHierarchy:
    def test_every_concrete_subclasses_apperror(self) -> None:
        for cls in (
            CommandError,
            ResourceNotFoundError,
            ResourceAlreadyExistsError,
            SchemaValidationError,
            BootstrapError,
            CancellationError,
            HostEnvironmentError,
        ):
            assert issubclass(cls, AppError), f"{cls.__name__} is not AppError"

    def test_cancellation_caught_as_operation_error(self) -> None:
        """Existing ``except OperationError:`` clauses catch
        ``CancellationError`` without code change."""
        try:
            raise CancellationError
        except OperationError:
            return  # caught — contract holds
        raise AssertionError("CancellationError not caught as OperationError")

    def test_resource_not_found_error_carries_name(self) -> None:
        err = ResourceNotFoundError("vm-42")
        assert err.name == "vm-42"
        assert "vm-42" in str(err)

    def test_resource_already_exists_carries_name(self) -> None:
        err = ResourceAlreadyExistsError("vm-42")
        assert err.name == "vm-42"

    def test_command_error_carries_argv_and_returncode(self) -> None:
        err = CommandError(argv=["ls", "/nope"], returncode=2, stderr="not found")
        assert err.argv == ["ls", "/nope"]
        assert err.returncode == 2
        assert err.stderr == "not found"

    def test_schema_validation_error_carries_errors(self) -> None:
        err = SchemaValidationError(["field 'x' is required", "field 'y' must be int"])
        assert err.errors == ["field 'x' is required", "field 'y' must be int"]
        assert "validation failed" in str(err)


class TestSubclassing:
    """The canonical pattern from PUBLIC_API.md / examples."""

    def test_subclass_a_concrete(self) -> None:
        class DomainNotFoundError(ResourceNotFoundError):
            def __init__(self, name: str) -> None:
                super().__init__(name)
                # Override the message format with project flavour.
                self.args = (f"no domain named {name!r}; run 'myapp list'",)

        err = DomainNotFoundError("corp-net")
        assert err.name == "corp-net"
        assert "myapp list" in str(err)
        # Still catchable via the generic concrete:
        try:
            raise err
        except ResourceNotFoundError:
            pass
        # AND via the category base:
        try:
            raise err
        except ProviderError:
            pass

    def test_subclass_a_category(self) -> None:
        class MyAppError(AppError):
            """Project-wide nominal alias."""

        err = MyAppError("anything")
        assert isinstance(err, AppError)


class TestBootstrapError:
    def test_carries_failed_step(self) -> None:
        """BootstrapError exposes the failed step + composes the
        cause's message into its own message. Note: the library
        does NOT chain via ``__cause__`` — the cause is text-only.
        Worth a follow-up if consumers want PEP-3134 chaining."""
        cause = ValueError("inner reason")
        err = BootstrapError("logging", cause=cause)
        assert err.failed_step == "logging"
        # Cause's message text is composed into the error message.
        assert "inner reason" in str(err)
        assert "logging" in str(err)


class TestRaiseAndCatch:
    """End-to-end: raise + catch + inspect a typed exception."""

    def test_user_can_catch_apperror_root(self) -> None:
        with pytest.raises(AppError):
            raise CommandError(argv=["false"], returncode=1, stderr="")


class TestAppConfigErrorKind:
    """Public discriminator for `AppConfigError` failure modes
    (closes FEEDBACK PAPER-11)."""

    def test_kind_is_str_enum(self) -> None:
        # str-enum so the value doubles as a stable string contract
        # for any non-Python consumer reading a serialised report.
        assert issubclass(AppConfigErrorKind, str)
        assert AppConfigErrorKind.JSON_PARSE_FAILURE == "json_parse_failure"

    def test_default_kind_is_none(self) -> None:
        # Bare construction — no opt-in raise site sets `kind`.
        err = AppConfigError("anything")
        assert err.kind is None

    def test_kind_round_trips_through_assignment(self) -> None:
        # The assignment-after-construction pattern is the contract:
        # raising sites set `exc.kind = AppConfigErrorKind.X` between
        # construction and `raise`.
        err = AppConfigError("anything")
        err.kind = AppConfigErrorKind.MISSING_VERSION
        assert err.kind is AppConfigErrorKind.MISSING_VERSION

    def test_kind_enables_match_case_translation(self) -> None:
        # The end-to-end shape consumers use: pattern-match on `kind`,
        # leave `_` for the None-fallback / unrecognised cases.
        err = AppConfigError("any message")
        err.kind = AppConfigErrorKind.UNSUPPORTED_VERSION

        match err.kind:
            case AppConfigErrorKind.UNSUPPORTED_VERSION | AppConfigErrorKind.MISSING_VERSION:
                hit = "schema_version"
            case AppConfigErrorKind.JSON_PARSE_FAILURE | AppConfigErrorKind.YAML_PARSE_FAILURE:
                hit = "unreadable"
            case _:
                hit = "other"

        assert hit == "schema_version"


# ---------------------------------------------------------------------------
# HTTP exception category (closes FEEDBACK HTTP-01, v0.18.0)
# ---------------------------------------------------------------------------


class TestHttpErrorHierarchy:
    """The eighth category in the typed-exception taxonomy. Web/API
    consumers subclass these for HTTP-status-coded exceptions and
    inherit ``http_status`` for framework-side response mapping.
    """

    def test_http_error_is_app_error(self) -> None:
        # Every typed exception MUST subclass AppError so existing
        # `except AppError:` clauses catch HTTP exceptions too.
        assert issubclass(HttpError, AppError)

    def test_two_subcategories_under_http_error(self) -> None:
        # Category-catch property: HttpClientError (4xx) +
        # HttpServerError (5xx) split the taxonomy.
        assert issubclass(HttpClientError, HttpError)
        assert issubclass(HttpServerError, HttpError)
        # And NOT cross-related — 4xx is not a 5xx.
        assert not issubclass(HttpClientError, HttpServerError)
        assert not issubclass(HttpServerError, HttpClientError)

    @pytest.mark.parametrize(
        "cls,expected_status,expected_parent",
        [
            (HttpBadRequestError, 400, HttpClientError),
            (HttpUnauthorizedError, 401, HttpClientError),
            (HttpForbiddenError, 403, HttpClientError),
            (HttpNotFoundError, 404, HttpClientError),
            (HttpConflictError, 409, HttpClientError),
            (HttpGoneError, 410, HttpClientError),
            (HttpUnprocessableError, 422, HttpClientError),
            (HttpInternalServerError, 500, HttpServerError),
            (HttpServiceUnavailableError, 503, HttpServerError),
        ],
    )
    def test_concrete_leaves_carry_status_and_inherit_correctly(
        self,
        cls: type[HttpError],
        expected_status: int,
        expected_parent: type[HttpError],
    ) -> None:
        assert cls.http_status == expected_status
        assert issubclass(cls, expected_parent)
        assert issubclass(cls, HttpError)
        assert issubclass(cls, AppError)

    def test_consumer_subclass_inherits_http_status(self) -> None:
        # The canonical consumer pattern from inthewords' Phase 8D
        # central-interfaces design (HTTP-01 evidence).
        class RoomNotFoundError(HttpNotFoundError):
            """Project-specific 404 — the requested room doesn't exist."""

        e = RoomNotFoundError("room not found")
        assert e.http_status == 404
        # Catchable at every layer of the category-catch ladder.
        assert isinstance(e, HttpNotFoundError)
        assert isinstance(e, HttpClientError)
        assert isinstance(e, HttpError)
        assert isinstance(e, AppError)

    def test_consumer_subclass_can_override_http_status(self) -> None:
        # The RoomArchivedError pattern from HTTP-01: subclass
        # NotFound but report 410 Gone.
        class RoomArchivedError(HttpGoneError):
            pass

        e = RoomArchivedError("room archived")
        assert e.http_status == 410

        # Subclass can override the status further if desired
        # (e.g., a project-specific 451 mapping):
        class CensoredContentError(HttpClientError):
            http_status = 451

        assert CensoredContentError().http_status == 451

    def test_category_catch_at_three_levels(self) -> None:
        # The full category-catch ladder: catch by leaf, by 4xx/5xx
        # subcategory, or by HttpError as a whole.
        for thrower, catch_cls, expected in (
            (HttpNotFoundError, HttpClientError, True),
            (HttpServiceUnavailableError, HttpServerError, True),
            (HttpForbiddenError, HttpServerError, False),  # 403 isn't 5xx
            (HttpInternalServerError, HttpClientError, False),  # 500 isn't 4xx
            (HttpNotFoundError, HttpError, True),
            (HttpServiceUnavailableError, HttpError, True),
        ):
            try:
                raise thrower("test")
            except catch_cls:
                got = True
            except Exception:
                got = False
            assert got is expected, (
                f"raise {thrower.__name__} ; except {catch_cls.__name__} "
                f"— expected {expected}, got {got}"
            )

    def test_distinct_from_provider_resource_not_found(self) -> None:
        # PAPER-01-style boundary: HttpNotFoundError is web-domain;
        # ResourceNotFoundError is provider-domain (SDK / hardware).
        # They MUST NOT subclass each other — different category trees.
        assert not issubclass(HttpNotFoundError, ResourceNotFoundError)
        assert not issubclass(ResourceNotFoundError, HttpNotFoundError)
        # Both subclass AppError, the universal root.
        assert issubclass(HttpNotFoundError, AppError)
        assert issubclass(ResourceNotFoundError, AppError)


class TestActionableHints:
    """EH-09: library-owned user-facing messages carry an actionable hint —
    substring-pinned so a refactor can't silently drop it."""

    def test_bootstrap_error_carries_retry_hint(self) -> None:
        e = BootstrapError("logging", cause=RuntimeError("boom"))
        msg = str(e)
        assert "retry mgf.common.bootstrap()" in msg
        # and still composes the underlying cause (regression guard for the
        # existing "inner reason" assertion).
        assert "boom" in msg
