"""Tests for LLM retry utilities.

Tests verify exponential backoff retry logic for transient errors,
immediate failure on authentication errors, and all private helpers.
"""

from __future__ import annotations

from unittest.mock import MagicMock, patch

import pytest
from hypothesis import given, settings
from hypothesis import strategies as st

from unfamiliar.llm.exceptions import AuthenticationError
from unfamiliar.llm.retry import (
    _calculate_delay,
    _extract_status_code,
    _is_auth_error,
    _is_transient_error,
    with_llm_retry,
)


class ExceptionWithStatusCode(Exception):
    """Test exception with status_code attribute."""

    def __init__(self, message: str, status_code: int) -> None:
        super().__init__(message)
        self.status_code = status_code


class ExceptionWithResponse(Exception):
    """Test exception with response.status_code attribute."""

    def __init__(self, message: str, status_code: int) -> None:
        super().__init__(message)
        self.response = MagicMock()
        self.response.status_code = status_code


class ExceptionWithCode(Exception):
    """Test exception with code attribute."""

    def __init__(self, message: str, code: int | str | None) -> None:
        super().__init__(message)
        self.code = code


class TestWithLlmRetry:
    """Tests for with_llm_retry() public function."""

    def test_succeeds_on_first_call_returns_result_immediately(self):
        """Function succeeds on first attempt returns result with no retries."""
        call_count = 0

        def success_fn():
            nonlocal call_count
            call_count += 1
            return "success"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(success_fn)

        assert result == "success"
        assert call_count == 1
        assert mock_sleep.call_count == 0

    def test_transient_429_then_success_retries_and_returns(self):
        """429 error triggers retry, succeeds on third attempt."""
        call_count = 0

        def flaky_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ExceptionWithStatusCode("Rate limited", 429)
            return "recovered"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(flaky_fn)

        assert result == "recovered"
        assert call_count == 3
        assert mock_sleep.call_count == 2

    def test_auth_error_401_raises_immediately_no_retry(self):
        """401 authentication error raises AuthenticationError immediately."""
        call_count = 0

        def auth_fail_fn():
            nonlocal call_count
            call_count += 1
            raise ExceptionWithStatusCode("Unauthorized", 401)

        with (
            patch("unfamiliar.llm.retry.time.sleep") as mock_sleep,
            pytest.raises(AuthenticationError) as exc_info,
        ):
            with_llm_retry(auth_fail_fn)

        assert "authentication failed" in str(exc_info.value).lower()
        assert call_count == 1
        assert mock_sleep.call_count == 0

    def test_auth_error_403_raises_immediately_no_retry(self):
        """403 forbidden error raises AuthenticationError immediately."""
        call_count = 0

        def forbidden_fn():
            nonlocal call_count
            call_count += 1
            raise ExceptionWithStatusCode("Forbidden", 403)

        with (
            patch("unfamiliar.llm.retry.time.sleep") as mock_sleep,
            pytest.raises(AuthenticationError) as exc_info,
        ):
            with_llm_retry(forbidden_fn)

        assert "authentication failed" in str(exc_info.value).lower()
        assert call_count == 1
        assert mock_sleep.call_count == 0

    def test_max_attempts_exhausted_raises_last_exception(self):
        """All transient errors exhaust retries and raise last exception."""
        call_count = 0

        def always_fail_fn():
            nonlocal call_count
            call_count += 1
            raise ExceptionWithStatusCode(f"Service unavailable {call_count}", 503)

        with (
            patch("unfamiliar.llm.retry.time.sleep"),
            pytest.raises(ExceptionWithStatusCode) as exc_info,
        ):
            with_llm_retry(always_fail_fn, max_attempts=3)

        assert "Service unavailable 3" in str(exc_info.value)
        assert call_count == 3

    def test_max_attempts_one_single_attempt_no_retry(self):
        """max_attempts=1 means single attempt with no retries."""
        call_count = 0

        def fail_once_fn():
            nonlocal call_count
            call_count += 1
            raise ExceptionWithStatusCode("Temporary error", 502)

        with (
            patch("unfamiliar.llm.retry.time.sleep") as mock_sleep,
            pytest.raises(ExceptionWithStatusCode),
        ):
            with_llm_retry(fail_once_fn, max_attempts=1)

        assert call_count == 1
        assert mock_sleep.call_count == 0

    def test_non_transient_value_error_raises_immediately(self):
        """Non-transient errors like ValueError raise immediately without retry."""
        call_count = 0

        def value_error_fn():
            nonlocal call_count
            call_count += 1
            raise ValueError("Invalid JSON response")

        with (
            patch("unfamiliar.llm.retry.time.sleep") as mock_sleep,
            pytest.raises(ValueError) as exc_info,
        ):
            with_llm_retry(value_error_fn)

        assert "Invalid JSON response" in str(exc_info.value)
        assert call_count == 1
        assert mock_sleep.call_count == 0

    def test_transient_message_pattern_retried(self):
        """Errors with transient message patterns like 'rate limit' are retried."""
        call_count = 0

        def rate_limit_msg_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise RuntimeError("rate limit exceeded")
            return "success"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(rate_limit_msg_fn)

        assert result == "success"
        assert call_count == 3
        assert mock_sleep.call_count == 2

    def test_schema_validation_error_retried(self):
        """Schema validation errors from LLM output are retried.

        When the LLM returns valid JSON but with missing required fields,
        we retry rather than failing immediately.
        """
        call_count = 0

        def schema_error_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ValueError(
                    "LLM response did not match expected schema "
                    "CategoryMissionsResponse: Field required"
                )
            return "success"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(schema_error_fn)

        assert result == "success"
        assert call_count == 3
        assert mock_sleep.call_count == 2

    def test_pydantic_validation_error_retried(self):
        """Pydantic validation errors are retried."""
        call_count = 0

        def validation_error_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ValueError(
                    "Validation error for field 'completion_condition': Field required"
                )
            return "success"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(validation_error_fn)

        assert result == "success"
        assert call_count == 3
        assert mock_sleep.call_count == 2

    def test_connection_error_retried_as_transient(self):
        """Connection errors are detected and retried."""
        call_count = 0

        def connection_error_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ConnectionError("Network unreachable")
            return "connected"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(connection_error_fn)

        assert result == "connected"
        assert call_count == 3
        assert mock_sleep.call_count == 2

    def test_timeout_error_retried_as_transient(self):
        """Timeout errors are detected and retried."""
        call_count = 0

        def timeout_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise TimeoutError("Request timed out")
            return "completed"

        with patch("unfamiliar.llm.retry.time.sleep") as mock_sleep:
            result = with_llm_retry(timeout_fn)

        assert result == "completed"
        assert call_count == 3
        assert mock_sleep.call_count == 2

    def test_429_with_api_key_in_message_retries_not_auth_error(self):
        """A 429 mentioning 'api key' must retry, not raise AuthenticationError.

        GLM rate-limit responses often include 'api key' in the message,
        which previously false-positived as an auth error.
        """
        call_count = 0

        def rate_limit_with_api_key_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise Exception("429 Too many requests for api key abc123")
            return "success"

        with patch("unfamiliar.llm.retry.time.sleep"):
            result = with_llm_retry(rate_limit_with_api_key_fn)

        assert result == "success"
        assert call_count == 3

    def test_passes_positional_and_keyword_args_to_fn(self):
        """Function receives all positional and keyword arguments."""
        received_args = None

        def capture_fn(*args, **kwargs):
            nonlocal received_args
            received_args = (args, kwargs)
            return "ok"

        with patch("unfamiliar.llm.retry.time.sleep"):
            result = with_llm_retry(capture_fn, "a", "b", x=1, y=2)

        assert result == "ok"
        assert received_args == (("a", "b"), {"x": 1, "y": 2})


