"""Tests for device authorization security (code enumeration prevention)."""

from sibyl.auth.primitives import (
    DeviceTokenError,
    generate_device_code,
    generate_user_code,
    hash_device_code,
    normalize_user_code,
)


class TestUserCodeNormalization:
    """Tests for user code normalization."""

    def test_valid_code_with_hyphen(self) -> None:
        """Valid code with hyphen should normalize correctly."""
        assert normalize_user_code("ABCD-EFGH") == "ABCD-EFGH"

    def test_valid_code_without_hyphen(self) -> None:
        """Valid code without hyphen should add hyphen."""
        assert normalize_user_code("ABCDEFGH") == "ABCD-EFGH"

    def test_lowercase_normalized_to_upper(self) -> None:
        """Lowercase codes should be uppercased."""
        assert normalize_user_code("abcd-efgh") == "ABCD-EFGH"

    def test_spaces_removed(self) -> None:
        """Spaces should be removed."""
        assert normalize_user_code("ABCD EFGH") == "ABCD-EFGH"

    def test_invalid_length_returns_none(self) -> None:
        """Invalid length should return None."""
        assert normalize_user_code("ABCD") is None
        assert normalize_user_code("ABCDEFGHIJ") is None

    def test_invalid_chars_returns_none(self) -> None:
        """Invalid characters (0, O, 1, I) should return None."""
        assert normalize_user_code("ABCD-0000") is None  # Contains 0
        assert normalize_user_code("OOOO-AAAA") is None  # Contains O
        assert normalize_user_code("1111-AAAA") is None  # Contains 1
        assert normalize_user_code("IIII-AAAA") is None  # Contains I

    def test_empty_returns_none(self) -> None:
        """Empty string should return None."""
        assert normalize_user_code("") is None
        assert normalize_user_code(None) is None


class TestGenerateUserCode:
    """Tests for user code generation."""

    def test_code_format(self) -> None:
        """Generated code should be in XXXX-XXXX format."""
        code = generate_user_code()
        assert len(code) == 9
        assert code[4] == "-"
        assert code[:4].isalnum()
        assert code[5:].isalnum()

    def test_code_has_no_ambiguous_chars(self) -> None:
        """Generated code should not contain 0, O, 1, I."""
        for _ in range(100):
            code = generate_user_code()
            assert "0" not in code
            assert "O" not in code
            assert "1" not in code
            assert "I" not in code


class TestGenerateDeviceCode:
    """Tests for raw device code generation."""

    def test_device_code_is_not_api_key_like(self) -> None:
        """Generated device codes should not look like API keys."""
        code = generate_device_code()
        assert code
        assert not code.startswith("sk_")


class TestDeviceAuthEnumerationPrevention:
    """Tests for code enumeration prevention.

    The verify endpoint should return the same response for:
    - Non-existent codes
    - Expired codes
    - Already consumed codes

    This prevents attackers from discovering valid codes.
    """

    def test_same_error_for_invalid_and_expired(self) -> None:
        """Invalid and expired codes should produce same error.

        This is a design requirement - the API should not reveal
        whether a code exists or not.
        """
        # Both invalid_user_code and expired_token should now be
        # combined into invalid_or_expired
        error_code = "invalid_or_expired"

        # Verify the error code pattern matches our fix
        assert "invalid" in error_code
        assert "expired" in error_code

    def test_pending_info_only_for_authenticated_users(self) -> None:
        """Pending request info should only be shown to authenticated users.

        Unauthenticated users should not see client_name, scope, or expiry
        even for valid codes - this prevents information leakage.
        """
        # This is validated by the implementation:
        # pending info is only set if `user` is not None


class TestDeviceAuthorizationManagerSecurity:
    """Security tests for DeviceAuthorizationManager."""

    def test_device_token_error_traceback_can_be_assigned(self) -> None:
        """DeviceTokenError must behave like a normal Exception during async unwinding."""
        error = DeviceTokenError("authorization_pending", "Authorization pending")

        error.__traceback__ = None

        assert error.error == "authorization_pending"

    def test_device_code_is_hashed(self) -> None:
        """Device codes should be stored as hashes, not plaintext."""
        device_code = "test-device-code-12345"
        hashed = hash_device_code(device_code)

        # Hash should be different from original
        assert hashed != device_code
        # Hash should be consistent (SHA-256 produces 64 hex chars)
        assert len(hashed) == 64
        # Same input produces same hash
        assert hash_device_code(device_code) == hashed

    def test_exchange_returns_same_error_for_all_invalid_states(self) -> None:
        """Token exchange should return same error type for all invalid states.

        This prevents attackers from distinguishing between:
        - Invalid device codes
        - Expired device codes
        - Already consumed codes
        """
        # All these should produce "invalid_grant" error
        error_types = {
            "invalid": DeviceTokenError("invalid_grant", "Invalid device_code"),
            "consumed": DeviceTokenError("invalid_grant", "Device code already used"),
        }

        # Verify all use the same error code
        for error in error_types.values():
            assert error.error == "invalid_grant"
