"""Tests for domain error taxonomy."""

from collections.abc import Callable
from typing import cast

import grpc
import pytest

from noteflow.domain.errors import (
    ConsentRequiredError,
    DatabaseRequiredError,
    DomainError,
    ErrorCode,
    NotFoundError,
    ProviderError,
    StateError,
    ValidationError,
)


class TestErrorCode:
    """Tests for ErrorCode enum."""

    @pytest.mark.parametrize(
        ("error_code", "expected_string"),
        [
            pytest.param(
                ErrorCode.MEETING_NOT_FOUND, "meeting_not_found", id="meeting_not_found"
            ),
            pytest.param(
                ErrorCode.INVALID_REQUEST, "invalid_request", id="invalid_request"
            ),
            pytest.param(
                ErrorCode.SEGMENT_NOT_FOUND, "segment_not_found", id="segment_not_found"
            ),
            pytest.param(
                ErrorCode.PROVIDER_ERROR, "provider_error", id="provider_error"
            ),
        ],
    )
    def test_error_code_has_string_code(
        self, error_code: ErrorCode, expected_string: str
    ) -> None:
        """Error codes provide string identifiers."""
        assert (
            error_code.code == expected_string
        ), f"{error_code.name} should have code '{expected_string}'"

    @pytest.mark.parametrize(
        ("error_code", "expected_grpc_status"),
        [
            pytest.param(
                ErrorCode.MEETING_NOT_FOUND,
                grpc.StatusCode.NOT_FOUND,
                id="not_found_maps_to_NOT_FOUND",
            ),
            pytest.param(
                ErrorCode.INVALID_REQUEST,
                grpc.StatusCode.INVALID_ARGUMENT,
                id="invalid_request_maps_to_INVALID_ARGUMENT",
            ),
            pytest.param(
                ErrorCode.MEETING_ALREADY_STOPPED,
                grpc.StatusCode.FAILED_PRECONDITION,
                id="state_error_maps_to_FAILED_PRECONDITION",
            ),
            pytest.param(
                ErrorCode.PROVIDER_ERROR,
                grpc.StatusCode.INTERNAL,
                id="provider_error_maps_to_INTERNAL",
            ),
        ],
    )
    def test_error_code_maps_to_grpc_status(
        self, error_code: ErrorCode, expected_grpc_status: grpc.StatusCode
    ) -> None:
        """Error codes map to appropriate gRPC status."""
        assert (
            error_code.grpc_status == expected_grpc_status
        ), f"{error_code.name} should map to {expected_grpc_status.name}"

    @pytest.mark.parametrize(
        "error_code",
        [pytest.param(code, id=code.name) for code in ErrorCode],
    )
    def test_all_error_codes_have_grpc_mapping(self, error_code: ErrorCode) -> None:
        """All error codes have valid gRPC status mappings."""
        assert isinstance(
            error_code.grpc_status, grpc.StatusCode
        ), f"{error_code.name} should have valid gRPC status"
        assert error_code.code, f"{error_code.name} should have non-empty string code"


class TestDomainError:
    """Tests for base DomainError class."""

    def test_domain_error_preserves_message(self) -> None:
        """DomainError stores and exposes message."""
        error = DomainError(ErrorCode.INTERNAL_ERROR, "Something went wrong")
        assert (
            error.message == "Something went wrong"
        ), "message property should return original message"
        assert (
            str(error) == "Something went wrong"
        ), "__str__ should return original message"

    def test_domain_error_stores_error_code(self) -> None:
        """DomainError stores error code."""
        error = DomainError(ErrorCode.MEETING_NOT_FOUND, "Meeting not found")
        assert (
            error.error_code == ErrorCode.MEETING_NOT_FOUND
        ), "error_code should match provided code"

    def test_domain_error_exposes_grpc_status(self) -> None:
        """DomainError exposes gRPC status from error code."""
        error = DomainError(ErrorCode.SEGMENT_NOT_FOUND, "Segment not found")
        assert (
            error.grpc_status == grpc.StatusCode.NOT_FOUND
        ), "grpc_status should derive from error_code"

    def test_domain_error_accepts_details(self) -> None:
        """DomainError stores optional details."""
        error = DomainError(
            ErrorCode.INVALID_REQUEST,
            "Invalid request",
            details={"field": "email", "reason": "malformed"},
        )
        assert error.details == {
            "field": "email",
            "reason": "malformed",
        }, "details should store provided dict"

    def test_domain_error_defaults_to_empty_details(self) -> None:
        """DomainError defaults to empty details dict."""
        error = DomainError(ErrorCode.INTERNAL_ERROR, "Error")
        assert error.details == {}, "details should default to empty dict"