class TestBackoffTiming:
    """Tests for exponential backoff delay calculation."""

    def test_first_delay_is_base_times_2_0_plus_jitter(self):
        """First retry delay is base_delay * 2^0 + jitter in [0, 1)."""
        call_count = 0
        sleep_delays = []

        def capture_delay(delay):
            sleep_delays.append(delay)

        def fail_once_fn():
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                raise ExceptionWithStatusCode("Error", 500)
            return "ok"

        with patch("unfamiliar.llm.retry.time.sleep", side_effect=capture_delay):
            with_llm_retry(fail_once_fn, base_delay=1.0)

        assert len(sleep_delays) == 1
        assert 1.0 <= sleep_delays[0] < 2.0

    def test_second_delay_is_base_times_2_1_plus_jitter(self):
        """Second retry delay is base_delay * 2^1 + jitter in [0, 1)."""
        call_count = 0
        sleep_delays = []

        def capture_delay(delay):
            sleep_delays.append(delay)

        def fail_twice_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ExceptionWithStatusCode("Error", 500)
            return "ok"

        with patch("unfamiliar.llm.retry.time.sleep", side_effect=capture_delay):
            with_llm_retry(fail_twice_fn, base_delay=1.0)

        assert len(sleep_delays) == 2
        assert 1.0 <= sleep_delays[0] < 2.0
        assert 2.0 <= sleep_delays[1] < 3.0

    def test_custom_base_delay_scales_all_delays(self):
        """base_delay=2.0 doubles all delay values."""
        call_count = 0
        sleep_delays = []

        def capture_delay(delay):
            sleep_delays.append(delay)

        def fail_twice_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 2:
                raise ExceptionWithStatusCode("Error", 500)
            return "ok"

        with patch("unfamiliar.llm.retry.time.sleep", side_effect=capture_delay):
            with_llm_retry(fail_twice_fn, base_delay=2.0)

        assert len(sleep_delays) == 2
        assert 2.0 <= sleep_delays[0] < 3.0
        assert 4.0 <= sleep_delays[1] < 5.0

    def test_third_delay_is_base_times_2_2_plus_jitter(self):
        """Third retry delay is base_delay * 2^2 + jitter in [0, 1)."""
        call_count = 0
        sleep_delays = []

        def capture_delay(delay):
            sleep_delays.append(delay)

        def fail_thrice_fn():
            nonlocal call_count
            call_count += 1
            if call_count <= 3:
                raise ExceptionWithStatusCode("Error", 500)
            return "ok"

        with patch("unfamiliar.llm.retry.time.sleep", side_effect=capture_delay):
            with_llm_retry(fail_thrice_fn, base_delay=1.0)

        assert len(sleep_delays) == 3
        assert 1.0 <= sleep_delays[0] < 2.0
        assert 2.0 <= sleep_delays[1] < 3.0
        assert 4.0 <= sleep_delays[2] < 5.0


