"""Tests for ``mgf.common.providers``: ABC + ``@translate_at_seam``."""

from __future__ import annotations

import asyncio

import pytest

from mgf.common.exceptions import (
    AppError,
    OperationError,
    ProviderError,
    ResourceNotFoundError,
)
from mgf.common.providers import ProviderABC, translate_at_seam

# ---------------------------------------------------------------------------
# Test fixtures: fake SDK exceptions + a sample provider.
# ---------------------------------------------------------------------------


class _SdkError(Exception):
    """Stand-in for an external SDK's base exception."""


class _SdkNotFound(_SdkError):  # noqa: N818 — mirrors real SDK naming
    """Stand-in for SDK's NotFound."""


class _SdkRateLimited(_SdkError):  # noqa: N818 — mirrors real SDK naming
    """Stand-in for SDK's RateLimited."""


class _Provider(ProviderABC):
    """Minimal provider for exercising the decorator."""

    def translate_exception(self, exc: Exception) -> AppError:
        if isinstance(exc, _SdkNotFound):
            return ResourceNotFoundError(name=str(exc))
        if isinstance(exc, _SdkRateLimited):
            return ProviderError(f"rate limited: {exc}")
        return ProviderError(f"upstream call failed: {exc}")

    @translate_at_seam
    def get_thing_sync(self, thing_id: str) -> dict[str, str]:
        if thing_id == "missing":
            raise _SdkNotFound(thing_id)
        if thing_id == "rate-limited":
            raise _SdkRateLimited(thing_id)
        if thing_id == "boom":
            raise _SdkError("upstream blew up")
        if thing_id == "typed-already":
            raise OperationError("already a typed error")
        return {"id": thing_id}

    @translate_at_seam
    async def get_thing_async(self, thing_id: str) -> dict[str, str]:
        if thing_id == "missing":
            raise _SdkNotFound(thing_id)
        if thing_id == "rate-limited":
            raise _SdkRateLimited(thing_id)
        if thing_id == "boom":
            raise _SdkError("upstream blew up")
        if thing_id == "typed-already":
            raise OperationError("already a typed error")
        return {"id": thing_id}


# ---------------------------------------------------------------------------
# ProviderABC contract.
# ---------------------------------------------------------------------------


class TestProviderABC:
    def test_abstract_method_must_be_implemented(self) -> None:
        with pytest.raises(TypeError):
            ProviderABC()  # type: ignore[abstract]

    def test_subclass_without_translate_exception_fails(self) -> None:
        class _Bad(ProviderABC):
            pass

        with pytest.raises(TypeError):
            _Bad()  # type: ignore[abstract]


# ---------------------------------------------------------------------------
# @translate_at_seam — sync.
# ---------------------------------------------------------------------------


class TestSyncDecorator:
    def test_passthrough_on_success(self) -> None:
        provider = _Provider()
        result = provider.get_thing_sync("ok")
        assert result == {"id": "ok"}

    def test_translates_sdk_not_found(self) -> None:
        provider = _Provider()
        with pytest.raises(ResourceNotFoundError) as info:
            provider.get_thing_sync("missing")
        # The original SDK exception is preserved as __cause__.
        assert isinstance(info.value.__cause__, _SdkNotFound)

    def test_translates_sdk_rate_limited(self) -> None:
        provider = _Provider()
        with pytest.raises(ProviderError) as info:
            provider.get_thing_sync("rate-limited")
        assert "rate limited" in str(info.value)
        assert isinstance(info.value.__cause__, _SdkRateLimited)

    def test_apperror_passes_through_unchanged(self) -> None:
        # Methods that already raise AppError should NOT be re-translated;
        # they pass through with their original identity (no double-wrap).
        provider = _Provider()
        with pytest.raises(OperationError) as info:
            provider.get_thing_sync("typed-already")
        # Not wrapped — same exception type, no __cause__ chain to a wrapper.
        assert info.value.__cause__ is None


# ---------------------------------------------------------------------------
# @translate_at_seam — async.
# ---------------------------------------------------------------------------


