"""Tests for :mod:`mgf.common.exceptions` — the base + categories +
generic concretes.

vm-vmanager-app-specific concrete subclasses (``DomainNotFoundError``,
``BackupError``, ``SpecValidationError``, etc.) live in vm-vmanager-app and
have their own tests there. This file covers ONLY what mgf.common
exposes.

Lifted from ``vmanager/tests/unit/test_exceptions.py`` (round 1
through 4 contributors); the vm-vmanager-app-specific assertions are
filtered out.
"""

from __future__ import annotations

import pytest

from mgf.common.exceptions import (
    AppConfigError,
    AppError,
    CommandError,
    ConfigError,
    GuestCommandError,
    GuestError,
    GuestUnreachableError,
    HostEnvironmentError,
    OperationError,
    ProviderError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
    SchedulerError,
    SchemaValidationError,
    VaultError,
)

# ---------------------------------------------------------------------------
# Hierarchy invariants
# ---------------------------------------------------------------------------


class TestHierarchy:
    def test_app_error_is_an_exception(self) -> None:
        assert issubclass(AppError, Exception)

    @pytest.mark.parametrize(
        ("child", "parent"),
        [
            (ConfigError, AppError),
            (ProviderError, AppError),
            (OperationError, AppError),
            (GuestError, AppError),
            (HostEnvironmentError, AppError),
            (VaultError, AppError),
            (SchedulerError, AppError),
            (SchemaValidationError, ConfigError),
            (AppConfigError, ConfigError),
            (ResourceNotFoundError, ProviderError),
            (ResourceAlreadyExistsError, ProviderError),
            (CommandError, OperationError),
            (GuestUnreachableError, GuestError),
            (GuestCommandError, GuestError),
        ],
    )
    def test_subclass_relationship(self, child: type[Exception], parent: type[Exception]) -> None:
        assert issubclass(child, parent)

    def test_host_environment_error_is_distinct_from_builtin(self) -> None:
        """``mgf.common.exceptions.HostEnvironmentError`` is distinct from
        Python's builtin ``EnvironmentError`` alias (which is itself an
        alias for ``OSError``). Pins the v0.33 rename.

        The deprecated ``EnvironmentError`` alias (which shadowed the
        builtin) was removed in v0.33.0 per its AP-12 cycle. This test
        asserts the new name has the right shape — distinct from OSError.
        """
        assert HostEnvironmentError is not OSError
        assert not issubclass(HostEnvironmentError, OSError)


# ---------------------------------------------------------------------------
# SchemaValidationError — carries .errors
# ---------------------------------------------------------------------------


class TestSchemaValidationError:
    def test_carries_all_errors_in_message(self) -> None:
        err = SchemaValidationError(["first error", "second error", "third"])
        msg = str(err)
        assert "first error" in msg
        assert "second error" in msg
        assert "third" in msg
        assert "validation failed:" in msg

    def test_errors_field_is_a_copy(self) -> None:
        """Mutating the input list must not mutate the exception."""
        original = ["only one"]
        err = SchemaValidationError(original)
        original.append("added later")
        assert err.errors == ["only one"]

    def test_empty_errors_rejected(self) -> None:
        with pytest.raises(ValueError, match="at least one error"):
            SchemaValidationError([])

    def test_paper_05_subclass_overrides_message_prefix(self) -> None:
        """FEEDBACK PAPER-05: subclasses customise the prefix via the
        ``_MESSAGE_PREFIX`` class attribute without bypassing
        ``__init__``. Before this fix, vm-vmanager-app's
        ``SpecValidationError`` had to call ``AppError.__init__``
        directly to skip the parent's hardcoded prefix.
        """

        class SpecValidationError(SchemaValidationError):
            _MESSAGE_PREFIX = "spec validation failed"

        err = SpecValidationError(["bad field"])
        msg = str(err)
        assert msg.startswith("spec validation failed:"), (
            f"PAPER-05: subclass prefix not applied; got {msg!r}"
        )
        # The .errors field still carries the input.
        assert err.errors == ["bad field"]
        # The base class still works with its default prefix.
        base_err = SchemaValidationError(["x"])
        assert str(base_err).startswith("validation failed:")


# ---------------------------------------------------------------------------
# ResourceNotFoundError / ResourceAlreadyExistsError — carry .name
# ---------------------------------------------------------------------------


class TestResourceErrors:
    def test_not_found_carries_name(self) -> None:
        err = ResourceNotFoundError("missing-thing")
        assert err.name == "missing-thing"
        assert "missing-thing" in str(err)

    def test_already_exists_carries_name(self) -> None:
        err = ResourceAlreadyExistsError("dup-thing")
        assert err.name == "dup-thing"
        assert "dup-thing" in str(err)