class TestNotFoundError:
    """Tests for NotFoundError class."""

    def test_not_found_error_formats_message(self) -> None:
        """NotFoundError formats message with resource type and ID."""
        error = NotFoundError("meeting", "abc-123")
        assert (
            error.message == "Meeting 'abc-123' not found"
        ), "message should format resource type and ID"

    @pytest.mark.parametrize(
        ("resource_type", "resource_id", "expected_code"),
        [
            pytest.param("meeting", "123", ErrorCode.MEETING_NOT_FOUND, id="meeting"),
            pytest.param("segment", "456", ErrorCode.SEGMENT_NOT_FOUND, id="segment"),
            pytest.param("entity", "789", ErrorCode.ENTITY_NOT_FOUND, id="entity"),
            pytest.param("job", "abc", ErrorCode.JOB_NOT_FOUND, id="job"),
        ],
    )
    def test_not_found_error_auto_detects_error_code(
        self, resource_type: str, resource_id: str, expected_code: ErrorCode
    ) -> None:
        """NotFoundError auto-detects error code from resource type."""
        error = NotFoundError(resource_type, resource_id)
        assert (
            error.error_code == expected_code
        ), f"resource_type '{resource_type}' should map to {expected_code.name}"

    def test_not_found_error_falls_back_to_entity_not_found(self) -> None:
        """NotFoundError falls back to ENTITY_NOT_FOUND for unknown types."""
        error = NotFoundError("widget", "789")
        assert (
            error.error_code == ErrorCode.ENTITY_NOT_FOUND
        ), "unknown resource_type should fall back to ENTITY_NOT_FOUND"

    def test_not_found_error_accepts_explicit_error_code(self) -> None:
        """NotFoundError accepts explicit error code override."""
        error = NotFoundError("item", "123", error_code=ErrorCode.JOB_NOT_FOUND)
        assert (
            error.error_code == ErrorCode.JOB_NOT_FOUND
        ), "explicit error_code should override auto-detection"

    def test_not_found_error_stores_details(self) -> None:
        """NotFoundError stores resource type and ID in details."""
        error = NotFoundError("meeting", "abc-123")
        assert (
            error.details["resource_type"] == "meeting"
        ), "details should contain resource_type"
        assert (
            error.details["resource_id"] == "abc-123"
        ), "details should contain resource_id"


class TestValidationError:
    """Tests for ValidationError class."""

    def test_validation_error_formats_message(self) -> None:
        """ValidationError formats message with field and reason."""
        error = ValidationError("email", "must be a valid email address")
        assert (
            error.message
            == "Validation failed for 'email': must be a valid email address"
        ), "message should format field and reason"

    def test_validation_error_uses_invalid_request_by_default(self) -> None:
        """ValidationError uses INVALID_REQUEST by default."""
        error = ValidationError("field", "reason")
        assert (
            error.error_code == ErrorCode.INVALID_REQUEST
        ), "default error_code should be INVALID_REQUEST"
        assert (
            error.grpc_status == grpc.StatusCode.INVALID_ARGUMENT
        ), "grpc_status should be INVALID_ARGUMENT"

    def test_validation_error_accepts_explicit_error_code(self) -> None:
        """ValidationError accepts explicit error code."""
        error = ValidationError("id", "malformed", error_code=ErrorCode.INVALID_MEETING_ID)
        assert (
            error.error_code == ErrorCode.INVALID_MEETING_ID
        ), "explicit error_code should override default"

    def test_validation_error_stores_details(self) -> None:
        """ValidationError stores field and reason in details."""
        error = ValidationError("password", "too short")
        assert error.details["field"] == "password", "details should contain field"
        assert error.details["reason"] == "too short", "details should contain reason"


class TestStateError:
    """Tests for StateError class."""

    def test_state_error_preserves_message(self) -> None:
        """StateError preserves provided message."""
        error = StateError(ErrorCode.MEETING_ALREADY_STOPPED, "Meeting already stopped")
        assert (
            error.message == "Meeting already stopped"
        ), "message should match provided message"

    def test_state_error_stores_states(self) -> None:
        """StateError stores current and required state."""
        error = StateError(
            ErrorCode.MEETING_NOT_STOPPED,
            "Meeting must be stopped first",
            current_state="recording",
            required_state="stopped",
        )
        assert (
            error.details["current_state"] == "recording"
        ), "details should contain current_state"
        assert (
            error.details["required_state"] == "stopped"
        ), "details should contain required_state"

    def test_state_error_omits_none_states(self) -> None:
        """StateError omits None states from details."""
        error = StateError(ErrorCode.JOB_ALREADY_RUNNING, "Job already running")
        assert (
            "current_state" not in error.details
        ), "None current_state should not be in details"
        assert (
            "required_state" not in error.details
        ), "None required_state should not be in details"