class TestIsAuthError:
    """Tests for _is_auth_error() private helper."""

    def test_status_code_401_returns_true(self):
        """401 status code indicates auth error."""
        exc = ExceptionWithStatusCode("Unauthorized", 401)

        assert _is_auth_error(exc) is True

    def test_status_code_403_returns_true(self):
        """403 status code indicates auth error."""
        exc = ExceptionWithStatusCode("Forbidden", 403)

        assert _is_auth_error(exc) is True

    def test_status_code_429_returns_false(self):
        """429 status code is not an auth error."""
        exc = ExceptionWithStatusCode("Rate limited", 429)

        assert _is_auth_error(exc) is False

    def test_status_code_500_returns_false(self):
        """500 status code is not an auth error."""
        exc = ExceptionWithStatusCode("Server error", 500)

        assert _is_auth_error(exc) is False

    def test_message_with_unauthorized_returns_true(self):
        """Message containing 'unauthorized' indicates auth error."""
        exc = Exception("Access unauthorized for this resource")

        assert _is_auth_error(exc) is True

    def test_message_with_invalid_api_key_returns_true(self):
        """Message containing 'invalid api key' indicates auth error."""
        exc = Exception("Invalid API key provided")

        assert _is_auth_error(exc) is True

    def test_message_with_forbidden_returns_true(self):
        """Message containing 'forbidden' indicates auth error."""
        exc = Exception("Access forbidden")

        assert _is_auth_error(exc) is True

    def test_message_with_generic_api_key_returns_false(self):
        """Bare 'api key' in message should NOT match — too broad.

        Rate-limit errors like '429 too many requests for api key xxx'
        would false-positive as auth errors, preventing retries.
        """
        exc = Exception("API key is missing or malformed")

        assert _is_auth_error(exc) is False

    def test_message_with_invalid_api_key_phrase_returns_true(self):
        """The specific phrase 'invalid api key' still matches."""
        exc = Exception("Invalid API key provided")

        assert _is_auth_error(exc) is True

    def test_message_with_authentication_failed_returns_true(self):
        """The phrase 'authentication failed' matches."""
        exc = Exception("Authentication failed for this request")

        assert _is_auth_error(exc) is True

    def test_unrelated_exception_returns_false(self):
        """Exception without auth indicators returns False."""
        exc = Exception("Connection timeout")

        assert _is_auth_error(exc) is False

    def test_case_insensitive_auth_detection(self):
        """Auth pattern matching is case insensitive."""
        exc = Exception("UNAUTHORIZED ACCESS")

        assert _is_auth_error(exc) is True


