"""
Tests for custom exception classes in app.core.exceptions.

Tests verify:
- Exception hierarchy (all inherit from GameEngineError)
- Exception attributes are properly set
- String representations are informative
- Exceptions can be caught by parent class

Author: Claude
Date: 2025-11-27
"""

from uuid import uuid4

import pytest

from app.core.exceptions import (
    AuthorizationError,
    DatabaseError,
    DecisionTimeoutError,
    ExternalAPIError,
    GameEngineError,
    GameNotFoundError,
    InvalidGameStateError,
    LineupError,
    PlayerDataError,
    SubstitutionError,
)


class TestGameEngineErrorHierarchy:
    """
    Test that all custom exceptions inherit from GameEngineError.

    Why: Allows catching all game-specific errors with a single except clause
    while still handling specific errors differently when needed.
    """

    def test_game_not_found_error_inherits_from_base(self):
        """GameNotFoundError should be catchable as GameEngineError."""
        exc = GameNotFoundError(uuid4())
        assert isinstance(exc, GameEngineError)
        assert isinstance(exc, Exception)

    def test_invalid_game_state_error_inherits_from_base(self):
        """InvalidGameStateError should be catchable as GameEngineError."""
        exc = InvalidGameStateError("test message")
        assert isinstance(exc, GameEngineError)

    def test_substitution_error_inherits_from_base(self):
        """SubstitutionError should be catchable as GameEngineError."""
        exc = SubstitutionError("test message")
        assert isinstance(exc, GameEngineError)

    def test_authorization_error_inherits_from_base(self):
        """AuthorizationError should be catchable as GameEngineError."""
        exc = AuthorizationError("test message")
        assert isinstance(exc, GameEngineError)

    def test_decision_timeout_error_inherits_from_base(self):
        """DecisionTimeoutError should be catchable as GameEngineError."""
        exc = DecisionTimeoutError(uuid4(), "defensive", 30)
        assert isinstance(exc, GameEngineError)

    def test_database_error_inherits_from_base(self):
        """DatabaseError should be catchable as GameEngineError."""
        exc = DatabaseError("save_play")
        assert isinstance(exc, GameEngineError)

    def test_lineup_error_inherits_from_base(self):
        """LineupError should be catchable as GameEngineError."""
        exc = LineupError(123, "test message")
        assert isinstance(exc, GameEngineError)

    def test_external_api_error_inherits_from_base(self):
        """ExternalAPIError should be catchable as GameEngineError."""
        exc = ExternalAPIError("SBA API", "test message")
        assert isinstance(exc, GameEngineError)

    def test_player_data_error_inherits_from_external_api_error(self):
        """PlayerDataError should be catchable as ExternalAPIError and GameEngineError."""
        exc = PlayerDataError(12345)
        assert isinstance(exc, ExternalAPIError)
        assert isinstance(exc, GameEngineError)


class TestGameNotFoundError:
    """
    Test GameNotFoundError exception.

    Why: This is the most common error - game not in memory or database.
    """

    def test_stores_game_id_as_uuid(self):
        """Game ID should be accessible from exception."""
        game_id = uuid4()
        exc = GameNotFoundError(game_id)
        assert exc.game_id == game_id

    def test_stores_game_id_as_string(self):
        """Should accept string game_id too."""
        game_id = "test-game-id"
        exc = GameNotFoundError(game_id)
        assert exc.game_id == game_id

    def test_string_representation(self):
        """String representation should include game_id."""
        game_id = uuid4()
        exc = GameNotFoundError(game_id)
        assert str(game_id) in str(exc)
        assert "not found" in str(exc).lower()


class TestInvalidGameStateError:
    """
    Test InvalidGameStateError exception.

    Why: Helps debugging when game is in wrong state for an operation.
    """

    def test_stores_message(self):
        """Message should be accessible via str()."""
        exc = InvalidGameStateError("Game already started")
        assert "Game already started" in str(exc)

    def test_stores_current_and_expected_state(self):
        """Current and expected state should be accessible."""
        exc = InvalidGameStateError(
            "Invalid transition",
            current_state="completed",
            expected_state="active",
        )
        assert exc.current_state == "completed"
        assert exc.expected_state == "active"

    def test_optional_state_fields(self):
        """State fields should be optional."""
        exc = InvalidGameStateError("Just a message")
        assert exc.current_state is None
        assert exc.expected_state is None


class TestSubstitutionError:
    """
    Test SubstitutionError exception.

    Why: Provides error codes for frontend to display appropriate messages.
    """

    def test_default_error_code(self):
        """Should have default error code."""
        exc = SubstitutionError("Player not found")
        assert exc.error_code == "SUBSTITUTION_ERROR"

    def test_custom_error_code(self):
        """Should accept custom error code."""
        exc = SubstitutionError("Already in game", error_code="ALREADY_ACTIVE")
        assert exc.error_code == "ALREADY_ACTIVE"

    def test_message_accessible(self):
        """Message should be accessible via str()."""
        exc = SubstitutionError("Player not found")
        assert "Player not found" in str(exc)