# ---------------------------------------------------------------------------
# CommandError — carries argv, returncode, stderr
# ---------------------------------------------------------------------------


class TestCommandError:
    def test_carries_all_fields(self) -> None:
        err = CommandError(
            argv=["qemu-img", "info", "/tmp/x.qcow2"],
            returncode=2,
            stderr="No such file",
        )
        assert err.argv == ["qemu-img", "info", "/tmp/x.qcow2"]
        assert err.returncode == 2
        assert err.stderr == "No such file"

    def test_message_includes_argv_returncode_stderr(self) -> None:
        err = CommandError(
            argv=["virsh", "list"],
            returncode=1,
            stderr="connection refused",
        )
        msg = str(err)
        assert "command failed (exit 1)" in msg
        assert "virsh list" in msg
        assert "connection refused" in msg

    def test_argv_is_copied(self) -> None:
        original = ["ls", "-la"]
        err = CommandError(argv=original, returncode=0)
        original.append("/tmp")
        assert err.argv == ["ls", "-la"]

    def test_empty_stderr_omits_tail(self) -> None:
        err = CommandError(argv=["true"], returncode=0)
        msg = str(err)
        # No trailing ": ..." when stderr is empty.
        assert msg.endswith("true")


# ---------------------------------------------------------------------------
# Catch-by-category invariant — the whole point of the hierarchy
# ---------------------------------------------------------------------------


class TestCatchByCategory:
    def test_can_catch_at_app_error_level(self) -> None:
        """Every exception in mgf.common is catchable at AppError."""
        for exc in (
            SchemaValidationError(["x"]),
            AppConfigError("bad config"),
            ResourceNotFoundError("x"),
            ResourceAlreadyExistsError("x"),
            CommandError(argv=["ls"], returncode=1),
            GuestUnreachableError("can't reach"),
            GuestCommandError("exit 1"),
        ):
            try:
                raise exc
            except AppError:
                pass
            else:
                pytest.fail(f"{type(exc).__name__} did not match AppError")

    def test_can_catch_at_category_level(self) -> None:
        with pytest.raises(ConfigError):
            raise SchemaValidationError(["x"])
        with pytest.raises(ProviderError):
            raise ResourceNotFoundError("x")
        with pytest.raises(OperationError):
            raise CommandError(argv=["x"], returncode=1)
        with pytest.raises(GuestError):
            raise GuestUnreachableError("x")


class TestExceptionHierarchyCompleteness:
    """EH-01 + TS-07: every public exception is on the AppError hierarchy,
    and the check is derived from `__all__` so a new exception added without
    a base fails here (not silently)."""

    def test_every_public_exception_subclasses_apperror(self) -> None:
        from mgf.common import exceptions
        from mgf.common.exceptions import AppError

        offenders = []
        for name in exceptions.__all__:
            obj = getattr(exceptions, name)
            if (
                isinstance(obj, type)
                and issubclass(obj, BaseException)
                and not issubclass(obj, AppError)
            ):
                offenders.append(name)
        assert not offenders, (
            f"public exceptions not on the AppError hierarchy (EH-01): {offenders}"
        )

    def test_idempotency_conflict_is_on_hierarchy(self) -> None:
        # EH-01 regression: this lived on bare Exception before the
        # 2026-05-29 conformance pass.
        from mgf.common.exceptions import AppError, OperationError
        from mgf.common.idempotency import IdempotencyKeyConflictError

        assert issubclass(IdempotencyKeyConflictError, OperationError)
        assert issubclass(IdempotencyKeyConflictError, AppError)


def test_ts07_every_public_exception_is_referenced_in_tests() -> None:
    """TS-07: every typed exception in ``__all__`` MUST be exercised by a test —
    enforced by reference across the suite (a new exception with no test fails
    here). Complements TestExceptionHierarchyCompleteness (membership only)."""
    from pathlib import Path

    import mgf.common.exceptions as exc_mod

    corpus = "\n".join(
        p.read_text(encoding="utf-8")
        for p in Path(__file__).resolve().parents[1].rglob("test_*.py")
    )
    missing = [
        n
        for n in exc_mod.__all__
        if isinstance(getattr(exc_mod, n), type)
        and issubclass(getattr(exc_mod, n), BaseException)
        and n not in corpus
    ]
    assert not missing, f"TS-07: typed exceptions never referenced in any test: {missing}"