class TestIsTransientError:
    """Tests for _is_transient_error() private helper."""

    def test_status_code_429_returns_true(self):
        """429 rate limit is a transient error."""
        exc = ExceptionWithStatusCode("Too many requests", 429)

        assert _is_transient_error(exc) is True

    def test_status_code_500_returns_true(self):
        """500 server error is a transient error."""
        exc = ExceptionWithStatusCode("Internal server error", 500)

        assert _is_transient_error(exc) is True

    def test_status_code_502_returns_true(self):
        """502 bad gateway is a transient error."""
        exc = ExceptionWithStatusCode("Bad gateway", 502)

        assert _is_transient_error(exc) is True

    def test_status_code_503_returns_true(self):
        """503 service unavailable is a transient error."""
        exc = ExceptionWithStatusCode("Service unavailable", 503)

        assert _is_transient_error(exc) is True

    def test_status_code_504_returns_true(self):
        """504 gateway timeout is a transient error."""
        exc = ExceptionWithStatusCode("Gateway timeout", 504)

        assert _is_transient_error(exc) is True

    def test_status_code_401_returns_false(self):
        """401 is not a transient error."""
        exc = ExceptionWithStatusCode("Unauthorized", 401)

        assert _is_transient_error(exc) is False

    def test_status_code_404_returns_false(self):
        """404 is not a transient error."""
        exc = ExceptionWithStatusCode("Not found", 404)

        assert _is_transient_error(exc) is False

    def test_connection_error_type_returns_true(self):
        """ConnectionError exception type is transient."""
        exc = ConnectionError("Failed to connect")

        assert _is_transient_error(exc) is True

    def test_timeout_error_type_returns_true(self):
        """TimeoutError exception type is transient."""
        exc = TimeoutError("Request timed out")

        assert _is_transient_error(exc) is True

    def test_message_with_rate_limit_returns_true(self):
        """Message containing 'rate limit' is transient."""
        exc = Exception("Rate limit exceeded")

        assert _is_transient_error(exc) is True

    def test_message_with_overloaded_returns_true(self):
        """Message containing 'overloaded' is transient."""
        exc = Exception("Service overloaded")

        assert _is_transient_error(exc) is True

    def test_message_with_too_many_requests_returns_true(self):
        """Message containing 'too many requests' is transient."""
        exc = Exception("Too many requests, please slow down")

        assert _is_transient_error(exc) is True

    def test_message_with_temporarily_unavailable_returns_true(self):
        """Message containing 'temporarily unavailable' is transient."""
        exc = Exception("Service temporarily unavailable")

        assert _is_transient_error(exc) is True

    def test_message_with_service_unavailable_returns_true(self):
        """Message containing 'service unavailable' is transient."""
        exc = Exception("The service unavailable at this time")

        assert _is_transient_error(exc) is True

    def test_unrelated_exception_returns_false(self):
        """Exception without transient indicators returns False."""
        exc = ValueError("Invalid input")

        assert _is_transient_error(exc) is False