class TestAsyncDecorator:
    def test_async_passthrough_on_success(self) -> None:
        provider = _Provider()
        result = asyncio.run(provider.get_thing_async("ok"))
        assert result == {"id": "ok"}

    def test_async_translates_sdk_not_found(self) -> None:
        provider = _Provider()
        with pytest.raises(ResourceNotFoundError) as info:
            asyncio.run(provider.get_thing_async("missing"))
        assert isinstance(info.value.__cause__, _SdkNotFound)

    def test_async_apperror_passes_through(self) -> None:
        provider = _Provider()
        with pytest.raises(OperationError) as info:
            asyncio.run(provider.get_thing_async("typed-already"))
        assert info.value.__cause__ is None

    def test_async_cancellation_passes_through(self) -> None:
        # asyncio.CancelledError is a BaseException (Python 3.8+); the
        # decorator MUST NOT catch it, or Python's cancellation model
        # breaks.
        class _CancelProvider(ProviderABC):
            def translate_exception(self, exc: Exception) -> AppError:
                return ProviderError("should not be reached")

            @translate_at_seam
            async def will_be_cancelled(self) -> None:
                raise asyncio.CancelledError("test cancel")

        provider = _CancelProvider()
        with pytest.raises(asyncio.CancelledError):
            asyncio.run(provider.will_be_cancelled())


# ---------------------------------------------------------------------------
# Defensive contract — bad implementations should fail loudly.
# ---------------------------------------------------------------------------


class TestDefensiveContract:
    def test_translate_exception_returning_non_apperror_raises(self) -> None:
        class _BadProvider(ProviderABC):
            def translate_exception(self, exc: Exception) -> AppError:  # type: ignore[override]
                return "not an apperror"  # type: ignore[return-value]

            @translate_at_seam
            def go_wrong(self) -> None:
                raise RuntimeError("boom")

        provider = _BadProvider()
        with pytest.raises(TypeError, match="instead of AppError"):
            provider.go_wrong()

    def test_decorator_on_class_without_translate_exception(self) -> None:
        class _NoTranslate:
            @translate_at_seam
            def go_wrong(self) -> None:
                raise RuntimeError("boom")

        obj = _NoTranslate()
        with pytest.raises(TypeError, match="has no translate_exception"):
            obj.go_wrong()


# ---------------------------------------------------------------------------
# Functools.wraps preserved metadata (introspection-friendly).
# ---------------------------------------------------------------------------


class TestMetadata:
    def test_wraps_preserves_name_and_doc_sync(self) -> None:
        # The decorator should preserve the wrapped function's name +
        # docstring + qualname so tracebacks + sphinx + IDEs stay sane.
        class _DocProvider(ProviderABC):
            def translate_exception(self, exc: Exception) -> AppError:
                return ProviderError(str(exc))

            @translate_at_seam
            def my_documented_method(self, x: int) -> int:
                """Adds one."""
                return x + 1

        method = _DocProvider.my_documented_method
        assert method.__name__ == "my_documented_method"
        assert method.__doc__ == "Adds one."

    def test_wraps_preserves_name_async(self) -> None:
        class _AsyncDocProvider(ProviderABC):
            def translate_exception(self, exc: Exception) -> AppError:
                return ProviderError(str(exc))

            @translate_at_seam
            async def my_async_method(self) -> int:
                """Async one."""
                return 1

        method = _AsyncDocProvider.my_async_method
        assert method.__name__ == "my_async_method"
        assert method.__doc__ == "Async one."


def test_catch_all_unknown_vendor_error_sync() -> None:
    provider = _Provider()
    with pytest.raises(ProviderError) as info:
        provider.get_thing_sync("boom")
    assert "upstream call failed" in str(info.value)
    assert isinstance(info.value.__cause__, _SdkError)


def test_catch_all_unknown_vendor_error_async() -> None:
    provider = _Provider()
    with pytest.raises(ProviderError) as info:
        asyncio.run(provider.get_thing_async("boom"))
    assert "upstream call failed" in str(info.value)
    assert isinstance(info.value.__cause__, _SdkError)