class TestDecisionTimeoutError:
    """
    Test DecisionTimeoutError exception.

    Why: Provides context about which decision timed out.
    """

    def test_stores_all_attributes(self):
        """All attributes should be accessible."""
        game_id = uuid4()
        exc = DecisionTimeoutError(game_id, "defensive", 30)
        assert exc.game_id == game_id
        assert exc.decision_type == "defensive"
        assert exc.timeout_seconds == 30

    def test_string_includes_context(self):
        """String representation should include all context."""
        game_id = uuid4()
        exc = DecisionTimeoutError(game_id, "offensive", 45)
        msg = str(exc)
        assert str(game_id) in msg
        assert "offensive" in msg
        assert "45" in msg


class TestDatabaseError:
    """
    Test DatabaseError exception.

    Why: Wraps SQLAlchemy exceptions with operation context.
    """

    def test_stores_operation(self):
        """Operation should be accessible."""
        exc = DatabaseError("save_play")
        assert exc.operation == "save_play"

    def test_stores_original_error(self):
        """Original error should be accessible."""
        original = ValueError("test error")
        exc = DatabaseError("create_substitution", original)
        assert exc.original_error is original

    def test_string_includes_operation(self):
        """String representation should include operation."""
        exc = DatabaseError("save_rolls_batch")
        assert "save_rolls_batch" in str(exc)

    def test_string_includes_original_error(self):
        """String representation should include original error message."""
        original = ValueError("connection refused")
        exc = DatabaseError("update_game", original)
        assert "connection refused" in str(exc)


class TestLineupError:
    """
    Test LineupError exception.

    Why: Provides team context for lineup issues.
    """

    def test_stores_team_id(self):
        """Team ID should be accessible."""
        exc = LineupError(123, "No active pitcher")
        assert exc.team_id == 123

    def test_string_includes_team_and_message(self):
        """String representation should include team ID and message."""
        exc = LineupError(456, "Incomplete batting order")
        msg = str(exc)
        assert "456" in msg
        assert "Incomplete batting order" in msg


class TestExternalAPIError:
    """
    Test ExternalAPIError exception.

    Why: Distinguishes external service failures from internal errors.
    """

    def test_stores_service_name(self):
        """Service name should be accessible."""
        exc = ExternalAPIError("SBA API", "timeout")
        assert exc.service == "SBA API"

    def test_stores_status_code(self):
        """HTTP status code should be accessible."""
        exc = ExternalAPIError("PD API", "not found", status_code=404)
        assert exc.status_code == 404

    def test_optional_status_code(self):
        """Status code should be optional."""
        exc = ExternalAPIError("Test API", "connection failed")
        assert exc.status_code is None

    def test_string_includes_service(self):
        """String representation should include service name."""
        exc = ExternalAPIError("SBA API", "server error")
        assert "SBA API" in str(exc)


class TestPlayerDataError:
    """
    Test PlayerDataError exception.

    Why: Common error for player data lookups.
    """

    def test_stores_player_id(self):
        """Player ID should be accessible."""
        exc = PlayerDataError(12345)
        assert exc.player_id == 12345

    def test_default_service(self):
        """Should default to SBA API."""
        exc = PlayerDataError(12345)
        assert exc.service == "SBA API"

    def test_custom_service(self):
        """Should accept custom service name."""
        exc = PlayerDataError(12345, service="PD API")
        assert exc.service == "PD API"

    def test_string_includes_player_id(self):
        """String representation should include player ID."""
        exc = PlayerDataError(98765)
        assert "98765" in str(exc)


class TestExceptionCatching:
    """
    Test that exception hierarchy allows proper catching patterns.

    Why: Verify the practical use of catching exceptions at different levels.
    """

    def test_catch_specific_before_generic(self):
        """
        Verify that specific exceptions can be caught before generic ones.

        This is the expected pattern in handlers:
        try:
            ...
        except GameNotFoundError:
            # Handle missing game
        except GameEngineError:
            # Handle other game errors
        """
        game_id = uuid4()

        try:
            raise GameNotFoundError(game_id)
        except GameNotFoundError as e:
            assert e.game_id == game_id
            caught_specific = True
        except GameEngineError:
            caught_specific = False

        assert caught_specific

    def test_catch_generic_for_all_game_errors(self):
        """
        Verify that GameEngineError catches all game exceptions.

        This allows handlers to have a fallback for unexpected game errors.
        """
        exceptions = [
            GameNotFoundError(uuid4()),
            InvalidGameStateError("test"),
            SubstitutionError("test"),
            DatabaseError("test"),
            LineupError(123, "test"),
            PlayerDataError(123),
        ]

        for exc in exceptions:
            try:
                raise exc
            except GameEngineError:
                pass  # All should be caught
            else:
                pytest.fail(f"{type(exc).__name__} was not caught by GameEngineError")

    def test_generic_exception_does_not_catch_game_errors(self):
        """
        Verify that regular Exception catches game errors (as expected).

        This confirms game errors are still Exceptions for ultimate fallback.
        """
        try:
            raise GameNotFoundError(uuid4())
        except Exception:
            pass  # Should be caught
        else:
            pytest.fail("GameNotFoundError was not caught by Exception")