class TestExtractStatusCode:
    """Tests for _extract_status_code() private helper."""

    def test_status_code_attribute_returns_value(self):
        """Exception with .status_code attribute returns that value."""
        exc = ExceptionWithStatusCode("Error", 429)

        assert _extract_status_code(exc) == 429

    def test_response_status_code_attribute_returns_value(self):
        """Exception with .response.status_code returns that value."""
        exc = ExceptionWithResponse("Error", 503)

        assert _extract_status_code(exc) == 503

    def test_code_attribute_int_returns_value(self):
        """Exception with .code as int returns that value."""
        exc = ExceptionWithCode("Error", 500)

        assert _extract_status_code(exc) == 500

    def test_code_attribute_string_converts_to_int(self):
        """Exception with .code as string converts to int."""
        exc = ExceptionWithCode("Error", "404")

        assert _extract_status_code(exc) == 404

    def test_no_status_code_returns_none(self):
        """Exception without status code attributes returns None."""
        exc = Exception("Generic error")

        assert _extract_status_code(exc) is None

    def test_code_non_convertible_returns_none(self):
        """Exception with .code non-convertible to int returns None."""
        exc = ExceptionWithCode("Error", "not-a-number")

        assert _extract_status_code(exc) is None

    def test_code_none_returns_none(self):
        """Exception with .code=None returns None."""
        exc = ExceptionWithCode("Error", None)

        assert _extract_status_code(exc) is None