class TestProviderError:
    """Tests for ProviderError class."""

    def test_provider_error_formats_message(self) -> None:
        """ProviderError formats message with provider, operation, and reason."""
        error = ProviderError("openai", "summarize", "rate limited")
        assert (
            error.message == "Provider 'openai' failed during 'summarize': rate limited"
        ), "message should format provider, operation, and reason"

    def test_provider_error_uses_provider_error_by_default(self) -> None:
        """ProviderError uses PROVIDER_ERROR by default."""
        error = ProviderError("anthropic", "complete", "timeout")
        assert (
            error.error_code == ErrorCode.PROVIDER_ERROR
        ), "default error_code should be PROVIDER_ERROR"
        assert (
            error.grpc_status == grpc.StatusCode.INTERNAL
        ), "grpc_status should be INTERNAL"

    def test_provider_error_accepts_explicit_error_code(self) -> None:
        """ProviderError accepts explicit error code."""
        error = ProviderError(
            "ollama",
            "generate",
            "service unavailable",
            error_code=ErrorCode.PROVIDER_UNAVAILABLE,
        )
        assert (
            error.error_code == ErrorCode.PROVIDER_UNAVAILABLE
        ), "explicit error_code should override default"

    def test_provider_error_stores_details(self) -> None:
        """ProviderError stores provider, operation, and reason."""
        error = ProviderError("google", "translate", "quota exceeded")
        assert (
            error.details["provider_name"] == "google"
        ), "details should contain provider_name"
        assert (
            error.details["operation"] == "translate"
        ), "details should contain operation"
        assert (
            error.details["reason"] == "quota exceeded"
        ), "details should contain reason"


class TestConsentRequiredError:
    """Tests for ConsentRequiredError class."""

    def test_consent_error_formats_message(self) -> None:
        """ConsentRequiredError formats message with consent type and operation."""
        error = ConsentRequiredError("cloud_consent", "summarization")
        assert (
            error.message == "'cloud_consent' consent required for 'summarization'"
        ), "message should format consent_type and operation"

    def test_consent_error_uses_consent_required_code(self) -> None:
        """ConsentRequiredError uses CONSENT_REQUIRED code."""
        error = ConsentRequiredError("analytics", "tracking")
        assert (
            error.error_code == ErrorCode.CONSENT_REQUIRED
        ), "error_code should be CONSENT_REQUIRED"
        assert (
            error.grpc_status == grpc.StatusCode.FAILED_PRECONDITION
        ), "grpc_status should be FAILED_PRECONDITION"


class TestDatabaseRequiredError:
    """Tests for DatabaseRequiredError class."""

    def test_database_required_error_formats_message(self) -> None:
        """DatabaseRequiredError formats message with feature."""
        error = DatabaseRequiredError("annotations")
        assert (
            error.message == "'annotations' requires database persistence"
        ), "message should format feature name"

    def test_database_required_error_uses_database_required_code(self) -> None:
        """DatabaseRequiredError uses DATABASE_REQUIRED code."""
        error = DatabaseRequiredError("webhooks")
        assert (
            error.error_code == ErrorCode.DATABASE_REQUIRED
        ), "error_code should be DATABASE_REQUIRED"
        assert (
            error.grpc_status == grpc.StatusCode.FAILED_PRECONDITION
        ), "grpc_status should be FAILED_PRECONDITION"


class TestErrorHierarchy:
    """Tests for error class hierarchy."""

    @pytest.mark.parametrize(
        ("error_class", "args"),
        [
            pytest.param(NotFoundError, ("meeting", "123"), id="NotFoundError"),
            pytest.param(ValidationError, ("field", "reason"), id="ValidationError"),
            pytest.param(
                StateError,
                (ErrorCode.MEETING_ALREADY_STOPPED, "message"),
                id="StateError",
            ),
            pytest.param(
                ProviderError, ("provider", "op", "reason"), id="ProviderError"
            ),
            pytest.param(
                ConsentRequiredError, ("consent", "op"), id="ConsentRequiredError"
            ),
            pytest.param(DatabaseRequiredError, ("feature",), id="DatabaseRequiredError"),
        ],
    )
    def test_all_errors_inherit_from_domain_error(
        self,
        error_class: type[DomainError],
        args: tuple[object, ...],
    ) -> None:
        """All error classes inherit from DomainError."""
        error_factory = cast(Callable[..., DomainError], error_class)
        error = error_factory(*args)
        assert isinstance(
            error, DomainError
        ), f"{error_class.__name__} should inherit from DomainError"
        assert isinstance(
            error, Exception
        ), f"{error_class.__name__} should inherit from Exception"