class TestCalculateDelay:
    """Tests for _calculate_delay() private helper."""

    def test_attempt_1_base_1_returns_1_plus_jitter(self):
        """Attempt 1 with base_delay=1.0 returns value in [1.0, 2.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.5):
            delay = _calculate_delay(1, 1.0)

        assert delay == 1.5

    def test_attempt_2_base_1_returns_2_plus_jitter(self):
        """Attempt 2 with base_delay=1.0 returns value in [2.0, 3.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.5):
            delay = _calculate_delay(2, 1.0)

        assert delay == 2.5

    def test_attempt_3_base_1_returns_4_plus_jitter(self):
        """Attempt 3 with base_delay=1.0 returns value in [4.0, 5.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.5):
            delay = _calculate_delay(3, 1.0)

        assert delay == 4.5

    def test_attempt_4_base_1_returns_8_plus_jitter(self):
        """Attempt 4 with base_delay=1.0 returns value in [8.0, 9.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.5):
            delay = _calculate_delay(4, 1.0)

        assert delay == 8.5

    def test_attempt_1_base_2_returns_2_plus_jitter(self):
        """Attempt 1 with base_delay=2.0 returns value in [2.0, 3.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.5):
            delay = _calculate_delay(1, 2.0)

        assert delay == 2.5

    def test_attempt_2_base_2_returns_4_plus_jitter(self):
        """Attempt 2 with base_delay=2.0 returns value in [4.0, 5.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.5):
            delay = _calculate_delay(2, 2.0)

        assert delay == 4.5

    def test_jitter_zero_at_minimum(self):
        """Jitter can be zero (random.random returns 0.0)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.0):
            delay = _calculate_delay(1, 1.0)

        assert delay == 1.0

    def test_jitter_just_under_one_at_maximum(self):
        """Jitter can be just under 1.0 (random.random returns 0.999...)."""
        with patch("unfamiliar.llm.retry.random.random", return_value=0.9999):
            delay = _calculate_delay(1, 1.0)

        assert 1.999 < delay < 2.0


class TestCalculateDelayPropertyBased:
    """Property-based tests for _calculate_delay() invariant."""

    @given(
        attempt=st.integers(min_value=1, max_value=10),
        base_delay=st.floats(min_value=0.1, max_value=5.0),
    )
    @settings(max_examples=100)
    def test_delay_within_expected_bounds(self, attempt: int, base_delay: float):
        """Delay is always in
        [base_delay * 2^(attempt-1), base_delay * 2^(attempt-1) + 1.0).
        """
        delay = _calculate_delay(attempt, base_delay)
        expected_base = base_delay * (2 ** (attempt - 1))

        assert delay >= expected_base
        assert delay < expected_base + 1.0


class TestExtractResponseBody:
    """Tests for _extract_response_body() private helper."""

    def test_httpx_error_with_text(self):
        """HTTPStatusError with .response.text returns body."""
        from unfamiliar.llm.retry import _extract_response_body

        response = MagicMock()
        response.text = '{"error": "rate limit exceeded"}'
        exc = Exception("Rate limited")
        exc.response = response  # type: ignore[attr-defined]

        body = _extract_response_body(exc)

        assert body == '{"error": "rate limit exceeded"}'

    def test_wrapped_exception_with_response_text(self):
        """Wrapped exception with __cause__.response.text returns body."""
        from unfamiliar.llm.retry import _extract_response_body

        # Create original exception
        response = MagicMock()
        response.text = '{"error": "original error"}'
        original = Exception("Original")
        original.response = response  # type: ignore[attr-defined]

        # Wrap it in another exception
        wrapped = ValueError("Wrapped")
        wrapped.__cause__ = original

        body = _extract_response_body(wrapped)

        assert body == '{"error": "original error"}'

    def test_exception_without_response_returns_none(self):
        """Exception without response attribute returns None."""
        from unfamiliar.llm.retry import _extract_response_body

        exc = ValueError("No response here")
        body = _extract_response_body(exc)

        assert body is None

    def test_response_without_text_returns_none(self):
        """Response object without text attribute returns None."""
        from unfamiliar.llm.retry import _extract_response_body

        exc = MagicMock()
        exc.response = MagicMock(spec=[])  # No .text attribute

        body = _extract_response_body(exc)

        assert body is None

    def test_truncates_long_body(self):
        """Response body longer than 500 chars is truncated."""
        from unfamiliar.llm.retry import _extract_response_body

        response = MagicMock()
        response.text = "x" * 600  # 600 characters
        exc = Exception("Long body")
        exc.response = response  # type: ignore[attr-defined]

        body = _extract_response_body(exc)

        assert len(body) == 500
        assert body == "x" * 500


class TestExtractRateLimitInfo:
    """Tests for _extract_rate_limit_info() private helper."""

    def test_retry_after_header(self):
        """Retry-After header is extracted."""
        from unfamiliar.llm.retry import _extract_rate_limit_info

        response = MagicMock()
        response.headers = {"Retry-After": "30"}
        exc = Exception("Rate limited")
        exc.response = response  # type: ignore[attr-defined]

        info = _extract_rate_limit_info(exc)

        assert info == "Retry-After: 30"

    def test_multiple_headers(self):
        """Multiple rate limit headers are extracted."""
        from unfamiliar.llm.retry import _extract_rate_limit_info

        response = MagicMock()
        response.headers = {
            "Retry-After": "60",
            "X-RateLimit-Remaining": "0",
        }
        exc = Exception("Rate limited")
        exc.response = response  # type: ignore[attr-defined]

        info = _extract_rate_limit_info(exc)

        assert "Retry-After: 60" in info
        assert "X-RateLimit-Remaining: 0" in info

    def test_no_headers_returns_none(self):
        """Exception without rate limit headers returns None."""
        from unfamiliar.llm.retry import _extract_rate_limit_info

        response = MagicMock()
        response.headers = {}
        exc = Exception("No headers")
        exc.response = response  # type: ignore[attr-defined]

        info = _extract_rate_limit_info(exc)

        assert info is None

    def test_wrapped_exception_headers(self):
        """Wrapped exception with __cause__ checks headers."""
        from unfamiliar.llm.retry import _extract_rate_limit_info

        response = MagicMock()
        response.headers = {"X-RateLimit-Reset": "2024-01-01T00:00:00Z"}
        original = Exception("Rate limited")
        original.response = response  # type: ignore[attr-defined]

        wrapped = ValueError("Wrapped")
        wrapped.__cause__ = original

        info = _extract_rate_limit_info(wrapped)

        assert info == "X-RateLimit-Reset: 2024-01-01T00:00:00Z"
